1use std::{error::Error, marker::PhantomData};
2
3use serde::{Deserialize, Serialize};
4
5use crate::{
6 shared::{
7 errors::FieldError,
8 helpers::{ensure_value_is_not_empty, merge_errors, sanitize_node_name},
9 macros::states,
10 node::EnvVar,
11 resources::{Resources, ResourcesBuilder},
12 },
13 types::{Arg, Command, Image, Port},
14 utils::is_false,
15};
16
17states! {
18 WithName,
19 WithOutName
20}
21
22states! {
23 WithCmd,
24 WithOutCmd
25}
26
27pub trait Cmd {}
28impl Cmd for WithOutCmd {}
29impl Cmd for WithCmd {}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct NamedPort {
41 pub name: String,
42 pub port: Port,
43}
44
45fn validate_port_name(name: &str) -> Result<(), anyhow::Error> {
48 if name.is_empty() || name.len() > 15 {
49 return Err(anyhow::anyhow!(
50 "port name '{name}' must be 1 to 15 characters long"
51 ));
52 }
53 if !name
54 .chars()
55 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
56 {
57 return Err(anyhow::anyhow!(
58 "port name '{name}' may only contain lowercase letters, digits and '-'"
59 ));
60 }
61 if !name.chars().any(|c| c.is_ascii_lowercase()) {
62 return Err(anyhow::anyhow!(
63 "port name '{name}' must contain at least one letter"
64 ));
65 }
66 if name.starts_with('-') || name.ends_with('-') || name.contains("--") {
67 return Err(anyhow::anyhow!(
68 "port name '{name}' may not start or end with '-' or contain '--'"
69 ));
70 }
71 Ok(())
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct ReadyCheck {
81 #[serde(flatten)]
82 pub target: ReadyTarget,
83 #[serde(default = "one", skip_serializing_if = "is_one")]
85 pub interval: u32,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "lowercase")]
91pub enum ReadyTarget {
92 Tcp(String),
94 Http { port: String, path: String },
96}
97
98impl ReadyTarget {
99 pub fn port_name(&self) -> &str {
101 match self {
102 Self::Tcp(port) | Self::Http { port, .. } => port,
103 }
104 }
105
106 pub fn http_path(&self) -> Option<&str> {
108 match self {
109 Self::Tcp(_) => None,
110 Self::Http { path, .. } => Some(path),
111 }
112 }
113}
114
115fn one() -> u32 {
116 1
117}
118
119fn is_one(value: &u32) -> bool {
120 *value == 1
121}
122
123impl ReadyCheck {
124 pub fn tcp(port: impl Into<String>) -> Self {
126 Self {
127 target: ReadyTarget::Tcp(port.into()),
128 interval: 1,
129 }
130 }
131
132 pub fn http(port: impl Into<String>, path: impl Into<String>) -> Self {
134 Self {
135 target: ReadyTarget::Http {
136 port: port.into(),
137 path: path.into(),
138 },
139 interval: 1,
140 }
141 }
142
143 pub fn with_interval(mut self, secs: u32) -> Self {
144 self.interval = secs;
145 self
146 }
147
148 pub fn port_name(&self) -> &str {
150 self.target.port_name()
151 }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "snake_case")]
157pub enum DependencyCondition {
158 ServiceStarted,
160 ServiceHealthy,
162 ServiceCompletedSuccessfully,
164}
165
166impl DependencyCondition {
167 pub fn as_str(&self) -> &'static str {
168 match self {
169 Self::ServiceStarted => "service_started",
170 Self::ServiceHealthy => "service_healthy",
171 Self::ServiceCompletedSuccessfully => "service_completed_successfully",
172 }
173 }
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(from = "DependencyRepr", into = "DependencyRepr")]
182pub struct Dependency {
183 pub name: String,
184 pub condition: Option<DependencyCondition>,
185}
186
187#[derive(Serialize, Deserialize)]
188#[serde(untagged)]
189enum DependencyRepr {
190 Name(String),
191 Full {
192 name: String,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 condition: Option<DependencyCondition>,
195 },
196}
197
198impl From<DependencyRepr> for Dependency {
199 fn from(repr: DependencyRepr) -> Self {
200 match repr {
201 DependencyRepr::Name(name) => Self {
202 name,
203 condition: None,
204 },
205 DependencyRepr::Full { name, condition } => Self { name, condition },
206 }
207 }
208}
209
210impl From<Dependency> for DependencyRepr {
211 fn from(dep: Dependency) -> Self {
212 match dep.condition {
213 None => Self::Name(dep.name),
214 Some(condition) => Self::Full {
215 name: dep.name,
216 condition: Some(condition),
217 },
218 }
219 }
220}
221
222impl Dependency {
223 pub fn condition_on(&self, target: &CustomProcess) -> DependencyCondition {
226 self.condition.unwrap_or_else(|| target.natural_condition())
227 }
228}
229
230impl From<&str> for Dependency {
231 fn from(name: &str) -> Self {
232 Self {
233 name: name.into(),
234 condition: None,
235 }
236 }
237}
238
239pub const MAX_TIMEOUT_SECS: u32 = 7 * 24 * 60 * 60;
241
242#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247pub struct CustomProcess {
248 name: String,
250 #[serde(skip_serializing_if = "Option::is_none")]
252 image: Option<Image>,
253 command: Command,
255 #[serde(skip_serializing_if = "std::vec::Vec::is_empty", default)]
257 args: Vec<Arg>,
258 #[serde(skip_serializing_if = "std::vec::Vec::is_empty", default)]
260 env: Vec<EnvVar>,
261 #[serde(skip_serializing_if = "std::vec::Vec::is_empty", default)]
263 ports: Vec<NamedPort>,
264 #[serde(skip_serializing_if = "Option::is_none")]
266 resources: Option<Resources>,
267 #[serde(skip_serializing_if = "Option::is_none")]
269 ready_check: Option<ReadyCheck>,
270 #[serde(skip_serializing_if = "std::vec::Vec::is_empty", default)]
272 depends_on: Vec<Dependency>,
273 #[serde(skip_serializing_if = "is_false", default)]
275 one_shot: bool,
276 #[serde(skip_serializing_if = "Option::is_none")]
278 timeout: Option<u32>,
279}
280
281impl Default for CustomProcess {
282 fn default() -> Self {
283 Self {
284 name: "".into(),
285 image: None,
286 command: Command::default(), args: vec![],
288 env: vec![],
289 ports: vec![],
290 resources: None,
291 ready_check: None,
292 depends_on: vec![],
293 one_shot: false,
294 timeout: None,
295 }
296 }
297}
298
299impl CustomProcess {
300 pub fn name(&self) -> &str {
302 &self.name
303 }
304
305 pub fn image(&self) -> Option<&Image> {
307 self.image.as_ref()
308 }
309
310 pub fn command(&self) -> &Command {
312 &self.command
313 }
314
315 pub fn args(&self) -> Vec<&Arg> {
317 self.args.iter().collect()
318 }
319
320 pub fn env(&self) -> Vec<&EnvVar> {
322 self.env.iter().collect()
323 }
324
325 pub fn ports(&self) -> &[NamedPort] {
327 &self.ports
328 }
329
330 pub fn resources(&self) -> Option<&Resources> {
332 self.resources.as_ref()
333 }
334
335 pub fn ready_check(&self) -> Option<&ReadyCheck> {
339 self.ready_check.as_ref()
340 }
341
342 pub fn depends_on(&self) -> &[Dependency] {
344 &self.depends_on
345 }
346
347 pub fn one_shot(&self) -> bool {
350 self.one_shot
351 }
352
353 pub fn timeout(&self) -> Option<u32> {
357 self.timeout
358 }
359
360 pub fn effective_check(&self) -> Option<ReadyTarget> {
365 if self.one_shot {
366 return None;
367 }
368 match &self.ready_check {
369 Some(check) => Some(check.target.clone()),
370 None => self.ports.first().map(|p| ReadyTarget::Tcp(p.name.clone())),
371 }
372 }
373
374 pub fn natural_condition(&self) -> DependencyCondition {
378 if self.one_shot {
379 DependencyCondition::ServiceCompletedSuccessfully
380 } else if self.effective_check().is_some() {
381 DependencyCondition::ServiceHealthy
382 } else {
383 DependencyCondition::ServiceStarted
384 }
385 }
386
387 pub fn validate(&self) -> Result<(), Vec<anyhow::Error>> {
390 let mut errors = vec![];
391 let valid_name = sanitize_node_name(&self.name);
392 if self.name.is_empty() {
393 errors.push(FieldError::Name(anyhow::anyhow!("can't be empty")).into());
394 } else if valid_name != self.name {
395 errors.push(
396 FieldError::Name(anyhow::anyhow!(
397 "'{}' must be lowercase letters, digits and '-', starting with a letter and at most 63 characters (e.g. '{valid_name}')",
398 self.name
399 ))
400 .into(),
401 );
402 }
403 let mut names: Vec<&str> = vec![];
404 let mut numbers: Vec<Port> = vec![];
405 for port in &self.ports {
406 if let Err(e) = validate_port_name(&port.name) {
407 errors.push(FieldError::Ports(e).into());
408 }
409 if names.contains(&port.name.as_str()) {
410 errors.push(
411 FieldError::Ports(anyhow::anyhow!(
412 "port name '{}' is declared twice",
413 port.name
414 ))
415 .into(),
416 );
417 }
418 if port.port != 0 && numbers.contains(&port.port) {
419 errors.push(
420 FieldError::Ports(anyhow::anyhow!(
421 "port {} is declared twice (as '{}')",
422 port.port,
423 port.name
424 ))
425 .into(),
426 );
427 }
428 names.push(&port.name);
429 numbers.push(port.port);
430 }
431
432 if let Some(check) = &self.ready_check {
433 if !names.contains(&check.port_name()) {
434 errors.push(
435 FieldError::ReadyCheck(anyhow::anyhow!(
436 "port '{}' is not declared in ports",
437 check.port_name()
438 ))
439 .into(),
440 );
441 }
442 if self.one_shot {
443 errors.push(
444 FieldError::ReadyCheck(anyhow::anyhow!(
445 "a one-shot process runs to completion, it has no readiness"
446 ))
447 .into(),
448 );
449 }
450 if let Some(path) = check.target.http_path() {
451 if !path.starts_with('/') {
452 errors.push(
453 FieldError::ReadyCheck(anyhow::anyhow!(
454 "http path '{path}' must start with '/'"
455 ))
456 .into(),
457 );
458 }
459 }
460 }
461 if self.ready_check.as_ref().is_some_and(|c| c.interval == 0) {
462 errors.push(
463 FieldError::ReadyCheck(anyhow::anyhow!("interval must be at least 1 second"))
464 .into(),
465 );
466 }
467 match self.timeout {
468 Some(0) => errors
469 .push(FieldError::Timeout(anyhow::anyhow!("must be at least 1 second")).into()),
470 Some(secs) if secs > MAX_TIMEOUT_SECS => errors.push(
471 FieldError::Timeout(anyhow::anyhow!(
472 "{secs} is more than the {MAX_TIMEOUT_SECS} seconds (a week) allowed"
473 ))
474 .into(),
475 ),
476 _ => {},
477 }
478 let mut seen: Vec<&str> = vec![];
479 for dep in &self.depends_on {
480 if dep.name == self.name {
481 errors.push(
482 FieldError::DependsOn(anyhow::anyhow!("'{}' depends on itself", dep.name))
483 .into(),
484 );
485 }
486 if seen.contains(&dep.name.as_str()) {
487 errors.push(
488 FieldError::DependsOn(anyhow::anyhow!("'{}' is listed twice", dep.name)).into(),
489 );
490 }
491 seen.push(&dep.name);
492 }
493
494 if errors.is_empty() {
495 Ok(())
496 } else {
497 Err(errors)
498 }
499 }
500}
501
502pub(crate) fn validate_custom_processes(processes: &[CustomProcess]) -> Result<(), Vec<String>> {
507 let by_name: std::collections::HashMap<&str, &CustomProcess> =
508 processes.iter().map(|p| (p.name(), p)).collect();
509 let mut errors = vec![];
510
511 for process in processes {
512 for dep in &process.depends_on {
513 let Some(target) = by_name.get(dep.name.as_str()) else {
514 errors.push(format!(
515 "custom_processes['{}'].depends_on: '{}' is not a custom process",
516 process.name(),
517 dep.name
518 ));
519 continue;
520 };
521 let condition = dep.condition_on(target);
522 let fits = match condition {
523 DependencyCondition::ServiceStarted => true,
524 DependencyCondition::ServiceHealthy => target.effective_check().is_some(),
525 DependencyCondition::ServiceCompletedSuccessfully => target.one_shot,
526 };
527 if !fits {
528 errors.push(format!(
529 "custom_processes['{}'].depends_on: '{}' cannot be {} ({})",
530 process.name(),
531 dep.name,
532 condition.as_str(),
533 if target.one_shot {
534 "it is a one-shot, which completes"
535 } else if condition == DependencyCondition::ServiceHealthy {
536 "it has no ready_check and no port"
537 } else {
538 "it is not a one-shot"
539 }
540 ));
541 }
542 }
543 }
544
545 let mut remaining: Vec<&CustomProcess> = processes.iter().collect();
548 let mut peeled: Vec<&str> = vec![];
549 loop {
550 let (free, blocked): (Vec<&CustomProcess>, Vec<&CustomProcess>) =
551 remaining.iter().partition(|p| {
552 p.depends_on.iter().all(|d| {
553 peeled.contains(&d.name.as_str()) || !by_name.contains_key(d.name.as_str())
554 })
555 });
556 if free.is_empty() {
557 break;
558 }
559 peeled.extend(free.iter().map(|p| p.name()));
560 remaining = blocked;
561 }
562 if !remaining.is_empty() {
563 let mut names: Vec<&str> = remaining.iter().map(|p| p.name()).collect();
564 names.sort();
565 errors.push(format!(
566 "custom_processes: depends_on forms a cycle among {}",
567 names.join(", ")
568 ));
569 }
570
571 if errors.is_empty() {
572 Ok(())
573 } else {
574 Err(errors)
575 }
576}
577
578pub struct CustomProcessBuilder<N, C> {
580 config: CustomProcess,
581 errors: Vec<anyhow::Error>,
582 _state_name: PhantomData<N>,
583 _state_cmd: PhantomData<C>,
584}
585
586impl Default for CustomProcessBuilder<WithOutName, WithOutCmd> {
587 fn default() -> Self {
588 Self {
589 config: CustomProcess::default(),
590 errors: vec![],
591 _state_name: PhantomData,
592 _state_cmd: PhantomData,
593 }
594 }
595}
596
597impl<A, B> CustomProcessBuilder<A, B> {
598 fn transition<C, D>(
599 config: CustomProcess,
600 errors: Vec<anyhow::Error>,
601 ) -> CustomProcessBuilder<C, D> {
602 CustomProcessBuilder {
603 config,
604 errors,
605 _state_name: PhantomData,
606 _state_cmd: PhantomData,
607 }
608 }
609}
610
611impl CustomProcessBuilder<WithOutName, WithOutCmd> {
612 pub fn new() -> CustomProcessBuilder<WithOutName, WithOutCmd> {
613 CustomProcessBuilder::default()
614 }
615}
616
617impl<C: Cmd> CustomProcessBuilder<WithOutName, C> {
618 pub fn with_name<T: Into<String> + Copy>(self, name: T) -> CustomProcessBuilder<WithName, C> {
620 let name: String = name.into();
621
622 match ensure_value_is_not_empty(&name) {
623 Ok(_) => Self::transition(
624 CustomProcess {
625 name,
626 ..self.config
627 },
628 self.errors,
629 ),
630 Err(e) => Self::transition(
631 CustomProcess {
632 name,
634 ..self.config
635 },
636 merge_errors(self.errors, FieldError::Name(e).into()),
637 ),
638 }
639 }
640}
641
642impl CustomProcessBuilder<WithName, WithOutCmd> {
643 pub fn with_command<T>(self, command: T) -> CustomProcessBuilder<WithName, WithCmd>
645 where
646 T: TryInto<Command>,
647 T::Error: Error + Send + Sync + 'static,
648 {
649 match command.try_into() {
650 Ok(command) => Self::transition(
651 CustomProcess {
652 command,
653 ..self.config
654 },
655 self.errors,
656 ),
657 Err(error) => Self::transition(
658 self.config,
659 merge_errors(self.errors, FieldError::Command(error.into()).into()),
660 ),
661 }
662 }
663}
664
665impl CustomProcessBuilder<WithName, WithCmd> {
666 pub fn with_image<T>(self, image: T) -> Self
668 where
669 T: TryInto<Image>,
670 T::Error: Error + Send + Sync + 'static,
671 {
672 match image.try_into() {
673 Ok(image) => Self::transition(
674 CustomProcess {
675 image: Some(image),
676 ..self.config
677 },
678 self.errors,
679 ),
680 Err(error) => Self::transition(
681 self.config,
682 merge_errors(self.errors, FieldError::Image(error.into()).into()),
683 ),
684 }
685 }
686
687 pub fn with_args(self, args: Vec<Arg>) -> Self {
689 Self::transition(
690 CustomProcess {
691 args,
692 ..self.config
693 },
694 self.errors,
695 )
696 }
697
698 pub fn with_env(self, env: Vec<impl Into<EnvVar>>) -> Self {
700 let env = env.into_iter().map(|var| var.into()).collect::<Vec<_>>();
701
702 Self::transition(CustomProcess { env, ..self.config }, self.errors)
703 }
704
705 pub fn with_named_port(self, name: impl Into<String>, port: Port) -> Self {
708 let mut ports = self.config.ports;
709 ports.push(NamedPort {
710 name: name.into(),
711 port,
712 });
713
714 Self::transition(
715 CustomProcess {
716 ports,
717 ..self.config
718 },
719 self.errors,
720 )
721 }
722
723 pub fn with_ready_check(self, check: ReadyCheck) -> Self {
725 Self::transition(
726 CustomProcess {
727 ready_check: Some(check),
728 ..self.config
729 },
730 self.errors,
731 )
732 }
733
734 pub fn with_dependency(self, name: impl Into<String>) -> Self {
737 self.with_dependency_on(name, None)
738 }
739
740 pub fn with_dependency_on(
742 self,
743 name: impl Into<String>,
744 condition: impl Into<Option<DependencyCondition>>,
745 ) -> Self {
746 let mut depends_on = self.config.depends_on;
747 depends_on.push(Dependency {
748 name: name.into(),
749 condition: condition.into(),
750 });
751 Self::transition(
752 CustomProcess {
753 depends_on,
754 ..self.config
755 },
756 self.errors,
757 )
758 }
759
760 pub fn with_timeout(self, secs: u32) -> Self {
764 Self::transition(
765 CustomProcess {
766 timeout: Some(secs),
767 ..self.config
768 },
769 self.errors,
770 )
771 }
772
773 pub fn with_one_shot(self) -> Self {
776 Self::transition(
777 CustomProcess {
778 one_shot: true,
779 ..self.config
780 },
781 self.errors,
782 )
783 }
784
785 pub fn with_resources(self, f: impl FnOnce(ResourcesBuilder) -> ResourcesBuilder) -> Self {
787 match f(ResourcesBuilder::new()).build() {
788 Ok(resources) => Self::transition(
789 CustomProcess {
790 resources: Some(resources),
791 ..self.config
792 },
793 self.errors,
794 ),
795 Err(errors) => Self::transition(
796 self.config,
797 errors.into_iter().fold(self.errors, |acc, e| {
798 merge_errors(acc, FieldError::Resources(e).into())
799 }),
800 ),
801 }
802 }
803
804 pub fn build(self) -> Result<CustomProcess, (String, Vec<anyhow::Error>)> {
806 let mut errors = self.errors;
807 if let Err(field_errors) = self.config.validate() {
808 errors.extend(field_errors);
809 }
810
811 if !errors.is_empty() {
812 return Err((self.config.name.clone(), errors));
813 }
814
815 Ok(self.config)
816 }
817}
818
819#[cfg(test)]
820mod tests {
821 use super::*;
822
823 #[test]
824 fn custom_process_config_builder_should_succeeds_and_returns_a_custom_process_config() {
825 let cpb = CustomProcessBuilder::new()
826 .with_name("demo")
827 .with_command("some")
828 .with_args(vec![("--port", "100").into(), "--custom-flag".into()])
829 .build()
830 .unwrap();
831
832 assert_eq!(cpb.command().as_str(), "some");
833 let args: Vec<Arg> = vec![("--port", "100").into(), "--custom-flag".into()];
834 assert_eq!(cpb.args(), args.iter().collect::<Vec<&Arg>>());
835 assert!(cpb.ports().is_empty());
836 assert!(cpb.resources().is_none());
837 }
838
839 #[test]
840 fn ports_and_resources_are_carried_and_round_trip() {
841 let cp = CustomProcessBuilder::new()
842 .with_name("ipfs")
843 .with_command("ipfs")
844 .with_image("docker.io/ipfs/kubo:v0.39.0")
845 .with_named_port("api", 5001)
846 .with_named_port("gateway", 8080)
847 .with_resources(|r| r.with_limit_cpu("500m").with_limit_memory("512Mi"))
848 .build()
849 .unwrap();
850
851 assert_eq!(
852 cp.ports(),
853 &[
854 NamedPort {
855 name: "api".into(),
856 port: 5001
857 },
858 NamedPort {
859 name: "gateway".into(),
860 port: 8080
861 }
862 ]
863 );
864 assert_eq!(
865 cp.resources().unwrap().limit_cpu().unwrap().as_str(),
866 "500m"
867 );
868
869 let json = serde_json::to_value(&cp).unwrap();
870 assert_eq!(json["ports"][1]["name"], "gateway");
871 assert_eq!(json["ports"][1]["port"], 8080);
872 let back: CustomProcess = serde_json::from_value(json).unwrap();
873 assert_eq!(back, cp);
874 }
875
876 #[test]
877 fn a_process_without_ports_serializes_as_before() {
878 let cp = CustomProcessBuilder::new()
879 .with_name("demo")
880 .with_command("some")
881 .build()
882 .unwrap();
883 let json = serde_json::to_value(&cp).unwrap();
884 assert!(json.get("ports").is_none());
885 assert!(json.get("resources").is_none());
886 }
887
888 #[test]
889 fn duplicate_port_names_and_numbers_are_rejected() {
890 let err = CustomProcessBuilder::new()
891 .with_name("demo")
892 .with_command("some")
893 .with_named_port("api", 5001)
894 .with_named_port("api", 5002)
895 .build()
896 .unwrap_err();
897 assert_eq!(err.0, "demo");
898 assert!(err.1[0].to_string().contains("declared twice"));
899
900 let err = CustomProcessBuilder::new()
901 .with_name("demo")
902 .with_command("some")
903 .with_named_port("api", 5001)
904 .with_named_port("other", 5001)
905 .build()
906 .unwrap_err();
907 assert!(err.1[0].to_string().contains("port 5001 is declared twice"));
908
909 CustomProcessBuilder::new()
911 .with_name("demo")
912 .with_command("some")
913 .with_named_port("api", 0)
914 .with_named_port("gateway", 0)
915 .build()
916 .unwrap();
917 }
918
919 #[test]
920 fn lifecycle_fields_round_trip_and_validate() {
921 let init = CustomProcessBuilder::new()
922 .with_name("init")
923 .with_command("sh")
924 .with_one_shot()
925 .build()
926 .unwrap();
927 let api = CustomProcessBuilder::new()
928 .with_name("api")
929 .with_command("api")
930 .with_named_port("http", 8080)
931 .with_ready_check(ReadyCheck::http("http", "/health"))
932 .with_timeout(30)
933 .with_dependency("init")
934 .with_dependency_on("db", DependencyCondition::ServiceStarted)
935 .build()
936 .unwrap();
937 let db = CustomProcessBuilder::new()
938 .with_name("db")
939 .with_command("postgres")
940 .with_named_port("pg", 5432)
941 .build()
942 .unwrap();
943
944 assert_eq!(
945 init.natural_condition(),
946 DependencyCondition::ServiceCompletedSuccessfully
947 );
948 assert_eq!(db.natural_condition(), DependencyCondition::ServiceHealthy);
949 assert_eq!(api.ready_check().unwrap().port_name(), "http");
950 assert_eq!(api.timeout(), Some(30));
951 assert_eq!(
952 api.effective_check(),
953 Some(ReadyTarget::Http {
954 port: "http".into(),
955 path: "/health".into()
956 })
957 );
958 assert_eq!(db.effective_check(), Some(ReadyTarget::Tcp("pg".into())));
959 assert_eq!(init.effective_check(), None);
960
961 let toml_str = toml::to_string(&api).unwrap();
962 assert!(
963 toml_str.contains(
964 "depends_on = [\"init\", { name = \"db\", condition = \"service_started\" }]"
965 ),
966 "{toml_str}"
967 );
968 assert!(toml_str.contains("[ready_check.http]"), "{toml_str}");
969 let back: CustomProcess = toml::from_str(&toml_str).unwrap();
970 assert_eq!(back, api);
971
972 validate_custom_processes(&[init.clone(), db.clone(), api.clone()]).unwrap();
973 }
974
975 #[test]
976 fn lifecycle_rules_are_enforced() {
977 let errors = CustomProcessBuilder::new()
979 .with_name("bad")
980 .with_command("sh")
981 .with_one_shot()
982 .with_ready_check(ReadyCheck::tcp("nope"))
983 .build()
984 .unwrap_err()
985 .1;
986 let text: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
987 assert!(
988 text.iter()
989 .any(|e| e.contains("port 'nope' is not declared")),
990 "{text:?}"
991 );
992 assert!(
993 text.iter()
994 .any(|e| e.contains("one-shot process runs to completion")),
995 "{text:?}"
996 );
997
998 let errors = CustomProcessBuilder::new()
1000 .with_name("slow")
1001 .with_command("sh")
1002 .with_named_port("p", 1)
1003 .with_ready_check(ReadyCheck::tcp("p").with_interval(0))
1004 .with_timeout(0)
1005 .build()
1006 .unwrap_err()
1007 .1;
1008 let text: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
1009 assert!(
1010 text.iter()
1011 .any(|e| e.contains("interval must be at least 1")),
1012 "{text:?}"
1013 );
1014 assert!(
1015 text.iter()
1016 .any(|e| e.contains("timeout: must be at least 1")),
1017 "{text:?}"
1018 );
1019 let errors = CustomProcessBuilder::new()
1020 .with_name("forever")
1021 .with_command("sh")
1022 .with_timeout(u32::MAX)
1023 .build()
1024 .unwrap_err()
1025 .1;
1026 assert!(
1027 errors.iter().any(|e| e.to_string().contains("a week")),
1028 "{errors:?}"
1029 );
1030
1031 let errors = CustomProcessBuilder::new()
1033 .with_name("me")
1034 .with_command("sh")
1035 .with_dependency("me")
1036 .with_dependency("other")
1037 .with_dependency("other")
1038 .build()
1039 .unwrap_err()
1040 .1;
1041 let text: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
1042 assert!(
1043 text.iter().any(|e| e.contains("depends on itself")),
1044 "{text:?}"
1045 );
1046 assert!(text.iter().any(|e| e.contains("listed twice")), "{text:?}");
1047
1048 let mk = |name: &str, deps: Vec<Dependency>, one_shot: bool| {
1050 let mut b = CustomProcessBuilder::new()
1051 .with_name(name)
1052 .with_command("sh");
1053 for d in deps {
1054 b = b.with_dependency_on(d.name, d.condition);
1055 }
1056 if one_shot {
1057 b = b.with_one_shot();
1058 }
1059 b.build().unwrap()
1060 };
1061 let errors = validate_custom_processes(&[
1062 mk("a", vec!["ghost".into()], false),
1063 mk(
1064 "b",
1065 vec![Dependency {
1066 name: "a".into(),
1067 condition: Some(DependencyCondition::ServiceHealthy),
1068 }],
1069 false,
1070 ),
1071 mk(
1072 "c",
1073 vec![Dependency {
1074 name: "a".into(),
1075 condition: Some(DependencyCondition::ServiceCompletedSuccessfully),
1076 }],
1077 false,
1078 ),
1079 mk("x", vec!["y".into()], false),
1080 mk("y", vec!["x".into()], false),
1081 ])
1082 .unwrap_err();
1083 assert!(
1084 errors
1085 .iter()
1086 .any(|e| e.contains("'ghost' is not a custom process")),
1087 "{errors:?}"
1088 );
1089 assert!(
1090 errors
1091 .iter()
1092 .any(|e| e.contains("'a' cannot be service_healthy")),
1093 "{errors:?}"
1094 );
1095 assert!(
1096 errors
1097 .iter()
1098 .any(|e| e.contains("'a' cannot be service_completed_successfully")),
1099 "{errors:?}"
1100 );
1101 assert!(
1102 errors.iter().any(|e| e.contains("cycle among x, y")),
1103 "{errors:?}"
1104 );
1105 }
1106
1107 #[test]
1108 fn port_names_must_be_valid_service_names() {
1109 for bad in [
1110 "",
1111 "json_rpc",
1112 "Gateway",
1113 "a-name-that-is-too-long",
1114 "8080",
1115 "-api",
1116 "api--x",
1117 ] {
1118 let err = CustomProcessBuilder::new()
1119 .with_name("demo")
1120 .with_command("some")
1121 .with_named_port(bad, 5001)
1122 .build()
1123 .unwrap_err();
1124 assert!(
1125 err.1[0].to_string().starts_with("ports: port name"),
1126 "{bad:?} should be rejected, got {}",
1127 err.1[0]
1128 );
1129 }
1130 for good in ["api", "rpc-http", "p2p", "a", "x-15-chars-long"] {
1131 CustomProcessBuilder::new()
1132 .with_name("demo")
1133 .with_command("some")
1134 .with_named_port(good, 5001)
1135 .build()
1136 .unwrap_or_else(|e| panic!("{good:?} should be accepted, got {:?}", e.1));
1137 }
1138 }
1139}