Skip to main content

zombienet_configuration/
custom_process.rs

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/// A port a custom process listens on, by name.
32///
33/// The name is what the port is exposed under (a k8s `Service` port, the key
34/// the running process reports its addresses by), so it has to be a valid
35/// service name: 1 to 15 lowercase letters, digits or `-`, with a letter in
36/// it. `port` is the number the process listens on; `0` lets zombienet pick a
37/// free one at spawn time, handed to the process through
38/// `{{ZOMBIE:<process>:port_<name>}}` in its args or env.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct NamedPort {
41    pub name: String,
42    pub port: Port,
43}
44
45/// k8s' rule for a named port (IANA_SVC_NAME), which is the strictest of the
46/// places a port name ends up.
47fn 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/// How a custom process is known to be ready. `tcp` is a declared port name
75/// that must accept a connection; `http` is a declared port name and a path
76/// that must answer 2xx. On kubernetes the check is the pod's readiness
77/// probe; elsewhere zombienet probes the port from where it runs. How long
78/// to wait is the process's `timeout`.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct ReadyCheck {
81    #[serde(flatten)]
82    pub target: ReadyTarget,
83    /// Seconds between two attempts.
84    #[serde(default = "one", skip_serializing_if = "is_one")]
85    pub interval: u32,
86}
87
88/// What a [`ReadyCheck`] probes, by declared port name.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "lowercase")]
91pub enum ReadyTarget {
92    /// The named port accepts a TCP connection.
93    Tcp(String),
94    /// `GET <path>` on the named port answers 2xx.
95    Http { port: String, path: String },
96}
97
98impl ReadyTarget {
99    /// The name of the port the check probes.
100    pub fn port_name(&self) -> &str {
101        match self {
102            Self::Tcp(port) | Self::Http { port, .. } => port,
103        }
104    }
105
106    /// The path a `GET` must answer 2xx on; `None` for a TCP connect.
107    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    /// A check that passes once the port named `port` accepts a connection.
125    pub fn tcp(port: impl Into<String>) -> Self {
126        Self {
127            target: ReadyTarget::Tcp(port.into()),
128            interval: 1,
129        }
130    }
131
132    /// A check that passes once `GET <path>` on the port named `port` answers 2xx.
133    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    /// The name of the port the check probes.
149    pub fn port_name(&self) -> &str {
150        self.target.port_name()
151    }
152}
153
154/// When a dependency counts as met, as docker-compose names them.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "snake_case")]
157pub enum DependencyCondition {
158    /// The process started.
159    ServiceStarted,
160    /// The process passed its ready check.
161    ServiceHealthy,
162    /// The one-shot process exited 0.
163    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/// Another custom process this one starts after. Written as a bare name
177/// (`"postgres"`) or as `{ name = "init", condition = "..." }`; without a
178/// condition the target's natural one applies, see
179/// [`CustomProcess::natural_condition`].
180#[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    /// The condition this dependency means on `target`: the one named, or
224    /// the target's natural one.
225    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
239/// The longest `timeout` accepted: a week, in seconds.
240pub const MAX_TIMEOUT_SECS: u32 = 7 * 24 * 60 * 60;
241
242/// A custom process to spawn next to the nodes: its `command`, `args`, `env`,
243/// `image` (provider specific), named `ports`, `resources` (provider
244/// specific), and its lifecycle: `ready_check`, `depends_on`, `one_shot` and
245/// `timeout`.
246#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247pub struct CustomProcess {
248    // Name of the process
249    name: String,
250    // Image to use
251    #[serde(skip_serializing_if = "Option::is_none")]
252    image: Option<Image>,
253    // Command to execute
254    command: Command,
255    // Arguments to pass
256    #[serde(skip_serializing_if = "std::vec::Vec::is_empty", default)]
257    args: Vec<Arg>,
258    // Environment to set
259    #[serde(skip_serializing_if = "std::vec::Vec::is_empty", default)]
260    env: Vec<EnvVar>,
261    // Named ports the process listens on
262    #[serde(skip_serializing_if = "std::vec::Vec::is_empty", default)]
263    ports: Vec<NamedPort>,
264    // Resources to apply (only k8s)
265    #[serde(skip_serializing_if = "Option::is_none")]
266    resources: Option<Resources>,
267    // How the process is known to be ready
268    #[serde(skip_serializing_if = "Option::is_none")]
269    ready_check: Option<ReadyCheck>,
270    // Processes this one starts after
271    #[serde(skip_serializing_if = "std::vec::Vec::is_empty", default)]
272    depends_on: Vec<Dependency>,
273    // Runs to completion instead of staying up
274    #[serde(skip_serializing_if = "is_false", default)]
275    one_shot: bool,
276    // Seconds to wait for the condition: the ready check, or a one-shot's exit
277    #[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(), // should be changed.
287            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    /// Node name.
301    pub fn name(&self) -> &str {
302        &self.name
303    }
304
305    /// Image to run (only podman/k8s).
306    pub fn image(&self) -> Option<&Image> {
307        self.image.as_ref()
308    }
309
310    /// Command to run the node.
311    pub fn command(&self) -> &Command {
312        &self.command
313    }
314
315    /// Arguments to use for node.
316    pub fn args(&self) -> Vec<&Arg> {
317        self.args.iter().collect()
318    }
319
320    /// Environment variables to set (inside pod for podman/k8s, inside shell for native).
321    pub fn env(&self) -> Vec<&EnvVar> {
322        self.env.iter().collect()
323    }
324
325    /// Named ports the process listens on.
326    pub fn ports(&self) -> &[NamedPort] {
327        &self.ports
328    }
329
330    /// Resources to apply to the process (only k8s).
331    pub fn resources(&self) -> Option<&Resources> {
332        self.resources.as_ref()
333    }
334
335    /// How the process is known to be ready, if declared. Without one, a
336    /// process with ports is ready when its first port accepts a connection,
337    /// and a process without ports is ready once started.
338    pub fn ready_check(&self) -> Option<&ReadyCheck> {
339        self.ready_check.as_ref()
340    }
341
342    /// The processes this one starts after.
343    pub fn depends_on(&self) -> &[Dependency] {
344        &self.depends_on
345    }
346
347    /// Whether the process runs to completion (exit 0 is success) instead of
348    /// staying up.
349    pub fn one_shot(&self) -> bool {
350        self.one_shot
351    }
352
353    /// Seconds to wait for the process to meet its condition (the ready check
354    /// to pass, or a one-shot to exit); the global `node_spawn_timeout` when
355    /// absent.
356    pub fn timeout(&self) -> Option<u32> {
357        self.timeout
358    }
359
360    /// What the process is waited on: the `ready_check`, or a TCP connect on
361    /// the first declared port when there is none. `None` for a one-shot
362    /// (waited on to exit) and for a process with nothing to check (ready
363    /// once started).
364    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    /// The condition a dependency on this process means when it names none:
375    /// completed for a one-shot, healthy when there is something to check,
376    /// started otherwise.
377    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    /// The field rules shared by the builder and the TOML loader. What is
388    /// checked at spawn follows from them, see [`Self::effective_check`].
389    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
502/// The rules between processes, applied once the whole set is known: every
503/// dependency names a custom process, its condition fits the target (only a
504/// one-shot completes, only a process with something to check is healthy),
505/// and there is no cycle. Errors are prefixed with the process they are on.
506pub(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    // A cycle: some process never becomes startable. Peel off the ones whose
546    // dependencies are all peeled; what is left is on a cycle.
547    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
578/// A custom process builder, used to build a [`CustomProcess`] declaratively with fields validation.
579pub 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    /// set the name of the process.
619    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                    // we still set the name in error case to display error path
633                    name,
634                    ..self.config
635                },
636                merge_errors(self.errors, FieldError::Name(e).into()),
637            ),
638        }
639    }
640}
641
642impl CustomProcessBuilder<WithName, WithOutCmd> {
643    /// Set the command that will be executed to spawn the process.
644    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    /// Set the image that will be used for the node (only podman/k8s).
667    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    /// Set the arguments that will be used when spawn the process.
688    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    /// Set the  environment variables that will be used when spawn the process.
699    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    /// Declare a named port the process listens on; `0` lets zombienet pick a
706    /// free one at spawn time. Names and numbers are checked in [`Self::build`].
707    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    /// Declare how the process is known to be ready; see [`ReadyCheck`].
724    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    /// Start after `name` has met its natural condition (completed for a
735    /// one-shot, healthy when it has a check or a port, started otherwise).
736    pub fn with_dependency(self, name: impl Into<String>) -> Self {
737        self.with_dependency_on(name, None)
738    }
739
740    /// Start after `name` has met `condition`, or its natural one when `None`.
741    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    /// Seconds to wait for the process to meet its condition (the ready check
761    /// to pass, or a one-shot to exit); the global `node_spawn_timeout`
762    /// otherwise.
763    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    /// The process runs to completion: exit 0 is success, anything else a
774    /// failure; others may depend on it with `service_completed_successfully`.
775    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    /// Set the resources to apply to the process (only k8s).
786    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    /// Seals the builder and returns a [`CustomProcess`] if there are no validation errors, else returns errors.
805    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        // Two ports left for zombienet to pick never collide.
910        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        // ready_check on a port that is not declared, and on a one-shot
978        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        // zero and absurd durations
999        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        // self dependency and a duplicate
1032        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        // across processes: unknown target, condition that does not fit, a cycle
1049        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}