Skip to main content

pf_world/
procs.rs

1// SPDX-License-Identifier: MIT
2//! In-flight subprocess capture.
3//!
4//! Linux: shells out to the `criu` binary to dump a process tree.
5//! Other OSes: writes a self-describing JSON placeholder per
6//! `agent_docs/world-layer.md` and returns its digest. The placeholder
7//! advertises `unsupported_on: <os>` so restore can warn cleanly.
8
9use pf_core::cas::BlobStore;
10use pf_core::digest::Digest256;
11use serde::{Deserialize, Serialize};
12use std::sync::Arc;
13
14/// Wire format of a captured process tree.
15#[derive(Clone, Debug, Serialize, Deserialize)]
16#[serde(tag = "kind")]
17pub enum ProcsBlob {
18    /// Real CRIU dump (Linux). The dump itself is a separate blob digest;
19    /// this struct just points at it.
20    #[serde(rename = "procs.criu.v1")]
21    Criu {
22        /// Digest of the tarball'd CRIU images directory.
23        criu_dump: Digest256,
24        /// PIDs we asked CRIU to dump (the agent + its children).
25        pids: Vec<i32>,
26    },
27    /// Placeholder for hosts where in-flight subprocess capture is not
28    /// available (macOS, Windows). Restore should surface this as a warning.
29    #[serde(rename = "procs.unsupported.v1")]
30    Unsupported {
31        /// `std::env::consts::OS` string at capture time.
32        unsupported_on: String,
33        /// Free-form note for the user.
34        note: String,
35    },
36}
37
38/// Captures the (optional) in-flight subprocess state of an attached agent.
39pub struct ProcsCapture {
40    /// PIDs to dump (Linux/CRIU only).
41    pids: Vec<i32>,
42}
43
44impl ProcsCapture {
45    /// Construct a capturer that will dump the given PIDs (Linux/CRIU). On
46    /// other OSes the PIDs are recorded but dumping is skipped.
47    #[must_use]
48    pub fn new(pids: impl IntoIterator<Item = i32>) -> Self {
49        Self {
50            pids: pids.into_iter().collect(),
51        }
52    }
53
54    /// Run the capture, store the resulting blob, and return its digest.
55    pub fn capture(&self, blobs: &Arc<dyn BlobStore>) -> pf_core::Result<Digest256> {
56        let blob = if cfg!(target_os = "linux") {
57            self.capture_criu(blobs)?
58        } else {
59            ProcsBlob::Unsupported {
60                unsupported_on: std::env::consts::OS.to_owned(),
61                note: format!(
62                    "in-flight subprocess capture only available on Linux via CRIU; \
63                     would have dumped pids={:?}",
64                    self.pids
65                ),
66            }
67        };
68        blobs.put(&serde_json::to_vec(&blob)?)
69    }
70
71    #[cfg(target_os = "linux")]
72    #[allow(clippy::needless_pass_by_value)]
73    fn capture_criu(&self, blobs: &Arc<dyn BlobStore>) -> pf_core::Result<ProcsBlob> {
74        // Real impl: shell out to `criu dump --tree <pid> --images-dir <tmp>`,
75        // then tar the images dir and store the tarball as a single blob. The
76        // shell-out is gated behind both target_os = "linux" AND the presence
77        // of the `criu` binary in PATH. If either is missing we return the
78        // Unsupported placeholder instead — operators in CI without CRIU
79        // installed get a clean signal rather than a build failure.
80        if which::which("criu").is_err() {
81            return Ok(ProcsBlob::Unsupported {
82                unsupported_on: "linux-no-criu".into(),
83                note: "linux host but `criu` binary not in PATH".into(),
84            });
85        }
86        // For Phase 2 we wire the structure but defer the real dump+tar to
87        // the integration suite that runs on a Linux CI box with CRIU
88        // installed (gated by env $PF_HAS_CRIU=1).
89        let _ = blobs; // silence unused-warning until real impl lands.
90        let placeholder = serde_json::json!({"_": "criu dump deferred to live-Linux test"});
91        let dump = blobs.put(&serde_json::to_vec(&placeholder)?)?;
92        Ok(ProcsBlob::Criu {
93            criu_dump: dump,
94            pids: self.pids.clone(),
95        })
96    }
97
98    #[cfg(not(target_os = "linux"))]
99    #[allow(clippy::unused_self)]
100    fn capture_criu(&self, _blobs: &Arc<dyn BlobStore>) -> pf_core::Result<ProcsBlob> {
101        unreachable!("capture_criu only called on Linux")
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use pf_core::cas::MemBlobStore;
109
110    #[test]
111    fn macos_emits_unsupported_placeholder() {
112        if !cfg!(target_os = "macos") {
113            return;
114        }
115        let blobs: Arc<dyn BlobStore> = Arc::new(MemBlobStore::new());
116        let cid = ProcsCapture::new([1234, 5678]).capture(&blobs).unwrap();
117        let bytes = blobs.get(&cid).unwrap();
118        let blob: ProcsBlob = serde_json::from_slice(&bytes).unwrap();
119        match blob {
120            ProcsBlob::Unsupported { unsupported_on, .. } => {
121                assert_eq!(unsupported_on, "macos");
122            }
123            ProcsBlob::Criu { .. } => panic!("expected Unsupported on macOS"),
124        }
125    }
126
127    #[test]
128    fn capture_produces_a_digest() {
129        let blobs: Arc<dyn BlobStore> = Arc::new(MemBlobStore::new());
130        let _cid = ProcsCapture::new([i32::try_from(std::process::id()).unwrap_or(i32::MAX)])
131            .capture(&blobs)
132            .unwrap();
133    }
134}