Skip to main content

rch_common/
worker_config_validation.rs

1//! `workers.toml` vs SSH-config / live-fact validation
2//! (bd-session-history-remediation-ocv9i.12.4).
3//!
4//! A worker entry in `workers.toml` drifts from reality in many quiet ways: the
5//! SSH config resolves a different user or host alias, the identity file moved,
6//! the configured build root or slot count no longer matches the host, the
7//! `rch-wkr` binary isn't where it should be, or the host is a different
8//! platform than assumed. [`validate_worker_config`] compares one configured
9//! [`WorkerConfig`] against a [`LiveHostObservation`] (SSH facts + the 12.1/12.2
10//! probed [`WorkerFacts`]) and reports each drift with a **safe, non-mutating**
11//! remediation step. It never changes config; applying a fix is the caller's
12//! explicit, separate action.
13
14use serde::{Deserialize, Serialize};
15
16use crate::types::WorkerConfig;
17use crate::worker_facts::WorkerFacts;
18
19/// A class of `workers.toml` drift.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum ConfigDriftKind {
23    /// Configured user differs from the SSH-resolved / live user.
24    UserDrift,
25    /// Configured host differs from the SSH-resolved canonical host.
26    HostAliasDrift,
27    /// The configured identity file does not exist.
28    IdentityFileMissing,
29    /// The configured build root is not among the worker's live build roots.
30    PathMismatch,
31    /// Configured slot count differs from the host's detected capacity.
32    SlotMismatch,
33    /// The `rch-wkr` binary did not report at its configured path.
34    MissingWorkerBinaryPath,
35    /// The worker's live platform differs from the expected target triple.
36    PlatformMismatch,
37}
38
39impl ConfigDriftKind {
40    /// Stable snake_case id.
41    #[must_use]
42    pub const fn as_str(self) -> &'static str {
43        match self {
44            Self::UserDrift => "user_drift",
45            Self::HostAliasDrift => "host_alias_drift",
46            Self::IdentityFileMissing => "identity_file_missing",
47            Self::PathMismatch => "path_mismatch",
48            Self::SlotMismatch => "slot_mismatch",
49            Self::MissingWorkerBinaryPath => "missing_worker_binary_path",
50            Self::PlatformMismatch => "platform_mismatch",
51        }
52    }
53}
54
55/// One drift finding with a safe remediation hint.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct ConfigDriftFinding {
58    pub kind: ConfigDriftKind,
59    pub worker_id: String,
60    pub detail: String,
61    /// A safe, non-mutating remediation step the operator can take.
62    pub remediation: String,
63}
64
65/// Live observation of a host to validate a config entry against. All fields
66/// optional/best-effort — an absent observation simply skips that check.
67#[derive(Debug, Clone, Default)]
68pub struct LiveHostObservation {
69    /// User the SSH config resolves to / the probe ran as.
70    pub ssh_user: Option<String>,
71    /// Canonical host the SSH config resolves to (for alias-drift detection).
72    pub resolved_host: Option<String>,
73    /// Whether the configured identity file exists.
74    pub identity_file_exists: Option<bool>,
75    /// Live probed facts (12.1/12.2).
76    pub facts: Option<WorkerFacts>,
77    /// Detected build-slot capacity (e.g. CPU cores).
78    pub detected_slots: Option<u32>,
79    /// The remote build root the controller expects this worker to offload into.
80    pub expected_build_root: Option<String>,
81    /// The target triple the controller will deploy artifacts for.
82    pub expected_target_triple: Option<String>,
83}
84
85/// Validate one configured worker against a live observation. Returns every
86/// drift finding (empty = clean). Never mutates anything.
87#[must_use]
88pub fn validate_worker_config(
89    entry: &WorkerConfig,
90    obs: &LiveHostObservation,
91) -> Vec<ConfigDriftFinding> {
92    let id = entry.id.as_str().to_string();
93    let mut findings = Vec::new();
94    let mut push = |kind: ConfigDriftKind, detail: String, remediation: String| {
95        findings.push(ConfigDriftFinding {
96            kind,
97            worker_id: id.clone(),
98            detail,
99            remediation,
100        });
101    };
102
103    if let Some(ssh_user) = &obs.ssh_user
104        && *ssh_user != entry.user
105    {
106        push(
107            ConfigDriftKind::UserDrift,
108            format!(
109                "workers.toml user '{}' != resolved '{ssh_user}'",
110                entry.user
111            ),
112            format!("set this worker's user to '{ssh_user}' or fix the SSH config"),
113        );
114    }
115
116    if let Some(resolved) = &obs.resolved_host
117        && *resolved != entry.host
118    {
119        push(
120            ConfigDriftKind::HostAliasDrift,
121            format!(
122                "workers.toml host '{}' resolves to '{resolved}'",
123                entry.host
124            ),
125            format!("point this worker at '{resolved}' or align the SSH host alias"),
126        );
127    }
128
129    if obs.identity_file_exists == Some(false) {
130        push(
131            ConfigDriftKind::IdentityFileMissing,
132            format!("identity file '{}' does not exist", entry.identity_file),
133            "restore the key or update identity_file to its real path".to_string(),
134        );
135    }
136
137    if let Some(detected) = obs.detected_slots
138        && detected != entry.total_slots
139    {
140        push(
141            ConfigDriftKind::SlotMismatch,
142            format!(
143                "configured slots {} != detected capacity {detected}",
144                entry.total_slots
145            ),
146            format!("set total_slots to {detected} (the host's detected capacity)"),
147        );
148    }
149
150    if let Some(expected_root) = &obs.expected_build_root
151        && let Some(facts) = &obs.facts
152        && !facts.user.build_roots.iter().any(|r| r == expected_root)
153    {
154        push(
155            ConfigDriftKind::PathMismatch,
156            format!(
157                "expected build root '{expected_root}' not among live roots {:?}",
158                facts.user.build_roots
159            ),
160            "create the build root on the worker or update the configured path".to_string(),
161        );
162    }
163
164    if let Some(facts) = &obs.facts {
165        // An empty version / path means the binary at the configured path did
166        // not report (missing or unusable as the configured user).
167        if facts.worker.version.is_empty() || facts.worker.rch_wkr_path.is_empty() {
168            push(
169                ConfigDriftKind::MissingWorkerBinaryPath,
170                "rch-wkr did not report a version at its configured path".to_string(),
171                "redeploy rch-wkr to the configured path (rch update --fleet)".to_string(),
172            );
173        }
174        if let Some(expected_triple) = &obs.expected_target_triple
175            && *expected_triple != facts.host.target_triple
176        {
177            push(
178                ConfigDriftKind::PlatformMismatch,
179                format!(
180                    "expected target triple '{expected_triple}' != live '{}'",
181                    facts.host.target_triple
182                ),
183                "deploy the artifact matching the worker's platform; do not reuse the controller's"
184                    .to_string(),
185            );
186        }
187    }
188
189    findings
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::types::{WorkerConfig, WorkerId};
196    use crate::worker_facts::{HostFacts, UserFacts, WorkerBinaryFacts};
197
198    fn entry() -> WorkerConfig {
199        WorkerConfig {
200            id: WorkerId("css".to_string()),
201            host: "css".to_string(),
202            user: "rch".to_string(),
203            identity_file: "/home/rch/.ssh/id_ed25519".to_string(),
204            total_slots: 8,
205            ..WorkerConfig::default()
206        }
207    }
208
209    fn facts() -> WorkerFacts {
210        WorkerFacts::new(
211            "css",
212            1_700_000_000_000,
213            HostFacts {
214                os: "linux".to_string(),
215                arch: "x86_64".to_string(),
216                libc: Some("gnu".to_string()),
217                shell: None,
218                target_triple: "x86_64-unknown-linux-gnu".to_string(),
219                artifact_platforms: vec![],
220            },
221            UserFacts {
222                remote_user: "rch".to_string(),
223                home: "/home/rch".to_string(),
224                temp_root: "/data/tmp".to_string(),
225                build_roots: vec!["/data/tmp/rch-targets".to_string()],
226            },
227            WorkerBinaryFacts {
228                rch_wkr_path: "/home/rch/.local/bin/rch-wkr".to_string(),
229                version: "1.0.41".to_string(),
230                protocol_version: 3,
231            },
232        )
233    }
234
235    #[test]
236    fn clean_config_has_no_findings() {
237        let obs = LiveHostObservation {
238            ssh_user: Some("rch".to_string()),
239            resolved_host: Some("css".to_string()),
240            identity_file_exists: Some(true),
241            facts: Some(facts()),
242            detected_slots: Some(8),
243            expected_build_root: Some("/data/tmp/rch-targets".to_string()),
244            expected_target_triple: Some("x86_64-unknown-linux-gnu".to_string()),
245        };
246        assert!(validate_worker_config(&entry(), &obs).is_empty());
247    }
248
249    fn kinds(findings: &[ConfigDriftFinding]) -> Vec<ConfigDriftKind> {
250        findings.iter().map(|f| f.kind).collect()
251    }
252
253    #[test]
254    fn user_drift_detected() {
255        let obs = LiveHostObservation {
256            ssh_user: Some("ubuntu".to_string()),
257            ..LiveHostObservation::default()
258        };
259        let f = validate_worker_config(&entry(), &obs);
260        assert_eq!(kinds(&f), vec![ConfigDriftKind::UserDrift]);
261        assert!(f[0].detail.contains("ubuntu"));
262        assert!(f[0].remediation.contains("ubuntu"));
263        assert_eq!(f[0].worker_id, "css");
264    }
265
266    #[test]
267    fn host_alias_drift_detected() {
268        let obs = LiveHostObservation {
269            resolved_host: Some("css.contabo.net".to_string()),
270            ..LiveHostObservation::default()
271        };
272        assert_eq!(
273            kinds(&validate_worker_config(&entry(), &obs)),
274            vec![ConfigDriftKind::HostAliasDrift]
275        );
276    }
277
278    #[test]
279    fn identity_file_missing_detected() {
280        let obs = LiveHostObservation {
281            identity_file_exists: Some(false),
282            ..LiveHostObservation::default()
283        };
284        assert_eq!(
285            kinds(&validate_worker_config(&entry(), &obs)),
286            vec![ConfigDriftKind::IdentityFileMissing]
287        );
288    }
289
290    #[test]
291    fn slot_mismatch_detected_and_suggests_detected() {
292        let obs = LiveHostObservation {
293            detected_slots: Some(16),
294            ..LiveHostObservation::default()
295        };
296        let f = validate_worker_config(&entry(), &obs);
297        assert_eq!(kinds(&f), vec![ConfigDriftKind::SlotMismatch]);
298        assert!(f[0].remediation.contains("16"));
299    }
300
301    #[test]
302    fn path_mismatch_detected() {
303        let mut fx = facts();
304        fx.user.build_roots = vec!["/tmp/other".to_string()];
305        let obs = LiveHostObservation {
306            facts: Some(fx),
307            expected_build_root: Some("/data/tmp/rch-targets".to_string()),
308            ..LiveHostObservation::default()
309        };
310        assert_eq!(
311            kinds(&validate_worker_config(&entry(), &obs)),
312            vec![ConfigDriftKind::PathMismatch]
313        );
314    }
315
316    #[test]
317    fn missing_worker_binary_detected() {
318        let mut fx = facts();
319        fx.worker.version = String::new(); // binary didn't report a version
320        let obs = LiveHostObservation {
321            facts: Some(fx),
322            ..LiveHostObservation::default()
323        };
324        assert_eq!(
325            kinds(&validate_worker_config(&entry(), &obs)),
326            vec![ConfigDriftKind::MissingWorkerBinaryPath]
327        );
328    }
329
330    #[test]
331    fn platform_mismatch_detected() {
332        let obs = LiveHostObservation {
333            facts: Some(facts()),
334            expected_target_triple: Some("aarch64-apple-darwin".to_string()),
335            ..LiveHostObservation::default()
336        };
337        let f = validate_worker_config(&entry(), &obs);
338        assert_eq!(kinds(&f), vec![ConfigDriftKind::PlatformMismatch]);
339        assert!(f[0].remediation.contains("do not reuse the controller"));
340    }
341
342    #[test]
343    fn multiple_drifts_reported_together() {
344        let obs = LiveHostObservation {
345            ssh_user: Some("ubuntu".to_string()),
346            identity_file_exists: Some(false),
347            detected_slots: Some(4),
348            ..LiveHostObservation::default()
349        };
350        let f = validate_worker_config(&entry(), &obs);
351        assert_eq!(f.len(), 3);
352        assert!(kinds(&f).contains(&ConfigDriftKind::UserDrift));
353        assert!(kinds(&f).contains(&ConfigDriftKind::SlotMismatch));
354    }
355
356    #[test]
357    fn findings_serialize_with_stable_fields() {
358        let obs = LiveHostObservation {
359            ssh_user: Some("ubuntu".to_string()),
360            ..LiveHostObservation::default()
361        };
362        let f = &validate_worker_config(&entry(), &obs)[0];
363        let v = serde_json::to_value(f).unwrap();
364        assert_eq!(v["kind"], "user_drift");
365        assert_eq!(v["worker_id"], "css");
366        assert!(v.get("detail").is_some() && v.get("remediation").is_some());
367        let back: ConfigDriftFinding = serde_json::from_value(v).unwrap();
368        assert_eq!(*f, back);
369    }
370}