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}