Skip to main content

cloud/
validate.rs

1//! Workspace-wide lint checks for the `.yah/services/` tree (R470-T3).
2//!
3//! Currently implements one check: **alias collision** — alias names declared
4//! in any service component's `[aliases]` block must be workspace-globally
5//! unique. Two services declaring the same alias is a consumer-site ambiguity
6//! (`yah-app.toml` says `alias = "whisper-default-ggml"` — which catalog
7//! wins?).
8//!
9//! Invoked by `yah cloud validate` and as a preflight in `yah cloud apply`.
10
11use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13
14use anyhow::Context as _;
15
16use crate::config::{MirrorConfig, MirrorProviderSlot, Provider, ServiceConfig};
17use crate::paths::services_dir;
18
19/// Where an alias is declared — points the operator at the source row.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct AliasSource {
22    /// Service name (matches `service.toml`'s `name` field).
23    pub service: String,
24    /// Component `id` within that service.
25    pub component_id: String,
26    /// Absolute path to the `workload.toml` containing the `[aliases]` block.
27    pub workload_toml: PathBuf,
28}
29
30/// A duplicate alias declaration found across two components.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct AliasCollision {
33    pub alias: String,
34    pub first: AliasSource,
35    pub second: AliasSource,
36}
37
38impl AliasCollision {
39    /// Human-readable error message matching the format described in W193.
40    pub fn message(&self) -> String {
41        format!(
42            "alias {:?} declared in both {} (component {}) and {} (component {})\n\
43             \u{2192} rename the alias in one of these files:\n  {}\n  {}",
44            self.alias,
45            self.first.service,
46            self.first.component_id,
47            self.second.service,
48            self.second.component_id,
49            self.first.workload_toml.display(),
50            self.second.workload_toml.display(),
51        )
52    }
53}
54
55/// Walk every service's static-asset components and collect all `(alias →
56/// source)` mappings. Returns a list of collisions (empty when clean).
57///
58/// Missing `.yah/services/` directory is not an error — returns empty.
59pub fn check_alias_collisions(workspace_root: &Path) -> anyhow::Result<Vec<AliasCollision>> {
60    let dir = services_dir(workspace_root);
61    if !dir.exists() {
62        return Ok(vec![]);
63    }
64
65    // alias_name → first source seen
66    let mut seen: BTreeMap<String, AliasSource> = BTreeMap::new();
67    let mut collisions = Vec::new();
68
69    let mut entries: Vec<_> = std::fs::read_dir(&dir)
70        .with_context(|| format!("reading {}", dir.display()))?
71        .filter_map(|e| e.ok())
72        .filter(|e| e.path().is_dir())
73        .collect();
74    entries.sort_by_key(|e| e.file_name());
75
76    for entry in entries {
77        let svc_dir = entry.path();
78        let service_toml = svc_dir.join("service.toml");
79        if !service_toml.exists() {
80            continue;
81        }
82        let service = match ServiceConfig::load(&service_toml) {
83            Ok(s) => s,
84            Err(e) => {
85                tracing::warn!(
86                    path = %service_toml.display(),
87                    error = %e,
88                    "skipping service with unparseable service.toml"
89                );
90                continue;
91            }
92        };
93
94        for component in &service.components {
95            if component.kind != "static-asset" {
96                continue;
97            }
98            let workload_dir = workspace_root.join(&component.path);
99            let workload_toml_path = workload_dir.join("workload.toml");
100            if !workload_toml_path.exists() {
101                continue;
102            }
103
104            let aliases = match load_static_asset_aliases(&workload_toml_path) {
105                Ok(a) => a,
106                Err(e) => {
107                    tracing::warn!(
108                        path = %workload_toml_path.display(),
109                        error = %e,
110                        "skipping workload.toml with parse error"
111                    );
112                    continue;
113                }
114            };
115
116            for alias_name in aliases.keys() {
117                let source = AliasSource {
118                    service: service.name.clone(),
119                    component_id: component.id.clone(),
120                    workload_toml: workload_toml_path.clone(),
121                };
122                if let Some(first) = seen.get(alias_name) {
123                    collisions.push(AliasCollision {
124                        alias: alias_name.clone(),
125                        first: first.clone(),
126                        second: source,
127                    });
128                } else {
129                    seen.insert(alias_name.clone(), source);
130                }
131            }
132        }
133    }
134
135    Ok(collisions)
136}
137
138/// Load the `[aliases]` block from a `workload.toml` that must be a
139/// `static-asset` kind. Returns an empty map for non-static-asset workloads
140/// (so the caller skips them silently).
141fn load_static_asset_aliases(path: &Path) -> anyhow::Result<BTreeMap<String, String>> {
142    let src =
143        std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
144    let workload: workload_spec::Workload =
145        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
146    match workload {
147        workload_spec::Workload::StaticAsset(w) => Ok(w.aliases),
148        _ => Ok(BTreeMap::new()),
149    }
150}
151
152// ── Port collisions (R602-B4) ────────────────────────────────────────────────
153
154/// Where a host port is declared — points the operator at the (service, env,
155/// slot) that binds it.
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct PortSource {
158    /// Service name (matches `service.toml`'s `name` field).
159    pub service: String,
160    /// Environment (file stem of `mirrors/<env>.toml`).
161    pub env: String,
162    /// Provider slot role the port sits under (`providers.<role>`).
163    pub slot_role: String,
164    /// The field that carried the port (`port` / `api_port` / `console_port`).
165    pub field: String,
166}
167
168/// Two local-tier mirror slots that bind the same host port. Because local
169/// mirrors share the operator's localhost, both binding the same port collide
170/// when brought up together — and the local-static adopt probe (a bare TCP
171/// connect) may then silently adopt the *wrong* service (R602-B4: `scrabcake`
172/// dev and `yah-marketing` pond both on 4322).
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct PortCollision {
175    pub port: u16,
176    pub first: PortSource,
177    pub second: PortSource,
178}
179
180impl PortCollision {
181    /// True when the two binders belong to different services — the dangerous
182    /// case, because the local-static adopt probe can then silently adopt the
183    /// *other* service's server. Same-service reuse (e.g. one service's dev +
184    /// cloud mirrors sharing a port) is only a can't-co-run bind conflict.
185    pub fn is_cross_service(&self) -> bool {
186        self.first.service != self.second.service
187    }
188
189    /// Human-readable error naming both binders + the fix.
190    pub fn message(&self) -> String {
191        format!(
192            "host port {} is bound by both {}/{} (providers.{}.{}) and {}/{} (providers.{}.{})\n\
193             \u{2192} give one a distinct port — local mirrors share the operator's localhost, so \
194             two slots on the same port collide, and the local-static adopt probe may silently \
195             adopt the wrong service.",
196            self.port,
197            self.first.service,
198            self.first.env,
199            self.first.slot_role,
200            self.first.field,
201            self.second.service,
202            self.second.env,
203            self.second.slot_role,
204            self.second.field,
205        )
206    }
207}
208
209/// Host-port field names a local-binding mirror slot may declare.
210const PORT_FIELDS: &[&str] = &["port", "api_port", "console_port"];
211
212/// True when this slot binds a port on the operator's localhost, so its port
213/// contends with every other local slot. Reference slots (`use = "..."`) and
214/// cloud/CF slots don't bind localhost and are skipped.
215fn slot_binds_localhost(slot: &MirrorProviderSlot) -> bool {
216    matches!(
217        slot.inline_kind(),
218        Some(Provider::LocalStatic | Provider::MiniflareContainer | Provider::MinioContainer)
219    )
220}
221
222/// Walk every service mirror and flag host-port reuse across local-tier
223/// provider slots (R602-B4). Only slots that bind a port on the operator's
224/// localhost are considered (`local-static`, `miniflare-container`,
225/// `minio-container`) — cloud/CF slots don't contend for localhost.
226///
227/// Deterministic: services + mirror envs are walked in sorted order, slot
228/// roles sorted, `PORT_FIELDS` in declared order — so the "first" binder of a
229/// port is stable across runs. Missing `.yah/services/` is not an error.
230pub fn check_port_collisions(workspace_root: &Path) -> anyhow::Result<Vec<PortCollision>> {
231    let dir = services_dir(workspace_root);
232    if !dir.exists() {
233        return Ok(vec![]);
234    }
235
236    // port → first source seen
237    let mut seen: BTreeMap<u16, PortSource> = BTreeMap::new();
238    let mut collisions = Vec::new();
239
240    let mut svc_entries: Vec<_> = std::fs::read_dir(&dir)
241        .with_context(|| format!("reading {}", dir.display()))?
242        .filter_map(|e| e.ok())
243        .filter(|e| e.path().is_dir())
244        .collect();
245    svc_entries.sort_by_key(|e| e.file_name());
246
247    for entry in svc_entries {
248        let svc_dir = entry.path();
249        let service_toml = svc_dir.join("service.toml");
250        if !service_toml.exists() {
251            continue;
252        }
253        let service = match ServiceConfig::load(&service_toml) {
254            Ok(s) => s,
255            Err(e) => {
256                tracing::warn!(
257                    path = %service_toml.display(),
258                    error = %e,
259                    "skipping service with unparseable service.toml"
260                );
261                continue;
262            }
263        };
264
265        let mirrors_dir = svc_dir.join("mirrors");
266        if !mirrors_dir.exists() {
267            continue;
268        }
269        let mut mirror_entries: Vec<_> = std::fs::read_dir(&mirrors_dir)
270            .with_context(|| format!("reading {}", mirrors_dir.display()))?
271            .filter_map(|e| e.ok())
272            .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
273            .collect();
274        mirror_entries.sort_by_key(|e| e.file_name());
275
276        for m in mirror_entries {
277            let path = m.path();
278            let env = path
279                .file_stem()
280                .and_then(|s| s.to_str())
281                .unwrap_or_default()
282                .to_string();
283            let mirror = match MirrorConfig::load(&path) {
284                Ok(mc) => mc,
285                Err(e) => {
286                    tracing::warn!(
287                        path = %path.display(),
288                        error = %e,
289                        "skipping mirror with parse error"
290                    );
291                    continue;
292                }
293            };
294
295            let mut roles: Vec<&String> = mirror.providers.keys().collect();
296            roles.sort();
297            for role in roles {
298                let slot = &mirror.providers[role];
299                if !slot_binds_localhost(slot) {
300                    continue;
301                }
302                for field in PORT_FIELDS {
303                    let Some(port) = crate::reconciler::slot_field_u16(slot.fields(), field) else {
304                        continue;
305                    };
306                    let source = PortSource {
307                        service: service.name.clone(),
308                        env: env.clone(),
309                        slot_role: role.clone(),
310                        field: (*field).to_string(),
311                    };
312                    match seen.get(&port) {
313                        Some(first) => collisions.push(PortCollision {
314                            port,
315                            first: first.clone(),
316                            second: source,
317                        }),
318                        None => {
319                            seen.insert(port, source);
320                        }
321                    }
322                }
323            }
324        }
325    }
326
327    Ok(collisions)
328}
329
330// ── Tests ──────────────────────────────────────────────────────────────────
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use tempfile::tempdir;
336
337    fn write_service(workspace: &Path, svc_name: &str, component_path: &str) {
338        let svc_dir = workspace.join(".yah/services").join(svc_name);
339        std::fs::create_dir_all(&svc_dir).unwrap();
340        let toml = format!(
341            "schema_version = 1\nname = \"{svc_name}\"\ndomain = \"{svc_name}.example.com\"\n\
342             [[components]]\nid = \"models\"\nkind = \"static-asset\"\n\
343             path = \"{component_path}\"\nrole = \"static\"\n"
344        );
345        std::fs::write(svc_dir.join("service.toml"), toml).unwrap();
346    }
347
348    fn write_workload_with_aliases(dir: &Path, aliases: &[(&str, &str)]) {
349        std::fs::create_dir_all(dir).unwrap();
350        let alias_lines: String = aliases
351            .iter()
352            .map(|(k, v)| format!("\"{k}\" = \"{v}\"\n"))
353            .collect();
354        let content = format!(
355            "kind = \"static-asset\"\nschema_version = \"V1\"\n\
356             [aliases]\n{alias_lines}"
357        );
358        std::fs::write(dir.join("workload.toml"), content).unwrap();
359    }
360
361    #[test]
362    fn cloud_validate_clean_workspace_returns_empty() {
363        let dir = tempdir().unwrap();
364        let root = dir.path();
365
366        write_service(root, "svc-a", "svc-a/models");
367        write_workload_with_aliases(
368            &root.join("svc-a/models"),
369            &[("whisper-default-ggml", "svc-a/whisper/model.bin")],
370        );
371
372        let collisions = check_alias_collisions(root).unwrap();
373        assert!(
374            collisions.is_empty(),
375            "expected no collisions: {collisions:?}"
376        );
377    }
378
379    #[test]
380    fn cloud_validate_rejects_alias_collision() {
381        let dir = tempdir().unwrap();
382        let root = dir.path();
383
384        write_service(root, "svc-a", "svc-a/models");
385        write_workload_with_aliases(
386            &root.join("svc-a/models"),
387            &[("whisper-default-ggml", "svc-a/whisper/model.bin")],
388        );
389
390        write_service(root, "svc-b", "svc-b/models");
391        write_workload_with_aliases(
392            &root.join("svc-b/models"),
393            &[("whisper-default-ggml", "svc-b/whisper/model.bin")],
394        );
395
396        let collisions = check_alias_collisions(root).unwrap();
397        assert_eq!(
398            collisions.len(),
399            1,
400            "expected one collision: {collisions:?}"
401        );
402        let c = &collisions[0];
403        assert_eq!(c.alias, "whisper-default-ggml");
404        assert_eq!(c.first.service, "svc-a");
405        assert_eq!(c.second.service, "svc-b");
406
407        let msg = c.message();
408        assert!(msg.contains("whisper-default-ggml"), "message: {msg}");
409        assert!(msg.contains("svc-a"), "message: {msg}");
410        assert!(msg.contains("svc-b"), "message: {msg}");
411    }
412
413    #[test]
414    fn cloud_validate_distinct_aliases_no_collision() {
415        let dir = tempdir().unwrap();
416        let root = dir.path();
417
418        write_service(root, "svc-a", "svc-a/models");
419        write_workload_with_aliases(
420            &root.join("svc-a/models"),
421            &[
422                ("whisper-default-ggml", "svc-a/model.bin"),
423                ("whisper-default", "svc-a/model.bin"),
424            ],
425        );
426
427        write_service(root, "svc-b", "svc-b/models");
428        write_workload_with_aliases(
429            &root.join("svc-b/models"),
430            &[("whisper-default-coreml", "svc-b/model.tar.gz")],
431        );
432
433        let collisions = check_alias_collisions(root).unwrap();
434        assert!(collisions.is_empty());
435    }
436
437    #[test]
438    fn cloud_validate_multiple_collisions_all_reported() {
439        let dir = tempdir().unwrap();
440        let root = dir.path();
441
442        write_service(root, "svc-a", "svc-a/models");
443        write_workload_with_aliases(
444            &root.join("svc-a/models"),
445            &[
446                ("alias-one", "svc-a/one.bin"),
447                ("alias-two", "svc-a/two.bin"),
448            ],
449        );
450
451        write_service(root, "svc-b", "svc-b/models");
452        write_workload_with_aliases(
453            &root.join("svc-b/models"),
454            &[
455                ("alias-one", "svc-b/one.bin"),
456                ("alias-two", "svc-b/two.bin"),
457            ],
458        );
459
460        let collisions = check_alias_collisions(root).unwrap();
461        assert_eq!(collisions.len(), 2);
462        let names: Vec<_> = collisions.iter().map(|c| c.alias.as_str()).collect();
463        assert!(names.contains(&"alias-one"));
464        assert!(names.contains(&"alias-two"));
465    }
466
467    #[test]
468    fn cloud_validate_missing_services_dir_is_not_error() {
469        let dir = tempdir().unwrap();
470        let collisions = check_alias_collisions(dir.path()).unwrap();
471        assert!(collisions.is_empty());
472    }
473
474    #[test]
475    fn cloud_validate_non_static_asset_workloads_ignored() {
476        let dir = tempdir().unwrap();
477        let root = dir.path();
478
479        write_service(root, "svc-a", "svc-a/api");
480        // Write a container workload — no [aliases] block, should be silently skipped.
481        let workload_dir = root.join("svc-a/api");
482        std::fs::create_dir_all(&workload_dir).unwrap();
483        // Just make the kind non-static-asset to ensure we skip it.
484        // (Writes a valid mesofact-static workload which has no aliases)
485        std::fs::write(
486            workload_dir.join("workload.toml"),
487            "schema_version = \"V1\"\nname = \"api\"\nkind = \"mesofact-static\"\n\
488             bundle_dir = \"dist\"\n",
489        )
490        .unwrap();
491
492        let collisions = check_alias_collisions(root).unwrap();
493        assert!(collisions.is_empty());
494    }
495
496    // ── Port collisions (R602-B4) ────────────────────────────────────────────
497
498    fn write_mirror(workspace: &Path, svc: &str, env: &str, body: &str) {
499        let dir = workspace.join(".yah/services").join(svc).join("mirrors");
500        std::fs::create_dir_all(&dir).unwrap();
501        std::fs::write(dir.join(format!("{env}.toml")), body).unwrap();
502    }
503
504    fn local_static_mirror(port: u16) -> String {
505        format!(
506            "schema_version = 1\nshape = \"local\"\n\
507             [providers.static]\nkind = \"local-static\"\nport = {port}\n"
508        )
509    }
510
511    #[test]
512    fn port_collision_across_services_and_envs_is_flagged() {
513        let dir = tempdir().unwrap();
514        let root = dir.path();
515        write_service(root, "scrabcake", "scrabcake/site");
516        write_mirror(root, "scrabcake", "dev", &local_static_mirror(4322));
517        write_service(root, "yah-marketing", "yah-marketing/site");
518        write_mirror(
519            root,
520            "yah-marketing",
521            "pond",
522            "schema_version = 1\nshape = \"local\"\n\
523             [providers.static]\nkind = \"miniflare-container\"\nport = 4322\n",
524        );
525
526        let cols = check_port_collisions(root).unwrap();
527        assert_eq!(cols.len(), 1, "{cols:?}");
528        assert_eq!(cols[0].port, 4322);
529        // Deterministic: "scrabcake" sorts before "yah-marketing".
530        assert_eq!(cols[0].first.service, "scrabcake");
531        assert_eq!(cols[0].second.service, "yah-marketing");
532        assert!(cols[0].is_cross_service(), "different services collide");
533        let msg = cols[0].message();
534        assert!(msg.contains("4322"), "{msg}");
535        assert!(msg.contains("scrabcake"), "{msg}");
536        assert!(msg.contains("yah-marketing"), "{msg}");
537    }
538
539    #[test]
540    fn distinct_ports_no_collision() {
541        let dir = tempdir().unwrap();
542        let root = dir.path();
543        write_service(root, "a", "a/site");
544        write_mirror(root, "a", "dev", &local_static_mirror(4322));
545        write_service(root, "b", "b/site");
546        write_mirror(root, "b", "dev", &local_static_mirror(4323));
547        assert!(check_port_collisions(root).unwrap().is_empty());
548    }
549
550    #[test]
551    fn same_service_two_envs_reusing_a_port_is_flagged() {
552        // The ticket's "scrabcake cloud+dev reuse <port> twice more" shape.
553        let dir = tempdir().unwrap();
554        let root = dir.path();
555        write_service(root, "scrabcake", "scrabcake/site");
556        write_mirror(root, "scrabcake", "dev", &local_static_mirror(4352));
557        write_mirror(root, "scrabcake", "cloud", &local_static_mirror(4352));
558        let cols = check_port_collisions(root).unwrap();
559        assert_eq!(cols.len(), 1, "{cols:?}");
560        assert_eq!(cols[0].port, 4352);
561        // "cloud" sorts before "dev".
562        assert_eq!(cols[0].first.env, "cloud");
563        assert_eq!(cols[0].second.env, "dev");
564        assert!(
565            !cols[0].is_cross_service(),
566            "same service across envs is NOT cross-service"
567        );
568    }
569
570    #[test]
571    fn reference_slots_do_not_bind_localhost_and_are_ignored() {
572        // A `use = "..."` reference slot points at a cloud provider — reusing a
573        // `port` field there is not a localhost collision.
574        let dir = tempdir().unwrap();
575        let root = dir.path();
576        let ref_slot = "schema_version = 1\nshape = \"local\"\n\
577             [providers.static]\nuse = \"cloudflare\"\nport = 8080\n";
578        write_service(root, "a", "a/site");
579        write_mirror(root, "a", "cloud", ref_slot);
580        write_service(root, "b", "b/site");
581        write_mirror(root, "b", "cloud", ref_slot);
582        assert!(check_port_collisions(root).unwrap().is_empty());
583    }
584
585    #[test]
586    fn minio_api_and_console_ports_collide_across_ponds() {
587        // Two pond MinIO slots on the same api_port bind the same host port.
588        let dir = tempdir().unwrap();
589        let root = dir.path();
590        let minio = "schema_version = 1\nshape = \"local\"\n\
591             [providers.object_store]\nkind = \"minio-container\"\napi_port = 9000\nconsole_port = 9001\n";
592        write_service(root, "a", "a/site");
593        write_mirror(root, "a", "pond", minio);
594        write_service(root, "b", "b/site");
595        write_mirror(root, "b", "pond", minio);
596        let cols = check_port_collisions(root).unwrap();
597        // Both api_port (9000) and console_port (9001) collide.
598        assert_eq!(cols.len(), 2, "{cols:?}");
599        let ports: Vec<u16> = cols.iter().map(|c| c.port).collect();
600        assert!(ports.contains(&9000));
601        assert!(ports.contains(&9001));
602    }
603
604    #[test]
605    fn missing_services_dir_is_not_error_for_ports() {
606        let dir = tempdir().unwrap();
607        assert!(check_port_collisions(dir.path()).unwrap().is_empty());
608    }
609}