Skip to main content

yah_qed/
peers.rs

1//! Per-camp peer registry (R494-F1).
2//!
3//! When a pipeline carries a [`SubPipelineRef::Peer { camp, pipeline }`] step,
4//! qed needs to know where that camp lives. The registry lives at
5//! `<qed_dir>/peers.toml` (typically `.yah/qed/peers.toml`) and maps
6//! registry keys to camp folders on this rig — or, when `rig` is set, to a
7//! camp on another rig that kamaji will broker the run to.
8//!
9//! Shape:
10//!
11//! ```toml
12//! # .yah/qed/peers.toml
13//! [peer.mesofact]
14//! path = "external/mesofact"          # relative to this camp's root
15//!
16//! [peer.cheers]
17//! path = "external/cheers"
18//!
19//! [peer.bigbuild]
20//! rig  = "rig-tokyo-1"                # remote — kamaji brokers (R494-T5)
21//! path = "/srv/camps/bigbuild"
22//! ```
23//!
24//! Resolution rules (R494-F2 wires the runner side):
25//!
26//! - **Local peer (`rig` unset).** The rig-local camp daemon loads the
27//!   peer camp's `.yah/qed/` and runs the named pipeline as a nested
28//!   [`QedRun`](crate::types::QedRunId). Same process, same runner, same
29//!   DB. There is **one camp-daemon per rig**; "other camps" are just
30//!   different folders to that daemon. No IPC.
31//! - **Remote peer (`rig` set).** Daemon asks kamaji to broker the
32//!   run on the named rig's daemon (R494-T5 stubs this — v1 surfaces an
33//!   explicit unsupported error).
34//!
35//! `yubaba` does **not** enter the resolution path. It only appears if a
36//! peer's own pipeline carries an `[[pipeline.on_success]] kind =
37//! "yubaba-deploy"` outcome — same as a same-camp pipeline.
38
39use serde::Deserialize;
40use std::collections::HashMap;
41use std::fs;
42use std::path::{Path, PathBuf};
43use thiserror::Error;
44
45/// Per-camp peer registry. Empty by default — the registry exists only
46/// when this camp wants to compose pipelines from other camp folders.
47#[derive(Debug, Clone, Default, Deserialize)]
48pub struct PeerConfig {
49    #[serde(default)]
50    pub peer: HashMap<String, PeerEntry>,
51}
52
53/// One peer entry from `peers.toml`. Either local (path only) or remote
54/// (`rig` + path on that rig). `path` is mandatory in both cases — for
55/// local peers it is relative to *this* camp's root; for remote peers it
56/// is an absolute path on the rig.
57#[derive(Debug, Clone, Deserialize)]
58pub struct PeerEntry {
59    pub path: PathBuf,
60    /// When set, the peer lives on another rig and resolution goes
61    /// through kamaji. v1 surfaces this as an unsupported-error at
62    /// step-execution time (R494-T5).
63    #[serde(default)]
64    pub rig: Option<String>,
65}
66
67/// Errors surfaced while loading `peers.toml`. Missing file is **not**
68/// an error — it yields a [`PeerConfig::default()`].
69#[derive(Debug, Error)]
70pub enum PeerConfigError {
71    #[error("IO error reading {path}: {source}")]
72    Io {
73        path: String,
74        source: std::io::Error,
75    },
76    #[error("TOML parse error in {path}: {source}")]
77    Parse {
78        path: String,
79        source: toml::de::Error,
80    },
81}
82
83impl PeerConfig {
84    /// Load `<qed_dir>/peers.toml` if present; return an empty config
85    /// otherwise. The empty config rejects every [`SubPipelineRef::Peer`]
86    /// step at resolution time, which is the right v1 default — operators
87    /// opt in by writing the file.
88    pub fn load(qed_dir: &Path) -> Result<Self, PeerConfigError> {
89        let path = qed_dir.join("peers.toml");
90        if !path.exists() {
91            return Ok(Self::default());
92        }
93        let src = fs::read_to_string(&path).map_err(|e| PeerConfigError::Io {
94            path: path.display().to_string(),
95            source: e,
96        })?;
97        toml::from_str(&src).map_err(|e| PeerConfigError::Parse {
98            path: path.display().to_string(),
99            source: e,
100        })
101    }
102
103    /// Look up a peer by registry key. Returns `None` for unknown peers
104    /// — the runner surfaces that as a resolution error at step time.
105    pub fn get(&self, camp: &str) -> Option<&PeerEntry> {
106        self.peer.get(camp)
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn missing_file_yields_empty_config() {
116        let tmp = tempfile::tempdir().unwrap();
117        let cfg = PeerConfig::load(tmp.path()).unwrap();
118        assert!(cfg.peer.is_empty());
119        assert!(cfg.get("anyone").is_none());
120    }
121
122    #[test]
123    fn parses_local_and_remote_entries() {
124        let tmp = tempfile::tempdir().unwrap();
125        let src = r#"
126            [peer.mesofact]
127            path = "external/mesofact"
128
129            [peer.cheers]
130            path = "external/cheers"
131
132            [peer.bigbuild]
133            rig  = "rig-tokyo-1"
134            path = "/srv/camps/bigbuild"
135        "#;
136        fs::write(tmp.path().join("peers.toml"), src).unwrap();
137        let cfg = PeerConfig::load(tmp.path()).unwrap();
138        assert_eq!(cfg.peer.len(), 3);
139
140        let meso = cfg.get("mesofact").unwrap();
141        assert_eq!(meso.path, PathBuf::from("external/mesofact"));
142        assert!(meso.rig.is_none());
143
144        let big = cfg.get("bigbuild").unwrap();
145        assert_eq!(big.rig.as_deref(), Some("rig-tokyo-1"));
146        assert_eq!(big.path, PathBuf::from("/srv/camps/bigbuild"));
147    }
148
149    #[test]
150    fn malformed_toml_surfaces_parse_error() {
151        let tmp = tempfile::tempdir().unwrap();
152        fs::write(tmp.path().join("peers.toml"), "not = valid = toml").unwrap();
153        let err = PeerConfig::load(tmp.path()).unwrap_err();
154        assert!(matches!(err, PeerConfigError::Parse { .. }), "got: {err:?}");
155    }
156
157    #[test]
158    fn entry_without_path_is_a_parse_error() {
159        let tmp = tempfile::tempdir().unwrap();
160        fs::write(
161            tmp.path().join("peers.toml"),
162            "[peer.broken]\nrig = \"some-rig\"\n",
163        )
164        .unwrap();
165        let err = PeerConfig::load(tmp.path()).unwrap_err();
166        assert!(matches!(err, PeerConfigError::Parse { .. }), "got: {err:?}");
167    }
168}