Skip to main content

zombienet_orchestrator/network/node/
process.rs

1//! Custom processes: the services a network config declares under
2//! `[[custom_processes]]`, running next to the nodes.
3//!
4//! A [`CustomProcessNode`] is a member of the network like any other node:
5//! it is tracked on [`Network`](crate::network::Network), written to
6//! `zombie.json`, recreated on attach and destroyed with the namespace. It
7//! has no chain, so the only protocol-level surface it exposes is the set of
8//! ports it declared and where each one is reachable.
9
10use std::{collections::BTreeMap, fmt, net::IpAddr, time::Duration};
11
12use async_trait::async_trait;
13use configuration::{types::Port, CustomProcess};
14use provider::types::ProcessStatus;
15use serde::{Deserialize, Serialize};
16use tracing::debug;
17
18use super::{
19    core::NodeCore,
20    spawned::{NodeKind, SpawnedNode},
21};
22
23/// Where one declared port of a custom process is reachable.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct ProcessPort {
26    /// The port the process listens on: as declared, or the one picked for `0`.
27    pub port: Port,
28    /// `host:port` as reachable from where zombienet runs: a port-forward in
29    /// k8s (unless running in CI, where it is the pod's own address), the
30    /// published host port on docker, the port itself on native.
31    pub external: String,
32    /// `host:port` as reachable from inside the network (other nodes and
33    /// processes): the process's Service in k8s (`<name>:<port>`), the
34    /// container ip on docker, the same as `external` on native.
35    pub internal: String,
36}
37
38/// Where a custom process is in its lifecycle. In `zombie.json` it is the
39/// `state` field (`starting`, `ready`, `completed`, `failed`) plus
40/// `state_reason` for a failure; a record without one is taken as ready.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(tag = "state", rename_all = "lowercase")]
43pub enum CustomProcessState {
44    /// Spawned, its condition not met yet.
45    Starting,
46    /// Up: passed its ready check, or had nothing to check.
47    Ready,
48    /// A one-shot that exited 0.
49    Completed,
50    /// The ready check failed or timed out, or the one-shot exited with an
51    /// error: what `wait_ready` reports then. During the spawn this fails
52    /// the spawn, so no process on a spawned network is in this state.
53    Failed {
54        #[serde(rename = "state_reason")]
55        reason: String,
56    },
57}
58
59impl fmt::Display for CustomProcessState {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        f.write_str(match self {
62            Self::Starting => "starting",
63            Self::Ready => "ready",
64            Self::Completed => "completed",
65            Self::Failed { .. } => "failed",
66        })
67    }
68}
69
70/// A running custom process.
71#[derive(Clone, Serialize)]
72pub struct CustomProcessNode {
73    #[serde(flatten)]
74    pub(crate) core: NodeCore,
75    pub(crate) spec: CustomProcess,
76    /// Ip the process is reachable at.
77    pub(crate) ip: IpAddr,
78    /// Where each declared port is reachable, by the port's name.
79    pub(crate) ports: BTreeMap<String, ProcessPort>,
80    #[serde(flatten)]
81    pub(crate) state: CustomProcessState,
82}
83
84/// Deserialization counterpart used when re-attaching to a running network.
85#[derive(Deserialize)]
86pub(crate) struct RawCustomProcessNode {
87    pub(crate) name: String,
88    pub(crate) spec: CustomProcess,
89    pub(crate) ip: IpAddr,
90    #[serde(default)]
91    pub(crate) ports: BTreeMap<String, ProcessPort>,
92    /// `None` for a record from before states were kept, or one this version
93    /// cannot read (a state it does not know); taken as up either way.
94    #[serde(flatten)]
95    pub(crate) state: Option<CustomProcessState>,
96    /// The provider's own record of the process, which it needs to reattach.
97    pub(crate) inner: serde_json::Value,
98}
99
100impl CustomProcessNode {
101    pub(crate) fn new(
102        name: impl Into<String>,
103        inner: provider::DynNode,
104        spec: CustomProcess,
105        ip: IpAddr,
106        ports: BTreeMap<String, ProcessPort>,
107        state: CustomProcessState,
108    ) -> Self {
109        Self {
110            core: NodeCore::new(name, inner, NodeKind::CustomProcess),
111            spec,
112            ip,
113            ports,
114            state,
115        }
116    }
117
118    /// The provider-generic part of this process.
119    pub fn core(&self) -> &NodeCore {
120        &self.core
121    }
122
123    pub fn name(&self) -> &str {
124        self.core.name()
125    }
126
127    /// The configuration this process was spawned from.
128    pub fn spec(&self) -> &CustomProcess {
129        &self.spec
130    }
131
132    /// Ip the process is reachable at.
133    pub fn ip(&self) -> IpAddr {
134        self.ip
135    }
136
137    /// Where every declared port is reachable, by the port's name.
138    pub fn ports(&self) -> &BTreeMap<String, ProcessPort> {
139        &self.ports
140    }
141
142    /// Where the port named `name` is reachable, if the process declared it.
143    pub fn port(&self, name: &str) -> Option<&ProcessPort> {
144        self.ports.get(name)
145    }
146
147    /// `host:port` to reach the port named `name` from where zombienet runs,
148    /// if the process declared it.
149    pub fn get_uri_for_name(&self, name: &str) -> Option<&str> {
150        self.ports.get(name).map(|p| p.external.as_str())
151    }
152
153    /// Where the process is in its lifecycle, as of the spawn (or the record
154    /// attached to); [`Self::wait_ready`] re-checks.
155    pub fn state(&self) -> &CustomProcessState {
156        &self.state
157    }
158
159    /// The first declared port, in declaration order, if any.
160    fn first_port(&self) -> Option<&ProcessPort> {
161        self.spec
162            .ports()
163            .first()
164            .and_then(|declared| self.ports.get(&declared.name))
165    }
166
167    /// Wait for the process to meet its condition and say what happened: a
168    /// one-shot to exit (0 is [`CustomProcessState::Completed`]), any other
169    /// to pass its ready check (the provider's own, where it has one, else a
170    /// probe from here). A one-shot that already finished is reported as
171    /// recorded: there is nothing left to observe, and after an attach its
172    /// pid is gone along with its exit code.
173    pub async fn wait_ready(&self, timeout: Duration) -> CustomProcessState {
174        if self.spec.one_shot()
175            && matches!(
176                self.state,
177                CustomProcessState::Completed | CustomProcessState::Failed { .. }
178            )
179        {
180            return self.state.clone();
181        }
182        match self.await_condition(timeout).await {
183            Ok(state) => state,
184            Err(reason) => CustomProcessState::Failed { reason },
185        }
186    }
187
188    async fn await_condition(&self, timeout: Duration) -> Result<CustomProcessState, String> {
189        let interval = Duration::from_secs(u64::from(
190            self.spec.ready_check().map(|c| c.interval).unwrap_or(1),
191        ));
192        let check = self.spec.effective_check();
193        let target = check
194            .as_ref()
195            .and_then(|check| Some((self.ports.get(check.port_name())?, check.http_path())));
196        if !self.spec.one_shot() && target.is_none() {
197            return Ok(CustomProcessState::Ready);
198        }
199        let client = reqwest::Client::builder()
200            .no_proxy()
201            .build()
202            .map_err(|e| format!("could not build the http probe: {e}"))?;
203
204        // The last thing that stood in the way, for the timeout message. A
205        // status read that fails is retried like any other miss: one API
206        // hiccup must not fail the process for good.
207        let mut problem = String::from("no status read yet");
208        let waited = tokio::time::timeout(timeout, async {
209            loop {
210                let status = match self.core.inner().status().await {
211                    Ok(status) => status,
212                    Err(err) => {
213                        problem = format!("could not read the process status: {err}");
214                        tokio::time::sleep(interval).await;
215                        continue;
216                    },
217                };
218                if self.spec.one_shot() {
219                    match status {
220                        ProcessStatus::Exited { code: Some(0) } => {
221                            return Ok(CustomProcessState::Completed)
222                        },
223                        ProcessStatus::Exited { code: Some(code) } => {
224                            return Err(format!("exited with code {code}"))
225                        },
226                        ProcessStatus::Exited { code: None } => {
227                            return Err("exited, with its code unknown".into())
228                        },
229                        ProcessStatus::Running { .. } => problem = "still running".into(),
230                    }
231                } else {
232                    let (port, path) = target.expect("checked above; qed");
233                    let kind = if path.is_some() { "http" } else { "tcp" };
234                    match status {
235                        ProcessStatus::Exited { code } => {
236                            return Err(match code {
237                                Some(code) => format!("exited with code {code}"),
238                                None => "exited".into(),
239                            })
240                        },
241                        // the provider checks (k8s: the pod's probe)
242                        ProcessStatus::Running { ready: Some(true) } => {
243                            return Ok(CustomProcessState::Ready)
244                        },
245                        ProcessStatus::Running { ready: Some(false) } => {
246                            problem = format!(
247                                "the provider's {kind} probe on port {}{} did not pass",
248                                port.port,
249                                path.unwrap_or("")
250                            );
251                        },
252                        // nobody else checks: probe from here
253                        ProcessStatus::Running { ready: None } => {
254                            let passed = match path {
255                                None => tcp_answers(&port.external).await,
256                                Some(path) => http_answers(&client, &port.external, path).await,
257                            };
258                            if passed {
259                                return Ok(CustomProcessState::Ready);
260                            }
261                            problem = format!(
262                                "{kind} {}{} did not pass",
263                                port.external,
264                                path.unwrap_or("")
265                            );
266                        },
267                    }
268                }
269                tokio::time::sleep(interval).await;
270            }
271        })
272        .await;
273
274        match waited {
275            Ok(outcome) => outcome,
276            Err(_elapsed) => Err(format!(
277                "{} after {}s ({problem})",
278                if self.spec.one_shot() {
279                    "not finished"
280                } else {
281                    "not ready"
282                },
283                timeout.as_secs()
284            )),
285        }
286    }
287
288    /// Check if the process is responsive by connecting to its first declared
289    /// port, with a short timeout (2 seconds). A process that declares no port
290    /// has nothing to probe and is reported responsive.
291    ///
292    /// On kubernetes outside CI the address is a local port-forward, and on
293    /// docker a published port behind docker's proxy; both accept a connection
294    /// whether or not the process listens, so there the probe says the forward
295    /// is up, not that the process answers. Native and kubernetes in CI reach
296    /// the process itself.
297    pub async fn is_responsive(&self) -> bool {
298        match self.first_port() {
299            Some(port) => tcp_answers(&port.external).await,
300            None => true,
301        }
302    }
303}
304
305/// One TCP connect to `addr`, given 2 seconds.
306async fn tcp_answers(addr: &str) -> bool {
307    tokio::time::timeout(Duration::from_secs(2), tokio::net::TcpStream::connect(addr))
308        .await
309        .is_ok_and(|connected| connected.is_ok())
310}
311
312/// One `GET http://<addr><path>`, given 5 seconds, passing on 2xx.
313async fn http_answers(client: &reqwest::Client, addr: &str, path: &str) -> bool {
314    client
315        .get(format!("http://{addr}{path}"))
316        .timeout(Duration::from_secs(5))
317        .send()
318        .await
319        .is_ok_and(|res| res.status().is_success())
320}
321
322#[async_trait]
323impl SpawnedNode for CustomProcessNode {
324    fn core(&self) -> &NodeCore {
325        &self.core
326    }
327
328    /// Nothing to wait on here: the spawn already waited for the process's
329    /// condition, and a failed one must not fail a network-wide wait (the
330    /// network is kept up for its logs). [`Self::wait_ready`] re-checks on
331    /// request.
332    async fn wait_until_is_up(&self, _timeout_secs: u64) -> Result<(), anyhow::Error> {
333        debug!(
334            "[{}] custom process, not waited on; state {}",
335            self.name(),
336            self.state
337        );
338        Ok(())
339    }
340}
341
342impl fmt::Debug for CustomProcessNode {
343    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
344        f.debug_struct("CustomProcessNode")
345            .field("inner", &"inner_skipped")
346            .field("name", &self.name())
347            .field("spec", &self.spec)
348            .field("ip", &self.ip)
349            .field("ports", &self.ports)
350            .field("state", &self.state)
351            .finish()
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use std::sync::Arc;
358
359    use configuration::{CustomProcessBuilder, ReadyCheck};
360
361    use super::*;
362    use crate::network::node::mock::MockNode;
363
364    fn node(spec: CustomProcess, mock: MockNode, state: CustomProcessState) -> CustomProcessNode {
365        let ports = spec
366            .ports()
367            .iter()
368            .map(|p| {
369                (
370                    p.name.clone(),
371                    ProcessPort {
372                        port: 8080,
373                        external: "127.0.0.1:1".into(),
374                        internal: "x:8080".into(),
375                    },
376                )
377            })
378            .collect();
379        CustomProcessNode::new(
380            "p",
381            Arc::new(mock),
382            spec,
383            "127.0.0.1".parse().unwrap(),
384            ports,
385            state,
386        )
387    }
388
389    fn one_shot() -> CustomProcess {
390        CustomProcessBuilder::new()
391            .with_name("p")
392            .with_command("sh")
393            .with_one_shot()
394            .build()
395            .unwrap()
396    }
397
398    fn with_check() -> CustomProcess {
399        CustomProcessBuilder::new()
400            .with_name("p")
401            .with_command("srv")
402            .with_named_port("http", 8080)
403            .with_ready_check(ReadyCheck::tcp("http"))
404            .build()
405            .unwrap()
406    }
407
408    #[tokio::test]
409    async fn wait_ready_follows_what_the_provider_reports() {
410        let running = ProcessStatus::Running { ready: None };
411        let secs = Duration::from_secs;
412
413        // a one-shot: running, then exit 0
414        let n = node(
415            one_shot(),
416            MockNode::new().with_statuses(vec![
417                Ok(running.clone()),
418                Ok(ProcessStatus::Exited { code: Some(0) }),
419            ]),
420            CustomProcessState::Starting,
421        );
422        assert_eq!(n.wait_ready(secs(10)).await, CustomProcessState::Completed);
423
424        // a one-shot that fails
425        let n = node(
426            one_shot(),
427            MockNode::new().with_statuses(vec![Ok(ProcessStatus::Exited { code: Some(3) })]),
428            CustomProcessState::Starting,
429        );
430        assert_eq!(
431            n.wait_ready(secs(10)).await,
432            CustomProcessState::Failed {
433                reason: "exited with code 3".into()
434            }
435        );
436
437        // a finished one-shot is reported as recorded, nothing is read
438        let n = node(
439            one_shot(),
440            MockNode::new().with_statuses(vec![Err("not asked")]),
441            CustomProcessState::Completed,
442        );
443        assert_eq!(n.wait_ready(secs(10)).await, CustomProcessState::Completed);
444
445        // a status error is retried; the provider's probe then passes
446        let n = node(
447            with_check(),
448            MockNode::new().with_statuses(vec![
449                Err("api down"),
450                Ok(ProcessStatus::Running { ready: Some(false) }),
451                Ok(ProcessStatus::Running { ready: Some(true) }),
452            ]),
453            CustomProcessState::Starting,
454        );
455        assert_eq!(n.wait_ready(secs(10)).await, CustomProcessState::Ready);
456
457        // never ready: the timeout names the last problem
458        let n = node(
459            with_check(),
460            MockNode::new().with_statuses(vec![Ok(ProcessStatus::Running { ready: Some(false) })]),
461            CustomProcessState::Starting,
462        );
463        assert_eq!(
464            n.wait_ready(secs(2)).await,
465            CustomProcessState::Failed {
466                reason: "not ready after 2s (the provider's tcp probe on port 8080 did not pass)"
467                    .into()
468            }
469        );
470
471        // exits before becoming ready
472        let n = node(
473            with_check(),
474            MockNode::new().with_statuses(vec![Ok(ProcessStatus::Exited { code: Some(9) })]),
475            CustomProcessState::Starting,
476        );
477        assert_eq!(
478            n.wait_ready(secs(10)).await,
479            CustomProcessState::Failed {
480                reason: "exited with code 9".into()
481            }
482        );
483    }
484
485    #[test]
486    fn the_state_is_two_plain_fields_and_an_old_record_is_ready() {
487        for (state, json) in [
488            (CustomProcessState::Starting, r#"{"state":"starting"}"#),
489            (CustomProcessState::Ready, r#"{"state":"ready"}"#),
490            (CustomProcessState::Completed, r#"{"state":"completed"}"#),
491            (
492                CustomProcessState::Failed {
493                    reason: "exited with code 3".into(),
494                },
495                r#"{"state":"failed","state_reason":"exited with code 3"}"#,
496            ),
497        ] {
498            assert_eq!(serde_json::to_string(&state).unwrap(), json);
499            assert_eq!(
500                serde_json::from_str::<CustomProcessState>(json).unwrap(),
501                state
502            );
503        }
504
505        let with_state = serde_json::json!({
506            "name": "web", "spec": {"name": "web", "command": "web"}, "ip": "10.0.0.1",
507            "state": "failed", "state_reason": "x", "inner": {}
508        });
509        let raw: RawCustomProcessNode = serde_json::from_value(with_state).unwrap();
510        assert_eq!(
511            raw.state,
512            Some(CustomProcessState::Failed { reason: "x".into() })
513        );
514
515        let without_state = serde_json::json!({
516            "name": "web", "spec": {"name": "web", "command": "web"}, "ip": "10.0.0.1", "inner": {}
517        });
518        let raw: RawCustomProcessNode = serde_json::from_value(without_state).unwrap();
519        assert_eq!(raw.state, None);
520    }
521
522    #[test]
523    fn a_raw_process_without_ports_still_deserializes_but_needs_its_inner() {
524        let json = serde_json::json!({
525            "name": "eth-rpc",
526            "spec": { "name": "eth-rpc", "command": "eth-rpc" },
527            "ip": "10.0.0.1",
528            "inner": { "name": "eth-rpc" }
529        });
530        let raw: RawCustomProcessNode = serde_json::from_value(json).unwrap();
531        assert_eq!(raw.name, "eth-rpc");
532        assert!(raw.ports.is_empty());
533
534        let json = serde_json::json!({
535            "name": "eth-rpc",
536            "spec": { "name": "eth-rpc", "command": "eth-rpc" },
537            "ip": "10.0.0.1"
538        });
539        assert!(serde_json::from_value::<RawCustomProcessNode>(json).is_err());
540    }
541
542    #[test]
543    fn ports_round_trip_by_name() {
544        let port = ProcessPort {
545            port: 8545,
546            external: "127.0.0.1:53421".into(),
547            internal: "eth-rpc:8545".into(),
548        };
549        let json = serde_json::to_value(&port).unwrap();
550        assert_eq!(json["external"], "127.0.0.1:53421");
551        let back: ProcessPort = serde_json::from_value(json).unwrap();
552        assert_eq!(back, port);
553    }
554}