1use serde::{Deserialize, Serialize};
15
16use crate::types::WorkerConfig;
17use crate::worker_facts::WorkerFacts;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum ConfigDriftKind {
23 UserDrift,
25 HostAliasDrift,
27 IdentityFileMissing,
29 PathMismatch,
31 SlotMismatch,
33 MissingWorkerBinaryPath,
35 PlatformMismatch,
37}
38
39impl ConfigDriftKind {
40 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct ConfigDriftFinding {
58 pub kind: ConfigDriftKind,
59 pub worker_id: String,
60 pub detail: String,
61 pub remediation: String,
63}
64
65#[derive(Debug, Clone, Default)]
68pub struct LiveHostObservation {
69 pub ssh_user: Option<String>,
71 pub resolved_host: Option<String>,
73 pub identity_file_exists: Option<bool>,
75 pub facts: Option<WorkerFacts>,
77 pub detected_slots: Option<u32>,
79 pub expected_build_root: Option<String>,
81 pub expected_target_triple: Option<String>,
83}
84
85#[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 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(); 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}