Skip to main content

cloud/
validate.rs

1//! Workspace-wide lint checks for the `.yah/` declaration tree (R470-T3).
2//!
3//! Every check shares one shape — walk the declarations, return a list of
4//! findings, empty means clean:
5//!
6//! - **alias collision** ([`check_alias_collisions`]) — alias names declared
7//!   in any service component's `[aliases]` block must be workspace-globally
8//!   unique. Two services declaring the same alias is a consumer-site
9//!   ambiguity (`yah-app.toml` says `alias = "whisper-default-ggml"` — which
10//!   catalog wins?).
11//! - **port collision** ([`check_port_collisions`]) — two local-tier mirror
12//!   slots binding the same localhost port (R602-B4).
13//! - **inert taint** ([`check_inert_taints`]) — a `taints` entry in
14//!   `.yah/infra/machines/*.toml` that no scheduler path can read
15//!   (W305/R742-T4).
16//! - **retired arch tag** ([`check_retired_arch_tags`]) — a machine still
17//!   carrying the `tier:<arch>` build-worker mesh tag R763 renamed.
18//! - **unroled sovereign member**
19//!   ([`check_unroled_sovereign_members`]) — a machine naming a
20//!   `sovereign_group` without saying whether it votes in it (R605-F12).
21//! - **LAN dial target** ([`check_lan_dial_targets`]) — a machine whose
22//!   `[connect].yubaba` is an RFC1918 literal, i.e. a break-glass address
23//!   sitting in the field every automated path dials (R605-T10).
24//! - **ingress collation** ([`collate_workspace_ingress`]) — two services
25//!   whose declared edges cannot share the node they both front through
26//!   (W305/R742-F2). The only check here that is *inherently* cross-service:
27//!   each service's own `yah cloud apply` sees one mirror, so a hostname
28//!   claimed twice on one box is invisible from either side of it.
29//!
30//! What unites them: each catches a declaration that *parses*, so nothing
31//! downstream complains, but which means something other than what it reads
32//! as. That is the class of bug this module exists for — a hard error is the
33//! type system's job, and a wrong-but-valid declaration is nobody's until
34//! someone writes the lint.
35//!
36//! Invoked by `yah cloud validate` and as a preflight in `yah cloud apply`.
37//!
38//! @yah:relay(R787, "Consolidate the four .yah/infra/machines/*.toml walks in oss/yubaba/crates/cloud/src/validate.rs behind one loader")
39//! @yah:status(review)
40//! @yah:at(2026-08-20T05:26:41Z)
41//! @yah:assignee(agent:bundle-anthropic-miravel)
42//! @yah:notify_on(R555, "Re-run `cd oss/yubaba && cargo test -p yah-cloud --lib validate::` — it was blocked crate-wide by R555's in-flight AdmissionGrant.secrets field missing from crates/cloud/src/reconciler/lowering_golden.rs's TransformRecipe fixture (E0063), unrelated to R787's validate.rs change. Confirm the two new tests (tolerant_mode_skips_an_unparseable_toml_and_still_pairs_the_path, strict_mode_fails_the_whole_load_on_one_unparseable_toml) and the existing validate:: suite pass once that's fixed.")
43//! @yah:handoff("Consolidated the four .yah/infra/machines/*.toml walks (check_inert_taints, check_retired_arch_tags, check_unroled_sovereign_members, load_machines) behind one shared load_machine_tomls(workspace_root, mode) in oss/yubaba/crates/cloud/src/validate.rs. Returns Vec<(PathBuf, MachineConfig)> per the agreed shape (Ashguard/R605-F12) so lint findings still name the file.")
44//! @yah:handoff("MachineLoadMode::Tolerant preserves the three lints' existing skip-and-warn behavior; MachineLoadMode::Strict preserves load_machines' hard-fail behavior for collate_workspace_ingress's placement resolution (R772) -- no behavior change at any of the four call sites.")
45//! @yah:handoff("Closed the vacuous-pass trap flagged in the ticket: added assert_machine_toml_parses(), called by write_machine, write_machine_tags, and write_sovereign_machine right after writing each fixture, so a future edit that drops a required MachineConfig field fails loudly at the helper instead of being silently skipped by the tolerant loader and producing a vacuous assert-empty pass.")
46//! @yah:handoff("Added two new tests locking in the Strict/Tolerant contract directly: tolerant_mode_skips_an_unparseable_toml_and_still_pairs_the_path, strict_mode_fails_the_whole_load_on_one_unparseable_toml.")
47//! @yah:handoff("R772's load_machines and R605-F12's check_unroled_sovereign_members were already landed and committed on this file before this pass (verified via git diff being empty and no live session on validate.rs at pickup) -- both were settled, so this was safe to do now rather than wait further.")
48//! @yah:verify("cargo check -p yah-cloud --lib (from repo root) -- clean, 0 warnings in validate.rs (2 pre-existing unrelated warnings elsewhere: mesofact_static.rs unused imports, and one more in a different file).")
49//! @yah:verify("cargo test -p yah-cloud --lib validate:: (from oss/yubaba, required for dev-deps) -- BLOCKED, not failing: E0063 missing field `secrets` in crates/cloud/src/reconciler/lowering_golden.rs's TransformRecipe fixture, caused by R555's in-flight uncommitted AdmissionGrant.secrets field in oss/qed/crates/velveteen-exec (git status confirms those files are live-modified, lowering_golden.rs is not). This is the same blocker R605-F12's own verify section recorded. Filed @yah:notify_on(R555) on this ticket so whoever reopens it re-runs the suite once R555 lands.")
50//! @yah:verify("Manual read-through of the diff: every lint's iteration body (finding construction) is byte-identical to before, only the walk+parse boilerplate was extracted -- no logic changed at any of the four call sites.")
51//! @yah:gotcha("Test verification for this crate is blocked camp-wide right now by R555 (live, Ashguard) -- see notify_on. Not this ticket's bug; do not attempt to fix lowering_golden.rs or velveteen-exec from here.")
52
53use std::collections::BTreeMap;
54use std::path::{Path, PathBuf};
55
56use anyhow::Context as _;
57
58use crate::config::{
59    live_taint_keys, private_ipv4_from_url, MachineConfig, MirrorConfig, MirrorProviderSlot,
60    Provider, ServiceConfig,
61};
62use crate::paths::{machines_dir, services_dir};
63
64/// Where an alias is declared — points the operator at the source row.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct AliasSource {
67    /// Service name (matches `service.toml`'s `name` field).
68    pub service: String,
69    /// Component `id` within that service.
70    pub component_id: String,
71    /// Absolute path to the `workload.toml` containing the `[aliases]` block.
72    pub workload_toml: PathBuf,
73}
74
75/// A duplicate alias declaration found across two components.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct AliasCollision {
78    pub alias: String,
79    pub first: AliasSource,
80    pub second: AliasSource,
81}
82
83impl AliasCollision {
84    /// Human-readable error message matching the format described in W193.
85    pub fn message(&self) -> String {
86        format!(
87            "alias {:?} declared in both {} (component {}) and {} (component {})\n\
88             \u{2192} rename the alias in one of these files:\n  {}\n  {}",
89            self.alias,
90            self.first.service,
91            self.first.component_id,
92            self.second.service,
93            self.second.component_id,
94            self.first.workload_toml.display(),
95            self.second.workload_toml.display(),
96        )
97    }
98}
99
100/// Walk every service's static-asset components and collect all `(alias →
101/// source)` mappings. Returns a list of collisions (empty when clean).
102///
103/// Missing `.yah/services/` directory is not an error — returns empty.
104pub fn check_alias_collisions(workspace_root: &Path) -> anyhow::Result<Vec<AliasCollision>> {
105    let dir = services_dir(workspace_root);
106    if !dir.exists() {
107        return Ok(vec![]);
108    }
109
110    // alias_name → first source seen
111    let mut seen: BTreeMap<String, AliasSource> = BTreeMap::new();
112    let mut collisions = Vec::new();
113
114    let mut entries: Vec<_> = std::fs::read_dir(&dir)
115        .with_context(|| format!("reading {}", dir.display()))?
116        .filter_map(|e| e.ok())
117        .filter(|e| e.path().is_dir())
118        .collect();
119    entries.sort_by_key(|e| e.file_name());
120
121    for entry in entries {
122        let svc_dir = entry.path();
123        let service_toml = svc_dir.join("service.toml");
124        if !service_toml.exists() {
125            continue;
126        }
127        let service = match ServiceConfig::load(&service_toml) {
128            Ok(s) => s,
129            Err(e) => {
130                tracing::warn!(
131                    path = %service_toml.display(),
132                    error = %e,
133                    "skipping service with unparseable service.toml"
134                );
135                continue;
136            }
137        };
138
139        for component in &service.components {
140            if component.kind != "static-asset" {
141                continue;
142            }
143            let workload_dir = workspace_root.join(&component.path);
144            let workload_toml_path = workload_dir.join("workload.toml");
145            if !workload_toml_path.exists() {
146                continue;
147            }
148
149            let aliases = match load_static_asset_aliases(&workload_toml_path) {
150                Ok(a) => a,
151                Err(e) => {
152                    tracing::warn!(
153                        path = %workload_toml_path.display(),
154                        error = %e,
155                        "skipping workload.toml with parse error"
156                    );
157                    continue;
158                }
159            };
160
161            for alias_name in aliases.keys() {
162                let source = AliasSource {
163                    service: service.name.clone(),
164                    component_id: component.id.clone(),
165                    workload_toml: workload_toml_path.clone(),
166                };
167                if let Some(first) = seen.get(alias_name) {
168                    collisions.push(AliasCollision {
169                        alias: alias_name.clone(),
170                        first: first.clone(),
171                        second: source,
172                    });
173                } else {
174                    seen.insert(alias_name.clone(), source);
175                }
176            }
177        }
178    }
179
180    Ok(collisions)
181}
182
183/// Load the `[aliases]` block from a `workload.toml` that must be a
184/// `static-asset` kind. Returns an empty map for non-static-asset workloads
185/// (so the caller skips them silently).
186fn load_static_asset_aliases(path: &Path) -> anyhow::Result<BTreeMap<String, String>> {
187    let src =
188        std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
189    let workload: workload_spec::Workload =
190        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
191    match workload {
192        workload_spec::Workload::StaticAsset(w) => Ok(w.aliases),
193        _ => Ok(BTreeMap::new()),
194    }
195}
196
197// ── Port collisions (R602-B4) ────────────────────────────────────────────────
198
199/// Where a host port is declared — points the operator at the (service, env,
200/// slot) that binds it.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct PortSource {
203    /// Service name (matches `service.toml`'s `name` field).
204    pub service: String,
205    /// Environment (file stem of `mirrors/<env>.toml`).
206    pub env: String,
207    /// Provider slot role the port sits under (`providers.<role>`).
208    pub slot_role: String,
209    /// The field that carried the port (`port` / `api_port` / `console_port`).
210    pub field: String,
211}
212
213/// Two local-tier mirror slots that bind the same host port. Because local
214/// mirrors share the operator's localhost, both binding the same port collide
215/// when brought up together — and the local-static adopt probe (a bare TCP
216/// connect) may then silently adopt the *wrong* service (R602-B4: `scrabcake`
217/// dev and `yah-marketing` pond both on 4322).
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct PortCollision {
220    pub port: u16,
221    pub first: PortSource,
222    pub second: PortSource,
223}
224
225impl PortCollision {
226    /// True when the two binders belong to different services — the dangerous
227    /// case, because the local-static adopt probe can then silently adopt the
228    /// *other* service's server. Same-service reuse (e.g. one service's dev +
229    /// cloud mirrors sharing a port) is only a can't-co-run bind conflict.
230    pub fn is_cross_service(&self) -> bool {
231        self.first.service != self.second.service
232    }
233
234    /// Human-readable error naming both binders + the fix.
235    pub fn message(&self) -> String {
236        format!(
237            "host port {} is bound by both {}/{} (providers.{}.{}) and {}/{} (providers.{}.{})\n\
238             \u{2192} give one a distinct port — local mirrors share the operator's localhost, so \
239             two slots on the same port collide, and the local-static adopt probe may silently \
240             adopt the wrong service.",
241            self.port,
242            self.first.service,
243            self.first.env,
244            self.first.slot_role,
245            self.first.field,
246            self.second.service,
247            self.second.env,
248            self.second.slot_role,
249            self.second.field,
250        )
251    }
252}
253
254/// Host-port field names a local-binding mirror slot may declare.
255const PORT_FIELDS: &[&str] = &["port", "api_port", "console_port"];
256
257/// True when this slot binds a port on the operator's localhost, so its port
258/// contends with every other local slot. Reference slots (`use = "..."`) and
259/// cloud/CF slots don't bind localhost and are skipped.
260fn slot_binds_localhost(slot: &MirrorProviderSlot) -> bool {
261    matches!(
262        slot.inline_kind(),
263        Some(Provider::LocalStatic | Provider::MiniflareContainer | Provider::MinioContainer)
264    )
265}
266
267/// Walk every service mirror and flag host-port reuse across local-tier
268/// provider slots (R602-B4). Only slots that bind a port on the operator's
269/// localhost are considered (`local-static`, `miniflare-container`,
270/// `minio-container`) — cloud/CF slots don't contend for localhost.
271///
272/// Deterministic: services + mirror envs are walked in sorted order, slot
273/// roles sorted, `PORT_FIELDS` in declared order — so the "first" binder of a
274/// port is stable across runs. Missing `.yah/services/` is not an error.
275pub fn check_port_collisions(workspace_root: &Path) -> anyhow::Result<Vec<PortCollision>> {
276    // port → first source seen
277    let mut seen: BTreeMap<u16, PortSource> = BTreeMap::new();
278    let mut collisions = Vec::new();
279
280    for m in load_service_mirrors(workspace_root)? {
281        let mut roles: Vec<&String> = m.mirror.providers.keys().collect();
282        roles.sort();
283        for role in roles {
284            let slot = &m.mirror.providers[role];
285            if !slot_binds_localhost(slot) {
286                continue;
287            }
288            for field in PORT_FIELDS {
289                let Some(port) = crate::reconciler::slot_field_u16(slot.fields(), field) else {
290                    continue;
291                };
292                let source = PortSource {
293                    service: m.service.clone(),
294                    env: m.env.clone(),
295                    slot_role: role.clone(),
296                    field: (*field).to_string(),
297                };
298                match seen.get(&port) {
299                    Some(first) => collisions.push(PortCollision {
300                        port,
301                        first: first.clone(),
302                        second: source,
303                    }),
304                    None => {
305                        seen.insert(port, source);
306                    }
307                }
308            }
309        }
310    }
311
312    Ok(collisions)
313}
314
315// ── Shared machine-TOML loader (R787) ───────────────────────────────────────
316
317/// How [`load_machine_tomls`] handles a `.yah/infra/machines/*.toml` that
318/// fails to read or parse.
319#[derive(Debug, Clone, Copy, PartialEq, Eq)]
320pub enum MachineLoadMode {
321    /// Skip the file with a `tracing::warn!` and continue — the lint sweeps'
322    /// existing behavior. A peer's half-written scaffold on a shared tree
323    /// must not blind the sweep to every other finding it would report.
324    Tolerant,
325    /// Fail the whole load on the first read/parse error — for callers where
326    /// silently dropping a machine could produce a wrong-but-successful
327    /// result, e.g. [`collate_workspace_ingress`] resolving a `required`
328    /// placement constraint onto a node that isn't actually a candidate.
329    Strict,
330}
331
332/// Every `.yah/infra/machines/*.toml`, paired with the path that declared it
333/// — every lint finding names the file the operator opens, and
334/// [`collate_workspace_ingress`]'s placement resolution has no use for the
335/// path but takes it anyway rather than forcing a second loader to exist.
336///
337/// Missing `.yah/infra/machines/` is not an error — a fresh camp declares no
338/// nodes. Files are walked in sorted-filename order so callers that report
339/// findings (or resolve a first match) are deterministic across runs.
340pub fn load_machine_tomls(
341    workspace_root: &Path,
342    mode: MachineLoadMode,
343) -> anyhow::Result<Vec<(PathBuf, MachineConfig)>> {
344    let dir = machines_dir(workspace_root);
345    if !dir.exists() {
346        return Ok(Vec::new());
347    }
348
349    let mut entries: Vec<_> = std::fs::read_dir(&dir)
350        .with_context(|| format!("reading {}", dir.display()))?
351        .filter_map(|e| e.ok())
352        .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
353        .collect();
354    entries.sort_by_key(|e| e.file_name());
355
356    let mut out = Vec::with_capacity(entries.len());
357    for entry in entries {
358        let path = entry.path();
359        let src = match std::fs::read_to_string(&path) {
360            Ok(s) => s,
361            Err(e) if mode == MachineLoadMode::Tolerant => {
362                tracing::warn!(path = %path.display(), error = %e, "skipping unreadable machine toml");
363                continue;
364            }
365            Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
366        };
367        let machine: MachineConfig = match toml::from_str(&src) {
368            Ok(m) => m,
369            Err(e) if mode == MachineLoadMode::Tolerant => {
370                tracing::warn!(path = %path.display(), error = %e, "skipping unparseable machine toml");
371                continue;
372            }
373            Err(e) => return Err(e).with_context(|| format!("parsing {}", path.display())),
374        };
375        out.push((path, machine));
376    }
377    Ok(out)
378}
379
380// ── R742-T4 (W305): inert node taints ──────────────────────────────────────
381
382/// A declared node taint no placement decision can read.
383#[derive(Debug, Clone, PartialEq, Eq)]
384pub struct InertTaint {
385    /// Machine name as declared in the TOML.
386    pub machine: String,
387    /// Path to the declaring `.yah/infra/machines/<name>.toml`.
388    pub machine_toml: PathBuf,
389    /// The offending key.
390    pub key: String,
391}
392
393impl InertTaint {
394    pub fn message(&self) -> String {
395        format!(
396            "machine {:?} declares the taint {:?}, which no placement decision can read\n\
397             \u{2192} a taint fires in exactly two ways: as repulsion \
398             (`no-server` / `no-appliance` / `no-job`, an absolute block on that archetype), \
399             or as affinity (a key a workload names in `yah.placement.requires-taint`).\n\
400             \u{2192} legal keys today: {}\n\
401             \u{2192} if this is a *fact* about the node rather than a placement input, \
402             move it to `mesh_tags` or a comment; if it should really constrain placement, \
403             add it to cloud::config::AFFINITY_TAINT_KEYS together with the workload that \
404             requires it.\n  {}",
405            self.machine,
406            self.key,
407            live_taint_keys().join(", "),
408            self.machine_toml.display(),
409        )
410    }
411}
412
413/// Flag every taint in `.yah/infra/machines/*.toml` that the scheduler cannot
414/// act on (W305 finding 1 / R742-T4).
415///
416/// Why this is a *lint* and not a parse error: an inert taint breaks nothing.
417/// It changes no placement decision — that is the entire complaint. Refusing
418/// to deserialize would make an unrelated `yah cloud machine status` fail on a
419/// cosmetic problem, so the judgement is made where the operator asks for it.
420///
421/// **Camp-local machines only.** [`machines_dir`] is deliberately not the
422/// merged view `CloudConfig::load` builds from `.yah/infra/sources.toml` — a
423/// borrowed machine is declared in someone else's tree, where this camp cannot
424/// fix it, and a lint that cannot be resolved is noise that trains the
425/// operator to ignore the whole check.
426///
427/// Missing `.yah/infra/machines/` is not an error — a fresh camp declares no
428/// nodes. Unparseable files are skipped with a warning rather than aborting
429/// the sweep, matching [`check_port_collisions`]; whatever is wrong with them
430/// is a bigger problem than a taint key and surfaces on the load path.
431pub fn check_inert_taints(workspace_root: &Path) -> anyhow::Result<Vec<InertTaint>> {
432    let mut found = Vec::new();
433    for (path, machine) in load_machine_tomls(workspace_root, MachineLoadMode::Tolerant)? {
434        for key in machine.inert_taints() {
435            found.push(InertTaint {
436                machine: machine.name.clone(),
437                machine_toml: path.clone(),
438                key: key.to_string(),
439            });
440        }
441    }
442    Ok(found)
443}
444
445// ── R763 (W314): the retired `tier:` arch mesh tag ─────────────────────────
446
447/// The mesh-tag prefix that used to carry a build-worker's CPU architecture.
448/// Retired 2026-08-14 — the value was an architecture, not a tier, and it was
449/// squatting a prefix the environment axis wants.
450const RETIRED_ARCH_TAG_PREFIX: &str = "tier:";
451/// What it became. [`qed::platform::build_worker_mesh_tags`] emits this.
452const ARCH_TAG_PREFIX: &str = "arch:";
453
454/// A machine still declaring the retired `tier:<arch>` build-worker mesh tag.
455#[derive(Debug, Clone, PartialEq, Eq)]
456pub struct RetiredArchTag {
457    pub machine: String,
458    pub machine_toml: PathBuf,
459    /// The offending tag, verbatim (e.g. `tier:x86`).
460    pub tag: String,
461}
462
463impl RetiredArchTag {
464    /// The replacement tag — same value, current prefix.
465    pub fn replacement(&self) -> String {
466        format!(
467            "{ARCH_TAG_PREFIX}{}",
468            self.tag.trim_start_matches(RETIRED_ARCH_TAG_PREFIX)
469        )
470    }
471
472    pub fn message(&self) -> String {
473        format!(
474            "machine {:?} declares the retired mesh tag {:?} — rename it to {:?}\n\
475             \u{2192} `tier:x86` / `tier:arm` were renamed to `arch:x86` / `arch:arm` \
476             (R763): the value is a CPU architecture, not a tier, and `tier:` is \
477             reserved for the environment axis.\n\
478             \u{2192} this does not fail loudly on its own, which is why it is checked \
479             here: `qed::platform::build_worker_mesh_tags` now requests `arch:<arch>`, \
480             and placement is SUPERSET matching, so a node still carrying the old tag \
481             simply stops matching and the build reports 'no node' instead of \
482             'wrong tag'.\n  {}",
483            self.machine,
484            self.tag,
485            self.replacement(),
486            self.machine_toml.display(),
487        )
488    }
489}
490
491/// Flag every machine still carrying a `tier:<arch>` build-worker mesh tag.
492///
493/// Same lint family, and the same camp-local-only scoping, as
494/// [`check_inert_taints`] — but note the failure it catches is *quieter* than
495/// an inert taint. An inert taint changes no decision; a stale arch tag changes
496/// the decision to "no candidate node", and the operator sees a placement
497/// failure with no hint that a tag rename caused it.
498pub fn check_retired_arch_tags(workspace_root: &Path) -> anyhow::Result<Vec<RetiredArchTag>> {
499    let mut found = Vec::new();
500    for (path, machine) in load_machine_tomls(workspace_root, MachineLoadMode::Tolerant)? {
501        for tag in machine
502            .mesh_tags
503            .iter()
504            .filter(|t| t.starts_with(RETIRED_ARCH_TAG_PREFIX))
505        {
506            found.push(RetiredArchTag {
507                machine: machine.name.clone(),
508                machine_toml: path.clone(),
509                tag: tag.clone(),
510            });
511        }
512    }
513    Ok(found)
514}
515
516// ── R605-F12: a group stamp with no role beside it ─────────────────────────
517
518/// A machine declaring `sovereign_group` without an explicit `sovereign_role`.
519#[derive(Debug, Clone, PartialEq, Eq)]
520pub struct UnroledSovereignMember {
521    pub machine: String,
522    pub machine_toml: PathBuf,
523    /// The group it declares — named in the message because the answer to
524    /// "voter or not" depends on which blast radius is being joined.
525    pub group: String,
526}
527
528impl UnroledSovereignMember {
529    pub fn message(&self) -> String {
530        format!(
531            "machine {:?} declares `sovereign_group = {:?}` but no `sovereign_role`\n\
532             \u{2192} membership and quorum eligibility are separate axes (R605-F12): a node can \
533             be inside a group's blast radius — its secrets, its upgrade cadence, its \
534             destruction — and still never hold a seat in its quorum.\n\
535             \u{2192} an absent role reads as `\"voter\"`, so this box is quorum-eligible today. \
536             That is the pre-R605-F12 meaning and is often right; the complaint is that nothing \
537             records whether anyone decided it.\n\
538             \u{2192} add `sovereign_role = \"voter\"` or `sovereign_role = \"non-voter\"` as a \
539             TOP-LEVEL key (below a `[table]` header TOML makes it a field of that table, and \
540             every consumer reads it as absent).\n  {}",
541            self.machine,
542            self.group,
543            self.machine_toml.display(),
544        )
545    }
546}
547
548/// Flag every machine that names a sovereign group without saying whether it
549/// votes in it (R605-F12).
550///
551/// Same lint family and the same camp-local-only scoping as
552/// [`check_inert_taints`], for a failure one step quieter than either of the
553/// others: an inert taint changes no decision and a stale arch tag changes it
554/// to "no candidate node", but an unwritten role changes nothing *visible* and
555/// silently grants a quorum seat. That is exactly the shape this ticket was
556/// opened about — a guarantee resting on which fields happen to be absent — so
557/// closing it by making the *other* absence load-bearing would have been the
558/// same bug with the sign flipped.
559///
560/// Why a lint rather than a required field: [`MachineConfig`] is deserialized
561/// from foreign trees too (`.yah/infra/sources.toml` overlays another camp's
562/// machines, which may predate this field entirely), and a hard parse error
563/// there is unfixable from here. The judgement is made where the operator asks
564/// for it, against machines this camp can actually edit.
565pub fn check_unroled_sovereign_members(
566    workspace_root: &Path,
567) -> anyhow::Result<Vec<UnroledSovereignMember>> {
568    let mut found = Vec::new();
569    for (path, machine) in load_machine_tomls(workspace_root, MachineLoadMode::Tolerant)? {
570        if let (Some(group), None) = (&machine.sovereign_group, &machine.sovereign_role) {
571            found.push(UnroledSovereignMember {
572                machine: machine.name.clone(),
573                machine_toml: path.clone(),
574                group: group.clone(),
575            });
576        }
577    }
578    Ok(found)
579}
580
581// ── R605-T10: a LAN literal in the field automation dials ──────────────────
582
583/// A machine whose `[connect].yubaba` points at an RFC1918 private address.
584#[derive(Debug, Clone, PartialEq, Eq)]
585pub struct LanDialTarget {
586    pub machine: String,
587    pub machine_toml: PathBuf,
588    /// The offending URL, verbatim (e.g. `http://192.168.10.11:7443`).
589    pub url: String,
590    /// The registered mesh address, when the box has one — the difference
591    /// between "delete a line" and "go join the mesh first".
592    pub mesh_ipv4: Option<String>,
593}
594
595impl LanDialTarget {
596    pub fn message(&self) -> String {
597        let fix = match &self.mesh_ipv4 {
598            Some(ip) => format!(
599                "\u{2192} this box IS mesh-joined at {ip}: DELETE the `yubaba` line. \
600                 `[registration].mesh_ipv4` composes with `[connect].yubaba_port` on its own, \
601                 and `[connect].address` already records the LAN address as metadata."
602            ),
603            None => "\u{2192} this box has no `[registration].mesh_ipv4`: mesh-join it and record \
604                     the tailnet address, or taint it out of placement. Until then it is \
605                     unresolvable to automation and `MachineConfig::reach` refuses it by name."
606                .to_string(),
607        };
608        format!(
609            "machine {:?} declares `[connect].yubaba = {:?}` — a LAN address in the field every \
610             automated path dials\n\
611             \u{2192} the LAN address is an emergency break-glass route, never an official one \
612             (R605-T10, operator 2026-08-19). Automation ALWAYS assumes the caller is not on that \
613             LAN, and this camp genuinely is not — it sits on 192.168.22.0/22 with no route to \
614             192.168.10.0/24.\n\
615             \u{2192} it does not sit beside the mesh route, it OVERRODE it: before this check, a \
616             declared literal beat `[registration].mesh_ipv4` outright (R707-T6), so a healthy \
617             mesh-joined build worker was elected and then dialed at an address only its own \
618             building can reach.\n\
619             {fix}\n\
620             \u{2192} keeping the LAN address is fine and wanted — in `[connect].address` and \
621             `[connect].ssh`, which no resolver dials. A manual SSH session may use it once a \
622             human confirms they are on that LAN.\n  {}",
623            self.machine,
624            self.url,
625            self.machine_toml.display(),
626        )
627    }
628}
629
630/// Flag every machine that has put a LAN literal in `[connect].yubaba`.
631///
632/// Same lint family and camp-local-only scoping as [`check_inert_taints`]. The
633/// failure this one catches is the loudest of the four and still went unnoticed
634/// for weeks, which is the argument for checking it: the declaration parses, the
635/// node is elected by placement, and the only symptom is a connect timeout at
636/// dial time attributed to the box rather than to its file.
637///
638/// Loopback is deliberately not flagged — `http://127.0.0.1:7443` is the
639/// pre-mesh "reach me through the SSH tunnel" declaration, a genuine statement
640/// that `hub::coordinator::is_loopback_url` already judges downstream.
641///
642/// A finding here is now belt-and-braces rather than the only guard:
643/// [`MachineConfig::reach`] refuses to dial a private literal regardless. The
644/// lint exists so the operator hears about it from the file rather than from a
645/// roll that silently picked a different address than the one written down.
646pub fn check_lan_dial_targets(workspace_root: &Path) -> anyhow::Result<Vec<LanDialTarget>> {
647    let mut found = Vec::new();
648    for (path, machine) in load_machine_tomls(workspace_root, MachineLoadMode::Tolerant)? {
649        let Some(url) = machine.connect.as_ref().and_then(|c| c.yubaba.as_deref()) else {
650            continue;
651        };
652        if private_ipv4_from_url(url).is_none() {
653            continue;
654        }
655        found.push(LanDialTarget {
656            machine: machine.name.clone(),
657            machine_toml: path.clone(),
658            url: url.to_string(),
659            mesh_ipv4: machine.mesh_ipv4().map(str::to_string),
660        });
661    }
662    Ok(found)
663}
664
665// ── R742-F2 (W305): ingress edge collation ─────────────────────────────────
666
667/// One mirror, loaded with the identity every finding has to be able to name.
668#[derive(Debug, Clone)]
669pub struct LoadedMirror {
670    /// Service name (matches `service.toml`'s `name` field).
671    pub service: String,
672    /// Environment (file stem of `mirrors/<env>.toml`).
673    pub env: String,
674    /// Path to the mirror TOML — what an operator opens to fix a finding.
675    pub path: PathBuf,
676    pub mirror: MirrorConfig,
677}
678
679/// Every `.yah/services/<svc>/mirrors/<env>.toml` in the workspace, in a
680/// deterministic order (services sorted, then envs).
681///
682/// Shared by every cross-mirror check, because "walk the mirrors" is the one
683/// part all of them agree on and three hand-rolled copies of it drift. A
684/// service with no `service.toml`, or a mirror that will not parse, is skipped
685/// with a warning rather than aborting the sweep — whatever is wrong with it is
686/// a bigger problem than the lint and surfaces on the load path.
687pub fn load_service_mirrors(workspace_root: &Path) -> anyhow::Result<Vec<LoadedMirror>> {
688    let dir = services_dir(workspace_root);
689    if !dir.exists() {
690        return Ok(vec![]);
691    }
692
693    let mut svc_entries: Vec<_> = std::fs::read_dir(&dir)
694        .with_context(|| format!("reading {}", dir.display()))?
695        .filter_map(|e| e.ok())
696        .filter(|e| e.path().is_dir())
697        .collect();
698    svc_entries.sort_by_key(|e| e.file_name());
699
700    let mut out = Vec::new();
701    for entry in svc_entries {
702        let svc_dir = entry.path();
703        let service_toml = svc_dir.join("service.toml");
704        if !service_toml.exists() {
705            continue;
706        }
707        let service = match ServiceConfig::load(&service_toml) {
708            Ok(s) => s,
709            Err(e) => {
710                tracing::warn!(
711                    path = %service_toml.display(),
712                    error = %e,
713                    "skipping service with unparseable service.toml"
714                );
715                continue;
716            }
717        };
718
719        let mirrors_dir = svc_dir.join("mirrors");
720        if !mirrors_dir.exists() {
721            continue;
722        }
723        let mut mirror_entries: Vec<_> = std::fs::read_dir(&mirrors_dir)
724            .with_context(|| format!("reading {}", mirrors_dir.display()))?
725            .filter_map(|e| e.ok())
726            .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
727            .collect();
728        mirror_entries.sort_by_key(|e| e.file_name());
729
730        for m in mirror_entries {
731            let path = m.path();
732            let mirror = match MirrorConfig::load(&path) {
733                Ok(mc) => mc,
734                Err(e) => {
735                    tracing::warn!(
736                        path = %path.display(),
737                        error = %e,
738                        "skipping mirror with parse error"
739                    );
740                    continue;
741                }
742            };
743            out.push(LoadedMirror {
744                service: service.name.clone(),
745                env: path
746                    .file_stem()
747                    .and_then(|s| s.to_str())
748                    .unwrap_or_default()
749                    .to_string(),
750                path,
751                mirror,
752            });
753        }
754    }
755    Ok(out)
756}
757
758/// An ingress declaration that cannot become a front door.
759#[derive(Debug, Clone, PartialEq, Eq)]
760pub enum IngressProblem {
761    /// One mirror's own edges do not plan — a hole in its partition, a slot
762    /// claimed twice, a selector matching nothing.
763    Declaration {
764        service: String,
765        env: String,
766        mirror_toml: PathBuf,
767        detail: String,
768    },
769    /// Two services' edges cannot share the node they both front through.
770    /// Nothing but a cross-service pass can see this.
771    Collation { detail: String },
772    /// A declared edge that collates onto no node at all. Not fatal — the
773    /// passway arm deliberately renders a `<machine>` placeholder for an
774    /// operator to fill in — but it publishes nothing until it is placed.
775    Unplaced { label: String },
776}
777
778impl IngressProblem {
779    /// `true` for the problems that make the declaration tree incoherent, as
780    /// opposed to merely incomplete.
781    pub fn is_fatal(&self) -> bool {
782        !matches!(self, Self::Unplaced { .. })
783    }
784
785    /// Human-readable finding naming the file to open.
786    pub fn message(&self) -> String {
787        match self {
788            Self::Declaration {
789                service,
790                env,
791                mirror_toml,
792                detail,
793            } => format!(
794                "{service}/{env}: ingress declaration does not plan — {detail}\n\u{2192} {}",
795                mirror_toml.display()
796            ),
797            Self::Collation { detail } => format!(
798                "ingress edges from two services collide on a shared node — {detail}\n\
799                 \u{2192} the node's front door is COLLATED from every service that fronts \
800                 through it (W305 F2), so this is invisible from either mirror alone."
801            ),
802            Self::Unplaced { label } => format!(
803                "{label}: declares a front door with no machine to run it on — neither the \
804                 edge's `machines` nor the fronted slot's placement names a node, so it \
805                 publishes nothing.\n\u{2192} add `machines = [...]` to the edge."
806            ),
807        }
808    }
809}
810
811/// What every node in the camp must front, derived from every service's edges.
812#[derive(Debug, Clone, Default)]
813pub struct IngressReport {
814    /// The per-node front doors, empty when nothing collated.
815    pub collation: crate::reconciler::Collation,
816    /// Findings, fatal ones first-class via [`IngressProblem::is_fatal`].
817    pub problems: Vec<IngressProblem>,
818}
819
820/// Collate every service's declared ingress edges into the per-node front doors
821/// they imply (W305 F2 — the node-controller half).
822///
823/// **This is the direction that makes the node's front door derived rather than
824/// declared.** A service says which edges front it; this walks every service and
825/// answers the node's question — *what am I running, and for whom* — without the
826/// node declaring anything. The old shape had the node carry its own
827/// `MachineConfig.cloudflared` cohort statement (W267 Gap 3), which nothing
828/// checked against the services that actually fronted through it.
829///
830/// Upstreams are resolved **from configuration, never from the network**
831/// (R844-F12). A rule that pins `upstream_host` keeps it; every other rule takes
832/// the declared `[registration].mesh_ipv4` of each machine in its placement set,
833/// via [`machine_mesh_addrs`](crate::reconciler::machine_mesh_addrs). That is a
834/// second lookup over the machine slice this function already loaded — no
835/// network, no credentials, so this still answers the same question in CI as on
836/// the operator's laptop.
837///
838/// It is also what lets a mirror drop `upstream_host` at all: before this, the
839/// pin was the only thing standing between the apex and a collation that
840/// rendered `<unresolved>`. What it is *not* is a source of truth — see
841/// [`IngressPlan::resolve_upstreams_from_config`](crate::reconciler::IngressPlan::resolve_upstreams_from_config).
842/// A rule placed on a machine that declares no mesh address still collates fine
843/// and simply has no address yet.
844pub fn collate_workspace_ingress(workspace_root: &Path) -> anyhow::Result<IngressReport> {
845    // Needed only to resolve a slot's `required = { … }` into a machine name
846    // (R772) — `plan_ingress` itself stays pure. Reads `.yah/infra/machines/`
847    // directly rather than going through the full `CloudConfig::load`: this
848    // walk collates every mirror in the workspace, and has no business
849    // hard-failing over an unrelated mirror's `providers.X.use = "<id>"` typo,
850    // which cross-ref validation would do. Strict mode: silently dropping a
851    // machine here could resolve a `required` constraint onto the wrong node
852    // with no error, unlike the tolerant lint sweeps below.
853    let machines: Vec<MachineConfig> =
854        load_machine_tomls(workspace_root, MachineLoadMode::Strict)?
855            .into_iter()
856            .map(|(_, m)| m)
857            .collect();
858
859    // R844-F12: name -> declared mesh address, built once for the whole walk.
860    // Same slice, second lookup — the offline stand-in for the discovery read
861    // this pass deliberately cannot make.
862    let mesh_addrs = crate::reconciler::machine_mesh_addrs(&machines);
863
864    let mut planned = Vec::new();
865    let mut problems = Vec::new();
866
867    for m in load_service_mirrors(workspace_root)? {
868        let placements = match crate::reconciler::resolve_ingress_placements(&machines, &m.mirror) {
869            Ok(p) => p,
870            Err(e) => {
871                problems.push(IngressProblem::Declaration {
872                    service: m.service.clone(),
873                    env: m.env.clone(),
874                    mirror_toml: m.path.clone(),
875                    detail: format!("{e:#}"),
876                });
877                continue;
878            }
879        };
880        match crate::reconciler::plan_ingress(&m.mirror, &placements) {
881            Ok(plans) => planned.extend(plans.into_iter().map(|mut plan| {
882                plan.resolve_upstreams_from_config(&mesh_addrs);
883                crate::reconciler::PlannedEdge {
884                    service: m.service.clone(),
885                    env: m.env.clone(),
886                    plan,
887                }
888            })),
889            Err(e) => problems.push(IngressProblem::Declaration {
890                service: m.service.clone(),
891                env: m.env.clone(),
892                mirror_toml: m.path.clone(),
893                detail: format!("{e:#}"),
894            }),
895        }
896    }
897
898    let collation = match crate::reconciler::collate_front_doors(&planned) {
899        Ok(c) => c,
900        Err(e) => {
901            problems.push(IngressProblem::Collation {
902                detail: format!("{e:#}"),
903            });
904            Default::default()
905        }
906    };
907    for label in &collation.unplaced {
908        problems.push(IngressProblem::Unplaced {
909            label: label.clone(),
910        });
911    }
912
913    Ok(IngressReport {
914        collation,
915        problems,
916    })
917}
918
919// ── Tests ──────────────────────────────────────────────────────────────────
920
921#[cfg(test)]
922mod tests {
923    use super::*;
924    use tempfile::tempdir;
925
926    fn write_service(workspace: &Path, svc_name: &str, component_path: &str) {
927        let svc_dir = workspace.join(".yah/services").join(svc_name);
928        std::fs::create_dir_all(&svc_dir).unwrap();
929        let toml = format!(
930            "schema_version = 1\nname = \"{svc_name}\"\ndomain = \"{svc_name}.example.com\"\n\
931             [[components]]\nid = \"models\"\nkind = \"static-asset\"\n\
932             path = \"{component_path}\"\nrole = \"static\"\n"
933        );
934        std::fs::write(svc_dir.join("service.toml"), toml).unwrap();
935    }
936
937    fn write_workload_with_aliases(dir: &Path, aliases: &[(&str, &str)]) {
938        std::fs::create_dir_all(dir).unwrap();
939        let alias_lines: String = aliases
940            .iter()
941            .map(|(k, v)| format!("\"{k}\" = \"{v}\"\n"))
942            .collect();
943        let content = format!(
944            "kind = \"static-asset\"\nschema_version = \"V1\"\n\
945             [aliases]\n{alias_lines}"
946        );
947        std::fs::write(dir.join("workload.toml"), content).unwrap();
948    }
949
950    #[test]
951    fn cloud_validate_clean_workspace_returns_empty() {
952        let dir = tempdir().unwrap();
953        let root = dir.path();
954
955        write_service(root, "svc-a", "svc-a/models");
956        write_workload_with_aliases(
957            &root.join("svc-a/models"),
958            &[("whisper-default-ggml", "svc-a/whisper/model.bin")],
959        );
960
961        let collisions = check_alias_collisions(root).unwrap();
962        assert!(
963            collisions.is_empty(),
964            "expected no collisions: {collisions:?}"
965        );
966    }
967
968    #[test]
969    fn cloud_validate_rejects_alias_collision() {
970        let dir = tempdir().unwrap();
971        let root = dir.path();
972
973        write_service(root, "svc-a", "svc-a/models");
974        write_workload_with_aliases(
975            &root.join("svc-a/models"),
976            &[("whisper-default-ggml", "svc-a/whisper/model.bin")],
977        );
978
979        write_service(root, "svc-b", "svc-b/models");
980        write_workload_with_aliases(
981            &root.join("svc-b/models"),
982            &[("whisper-default-ggml", "svc-b/whisper/model.bin")],
983        );
984
985        let collisions = check_alias_collisions(root).unwrap();
986        assert_eq!(
987            collisions.len(),
988            1,
989            "expected one collision: {collisions:?}"
990        );
991        let c = &collisions[0];
992        assert_eq!(c.alias, "whisper-default-ggml");
993        assert_eq!(c.first.service, "svc-a");
994        assert_eq!(c.second.service, "svc-b");
995
996        let msg = c.message();
997        assert!(msg.contains("whisper-default-ggml"), "message: {msg}");
998        assert!(msg.contains("svc-a"), "message: {msg}");
999        assert!(msg.contains("svc-b"), "message: {msg}");
1000    }
1001
1002    #[test]
1003    fn cloud_validate_distinct_aliases_no_collision() {
1004        let dir = tempdir().unwrap();
1005        let root = dir.path();
1006
1007        write_service(root, "svc-a", "svc-a/models");
1008        write_workload_with_aliases(
1009            &root.join("svc-a/models"),
1010            &[
1011                ("whisper-default-ggml", "svc-a/model.bin"),
1012                ("whisper-default", "svc-a/model.bin"),
1013            ],
1014        );
1015
1016        write_service(root, "svc-b", "svc-b/models");
1017        write_workload_with_aliases(
1018            &root.join("svc-b/models"),
1019            &[("whisper-default-coreml", "svc-b/model.tar.gz")],
1020        );
1021
1022        let collisions = check_alias_collisions(root).unwrap();
1023        assert!(collisions.is_empty());
1024    }
1025
1026    #[test]
1027    fn cloud_validate_multiple_collisions_all_reported() {
1028        let dir = tempdir().unwrap();
1029        let root = dir.path();
1030
1031        write_service(root, "svc-a", "svc-a/models");
1032        write_workload_with_aliases(
1033            &root.join("svc-a/models"),
1034            &[
1035                ("alias-one", "svc-a/one.bin"),
1036                ("alias-two", "svc-a/two.bin"),
1037            ],
1038        );
1039
1040        write_service(root, "svc-b", "svc-b/models");
1041        write_workload_with_aliases(
1042            &root.join("svc-b/models"),
1043            &[
1044                ("alias-one", "svc-b/one.bin"),
1045                ("alias-two", "svc-b/two.bin"),
1046            ],
1047        );
1048
1049        let collisions = check_alias_collisions(root).unwrap();
1050        assert_eq!(collisions.len(), 2);
1051        let names: Vec<_> = collisions.iter().map(|c| c.alias.as_str()).collect();
1052        assert!(names.contains(&"alias-one"));
1053        assert!(names.contains(&"alias-two"));
1054    }
1055
1056    #[test]
1057    fn cloud_validate_missing_services_dir_is_not_error() {
1058        let dir = tempdir().unwrap();
1059        let collisions = check_alias_collisions(dir.path()).unwrap();
1060        assert!(collisions.is_empty());
1061    }
1062
1063    #[test]
1064    fn cloud_validate_non_static_asset_workloads_ignored() {
1065        let dir = tempdir().unwrap();
1066        let root = dir.path();
1067
1068        write_service(root, "svc-a", "svc-a/api");
1069        // Write a container workload — no [aliases] block, should be silently skipped.
1070        let workload_dir = root.join("svc-a/api");
1071        std::fs::create_dir_all(&workload_dir).unwrap();
1072        // Just make the kind non-static-asset to ensure we skip it.
1073        // (Writes a valid mesofact-static workload which has no aliases)
1074        std::fs::write(
1075            workload_dir.join("workload.toml"),
1076            "schema_version = \"V1\"\nname = \"api\"\nkind = \"mesofact-static\"\n\
1077             bundle_dir = \"dist\"\n",
1078        )
1079        .unwrap();
1080
1081        let collisions = check_alias_collisions(root).unwrap();
1082        assert!(collisions.is_empty());
1083    }
1084
1085    // ── Port collisions (R602-B4) ────────────────────────────────────────────
1086
1087    fn write_mirror(workspace: &Path, svc: &str, env: &str, body: &str) {
1088        let dir = workspace.join(".yah/services").join(svc).join("mirrors");
1089        std::fs::create_dir_all(&dir).unwrap();
1090        std::fs::write(dir.join(format!("{env}.toml")), body).unwrap();
1091    }
1092
1093    fn local_static_mirror(port: u16) -> String {
1094        format!(
1095            "schema_version = 1\nshape = \"local\"\n\
1096             [providers.static]\nkind = \"local-static\"\nport = {port}\n"
1097        )
1098    }
1099
1100    #[test]
1101    fn port_collision_across_services_and_envs_is_flagged() {
1102        let dir = tempdir().unwrap();
1103        let root = dir.path();
1104        write_service(root, "scrabcake", "scrabcake/site");
1105        write_mirror(root, "scrabcake", "dev", &local_static_mirror(4322));
1106        write_service(root, "yah-marketing", "yah-marketing/site");
1107        write_mirror(
1108            root,
1109            "yah-marketing",
1110            "pond",
1111            "schema_version = 1\nshape = \"local\"\n\
1112             [providers.static]\nkind = \"miniflare-container\"\nport = 4322\n",
1113        );
1114
1115        let cols = check_port_collisions(root).unwrap();
1116        assert_eq!(cols.len(), 1, "{cols:?}");
1117        assert_eq!(cols[0].port, 4322);
1118        // Deterministic: "scrabcake" sorts before "yah-marketing".
1119        assert_eq!(cols[0].first.service, "scrabcake");
1120        assert_eq!(cols[0].second.service, "yah-marketing");
1121        assert!(cols[0].is_cross_service(), "different services collide");
1122        let msg = cols[0].message();
1123        assert!(msg.contains("4322"), "{msg}");
1124        assert!(msg.contains("scrabcake"), "{msg}");
1125        assert!(msg.contains("yah-marketing"), "{msg}");
1126    }
1127
1128    #[test]
1129    fn distinct_ports_no_collision() {
1130        let dir = tempdir().unwrap();
1131        let root = dir.path();
1132        write_service(root, "a", "a/site");
1133        write_mirror(root, "a", "dev", &local_static_mirror(4322));
1134        write_service(root, "b", "b/site");
1135        write_mirror(root, "b", "dev", &local_static_mirror(4323));
1136        assert!(check_port_collisions(root).unwrap().is_empty());
1137    }
1138
1139    #[test]
1140    fn same_service_two_envs_reusing_a_port_is_flagged() {
1141        // The ticket's "scrabcake cloud+dev reuse <port> twice more" shape.
1142        let dir = tempdir().unwrap();
1143        let root = dir.path();
1144        write_service(root, "scrabcake", "scrabcake/site");
1145        write_mirror(root, "scrabcake", "dev", &local_static_mirror(4352));
1146        write_mirror(root, "scrabcake", "cloud", &local_static_mirror(4352));
1147        let cols = check_port_collisions(root).unwrap();
1148        assert_eq!(cols.len(), 1, "{cols:?}");
1149        assert_eq!(cols[0].port, 4352);
1150        // "cloud" sorts before "dev".
1151        assert_eq!(cols[0].first.env, "cloud");
1152        assert_eq!(cols[0].second.env, "dev");
1153        assert!(
1154            !cols[0].is_cross_service(),
1155            "same service across envs is NOT cross-service"
1156        );
1157    }
1158
1159    #[test]
1160    fn reference_slots_do_not_bind_localhost_and_are_ignored() {
1161        // A `use = "..."` reference slot points at a cloud provider — reusing a
1162        // `port` field there is not a localhost collision.
1163        let dir = tempdir().unwrap();
1164        let root = dir.path();
1165        let ref_slot = "schema_version = 1\nshape = \"local\"\n\
1166             [providers.static]\nuse = \"cloudflare\"\nport = 8080\n";
1167        write_service(root, "a", "a/site");
1168        write_mirror(root, "a", "cloud", ref_slot);
1169        write_service(root, "b", "b/site");
1170        write_mirror(root, "b", "cloud", ref_slot);
1171        assert!(check_port_collisions(root).unwrap().is_empty());
1172    }
1173
1174    #[test]
1175    fn minio_api_and_console_ports_collide_across_ponds() {
1176        // Two pond MinIO slots on the same api_port bind the same host port.
1177        let dir = tempdir().unwrap();
1178        let root = dir.path();
1179        let minio = "schema_version = 1\nshape = \"local\"\n\
1180             [providers.object_store]\nkind = \"minio-container\"\napi_port = 9000\nconsole_port = 9001\n";
1181        write_service(root, "a", "a/site");
1182        write_mirror(root, "a", "pond", minio);
1183        write_service(root, "b", "b/site");
1184        write_mirror(root, "b", "pond", minio);
1185        let cols = check_port_collisions(root).unwrap();
1186        // Both api_port (9000) and console_port (9001) collide.
1187        assert_eq!(cols.len(), 2, "{cols:?}");
1188        let ports: Vec<u16> = cols.iter().map(|c| c.port).collect();
1189        assert!(ports.contains(&9000));
1190        assert!(ports.contains(&9001));
1191    }
1192
1193    #[test]
1194    fn missing_services_dir_is_not_error_for_ports() {
1195        let dir = tempdir().unwrap();
1196        assert!(check_port_collisions(dir.path()).unwrap().is_empty());
1197    }
1198
1199    // ── R763: the retired `tier:` arch mesh tag ────────────────────────────
1200
1201    /// Writes and re-parses the TOML immediately: [`load_machine_tomls`]'s
1202    /// tolerant mode *skips* a file that fails to deserialize, so a fixture
1203    /// missing a required `MachineConfig` field would otherwise make every
1204    /// assert-empty test using it pass vacuously instead of failing loud.
1205    fn assert_machine_toml_parses(path: &Path) {
1206        let src = std::fs::read_to_string(path).unwrap();
1207        toml::from_str::<MachineConfig>(&src)
1208            .unwrap_or_else(|e| panic!("fixture {} does not parse as MachineConfig: {e}\n{src}", path.display()));
1209    }
1210
1211    fn write_machine_tags(workspace: &Path, name: &str, mesh_tags: &[&str]) {
1212        let dir = workspace.join(".yah/infra/machines");
1213        std::fs::create_dir_all(&dir).unwrap();
1214        let list: Vec<String> = mesh_tags.iter().map(|t| format!("\"{t}\"")).collect();
1215        let path = dir.join(format!("{name}.toml"));
1216        std::fs::write(
1217            &path,
1218            format!(
1219                "name = \"{name}\"\nprovider = \"static\"\nmesh_tags = [{}]\n",
1220                list.join(", ")
1221            ),
1222        )
1223        .unwrap();
1224        assert_machine_toml_parses(&path);
1225    }
1226
1227    #[test]
1228    fn the_current_arch_tag_is_clean() {
1229        let dir = tempdir().unwrap();
1230        write_machine_tags(dir.path(), "n1", &["tag:build-worker", "arch:x86", "os:linux"]);
1231        assert!(check_retired_arch_tags(dir.path()).unwrap().is_empty());
1232    }
1233
1234    #[test]
1235    fn a_stale_tier_arch_tag_is_flagged_with_its_replacement() {
1236        let dir = tempdir().unwrap();
1237        write_machine_tags(dir.path(), "n1", &["tag:build-worker", "tier:arm", "os:linux"]);
1238        let found = check_retired_arch_tags(dir.path()).unwrap();
1239        assert_eq!(found.len(), 1, "{found:?}");
1240        assert_eq!(found[0].tag, "tier:arm");
1241        assert_eq!(found[0].replacement(), "arch:arm");
1242        let msg = found[0].message();
1243        // The message has to carry the fix, not just the complaint — this lint
1244        // exists precisely because the symptom ("no node") names nothing.
1245        assert!(msg.contains("arch:arm"), "{msg}");
1246        assert!(msg.contains("n1"), "{msg}");
1247    }
1248
1249    /// The whole point: a node with the old tag is not *rejected* by placement,
1250    /// it silently stops being a candidate. Superset matching has no way to say
1251    /// "you asked for arch:x86 and I have tier:x86".
1252    #[test]
1253    fn a_stale_tag_is_not_caught_by_the_inert_taint_lint() {
1254        let dir = tempdir().unwrap();
1255        write_machine_tags(dir.path(), "n1", &["tier:x86"]);
1256        assert!(
1257            check_inert_taints(dir.path()).unwrap().is_empty(),
1258            "mesh tags are not taints — this needs its own check"
1259        );
1260        assert_eq!(check_retired_arch_tags(dir.path()).unwrap().len(), 1);
1261    }
1262
1263    #[test]
1264    fn missing_machines_dir_is_not_error_for_arch_tags() {
1265        let dir = tempdir().unwrap();
1266        assert!(check_retired_arch_tags(dir.path()).unwrap().is_empty());
1267    }
1268
1269    // ── R605-F12: sovereign members with no stated role ────────────────────
1270
1271    /// `stamp` is the sovereign declaration under test; everything else is the
1272    /// minimum a `MachineConfig` deserializes from. `assert_machine_toml_parses`
1273    /// catches a future edit here that drops a required field before it can
1274    /// produce a vacuously-passing assert-empty test (see that fn's doc).
1275    fn write_sovereign_machine(workspace: &Path, name: &str, stamp: &str) {
1276        let dir = workspace.join(".yah/infra/machines");
1277        std::fs::create_dir_all(&dir).unwrap();
1278        let path = dir.join(format!("{name}.toml"));
1279        std::fs::write(
1280            &path,
1281            format!("name = \"{name}\"\nprovider = \"static\"\nmesh_tags = []\n{stamp}\n"),
1282        )
1283        .unwrap();
1284        assert_machine_toml_parses(&path);
1285    }
1286
1287    #[test]
1288    fn a_group_with_no_role_is_reported_with_the_declaring_file() {
1289        let dir = tempdir().unwrap();
1290        let root = dir.path();
1291        write_sovereign_machine(root, "us-west-001", "sovereign_group = \"prod\"");
1292        let found = check_unroled_sovereign_members(root).unwrap();
1293        assert_eq!(found.len(), 1, "{found:?}");
1294        assert_eq!(found[0].machine, "us-west-001");
1295        assert_eq!(found[0].group, "prod");
1296        assert!(found[0].machine_toml.ends_with("us-west-001.toml"));
1297        let msg = found[0].message();
1298        // The message has to say what the silence currently means, or the
1299        // operator reads it as pedantry and adds the line without deciding.
1300        assert!(msg.contains("voter") && msg.contains("non-voter"), "{msg}");
1301        assert!(msg.contains("TOP-LEVEL"), "{msg}");
1302    }
1303
1304    #[test]
1305    fn either_stated_role_is_clean() {
1306        let dir = tempdir().unwrap();
1307        let root = dir.path();
1308        write_sovereign_machine(
1309            root,
1310            "us-west-001",
1311            "sovereign_group = \"prod\"\nsovereign_role = \"voter\"",
1312        );
1313        write_sovereign_machine(
1314            root,
1315            "us-west-003",
1316            "sovereign_group = \"prod\"\nsovereign_role = \"non-voter\"",
1317        );
1318        assert!(check_unroled_sovereign_members(root).unwrap().is_empty());
1319    }
1320
1321    /// A standalone box has no quorum to be eligible for, so demanding a role
1322    /// of it would be noise — and noise is what trains an operator to stop
1323    /// reading the check that matters.
1324    #[test]
1325    fn a_machine_in_no_group_is_not_asked_for_a_role() {
1326        let dir = tempdir().unwrap();
1327        let root = dir.path();
1328        write_sovereign_machine(root, "us-west-002", "taints = [\"no-appliance\"]");
1329        assert!(check_unroled_sovereign_members(root).unwrap().is_empty());
1330    }
1331
1332    #[test]
1333    fn unroled_findings_are_ordered_by_file_so_output_is_stable() {
1334        let dir = tempdir().unwrap();
1335        let root = dir.path();
1336        write_sovereign_machine(root, "b-node", "sovereign_group = \"dev\"");
1337        write_sovereign_machine(root, "a-node", "sovereign_group = \"prod\"");
1338        let found = check_unroled_sovereign_members(root).unwrap();
1339        let names: Vec<&str> = found.iter().map(|f| f.machine.as_str()).collect();
1340        assert_eq!(names, vec!["a-node", "b-node"]);
1341    }
1342
1343    #[test]
1344    fn missing_machines_dir_is_not_error_for_unroled_members() {
1345        let dir = tempdir().unwrap();
1346        assert!(check_unroled_sovereign_members(dir.path())
1347            .unwrap()
1348            .is_empty());
1349    }
1350
1351    // ── R605-T10: LAN dial targets ─────────────────────────────────────────
1352
1353    /// `connect` is the raw body of the `[connect]` table; `registration` the
1354    /// raw body of `[registration]` (empty string omits the table).
1355    fn write_reach_machine(workspace: &Path, name: &str, connect: &str, registration: &str) {
1356        let dir = workspace.join(".yah/infra/machines");
1357        std::fs::create_dir_all(&dir).unwrap();
1358        let path = dir.join(format!("{name}.toml"));
1359        let reg = if registration.is_empty() {
1360            String::new()
1361        } else {
1362            format!("\n[registration]\n{registration}\n")
1363        };
1364        std::fs::write(
1365            &path,
1366            format!(
1367                "name = \"{name}\"\nprovider = \"static\"\nmesh_tags = []\n\n\
1368                 [connect]\n{connect}\n{reg}"
1369            ),
1370        )
1371        .unwrap();
1372        assert_machine_toml_parses(&path);
1373    }
1374
1375    #[test]
1376    fn a_lan_literal_in_the_dialed_field_is_reported_with_its_file() {
1377        let dir = tempdir().unwrap();
1378        let root = dir.path();
1379        // us-west-011's shape at filing time: LAN literal, no mesh address.
1380        write_reach_machine(
1381            root,
1382            "us-west-011",
1383            "address = \"192.168.10.11\"\nssh = \"yah@192.168.10.11\"\n\
1384             yubaba = \"http://192.168.10.11:7443\"",
1385            "",
1386        );
1387        let found = check_lan_dial_targets(root).unwrap();
1388        assert_eq!(found.len(), 1);
1389        assert_eq!(found[0].machine, "us-west-011");
1390        assert_eq!(found[0].url, "http://192.168.10.11:7443");
1391        assert_eq!(found[0].mesh_ipv4, None);
1392        let msg = found[0].message();
1393        assert!(msg.contains("mesh-join it"), "{msg}");
1394        assert!(msg.contains("us-west-011.toml"), "{msg}");
1395    }
1396
1397    /// A mesh-joined box gets the cheaper instruction, because the fix really
1398    /// is one deleted line — us-west-013/014's shape.
1399    #[test]
1400    fn a_mesh_joined_lan_declarer_is_told_to_delete_the_line() {
1401        let dir = tempdir().unwrap();
1402        let root = dir.path();
1403        write_reach_machine(
1404            root,
1405            "us-west-014",
1406            "address = \"192.168.10.14\"\nssh = \"yah@192.168.10.14\"\n\
1407             yubaba = \"http://192.168.10.14:7443\"",
1408            "mesh_ipv4 = \"100.64.0.6\"",
1409        );
1410        let found = check_lan_dial_targets(root).unwrap();
1411        assert_eq!(found.len(), 1);
1412        assert_eq!(found[0].mesh_ipv4.as_deref(), Some("100.64.0.6"));
1413        let msg = found[0].message();
1414        assert!(msg.contains("DELETE the `yubaba` line"), "{msg}");
1415        assert!(msg.contains("100.64.0.6"), "{msg}");
1416    }
1417
1418    /// The three shapes that must NOT be flagged: a mesh address, the pre-mesh
1419    /// loopback placeholder, and a LAN address confined to the metadata fields
1420    /// (which is the whole point — the operator keeps it, automation ignores it).
1421    #[test]
1422    fn mesh_loopback_and_metadata_only_lan_addresses_are_clean() {
1423        let dir = tempdir().unwrap();
1424        let root = dir.path();
1425        write_reach_machine(
1426            root,
1427            "meshed",
1428            "address = \"192.168.10.15\"\nssh = \"yah@192.168.10.15\"",
1429            "mesh_ipv4 = \"100.64.0.7\"",
1430        );
1431        write_reach_machine(
1432            root,
1433            "tunnelled",
1434            "address = \"192.168.10.16\"\nssh = \"yah@192.168.10.16\"\n\
1435             yubaba = \"http://127.0.0.1:7443\"",
1436            "",
1437        );
1438        write_reach_machine(
1439            root,
1440            "public",
1441            "address = \"45.32.194.254\"\nssh = \"debian@45.32.194.254\"\n\
1442             yubaba = \"http://45.32.194.254:7443\"",
1443            "",
1444        );
1445        assert!(check_lan_dial_targets(root).unwrap().is_empty());
1446    }
1447
1448    #[test]
1449    fn missing_machines_dir_is_not_error_for_lan_dial_targets() {
1450        let dir = tempdir().unwrap();
1451        assert!(check_lan_dial_targets(dir.path()).unwrap().is_empty());
1452    }
1453
1454    // ── R742-T4: inert taints ──────────────────────────────────────────────
1455
1456    fn write_machine(workspace: &Path, name: &str, taints: &[&str]) {
1457        let dir = workspace.join(".yah/infra/machines");
1458        std::fs::create_dir_all(&dir).unwrap();
1459        let list: Vec<String> = taints.iter().map(|t| format!("\"{t}\"")).collect();
1460        let path = dir.join(format!("{name}.toml"));
1461        std::fs::write(
1462            &path,
1463            format!(
1464                "name = \"{name}\"\nprovider = \"static\"\nmesh_tags = []\ntaints = [{}]\n",
1465                list.join(", ")
1466            ),
1467        )
1468        .unwrap();
1469        assert_machine_toml_parses(&path);
1470    }
1471
1472    #[test]
1473    fn archetype_repel_keys_and_affinity_keys_are_clean() {
1474        let dir = tempdir().unwrap();
1475        let root = dir.path();
1476        write_machine(root, "worker", &["no-server", "no-appliance", "no-job"]);
1477        write_machine(root, "edge", &["public-ip"]);
1478        write_machine(root, "plain", &[]);
1479        assert!(check_inert_taints(root).unwrap().is_empty());
1480    }
1481
1482    #[test]
1483    fn a_free_form_taint_is_reported_with_the_declaring_file() {
1484        let dir = tempdir().unwrap();
1485        let root = dir.path();
1486        // W305's headline example. Environment is not a taint.
1487        write_machine(root, "us-west-011", &["qa"]);
1488        let found = check_inert_taints(root).unwrap();
1489        assert_eq!(found.len(), 1, "{found:?}");
1490        assert_eq!(found[0].machine, "us-west-011");
1491        assert_eq!(found[0].key, "qa");
1492        assert!(found[0].machine_toml.ends_with("us-west-011.toml"));
1493        let msg = found[0].message();
1494        // The message has to carry the legal vocabulary, otherwise the
1495        // operator's only recourse is reading the scheduler.
1496        assert!(msg.contains("no-appliance"), "{msg}");
1497        assert!(msg.contains("public-ip"), "{msg}");
1498        assert!(msg.contains("mesh_tags"), "{msg}");
1499    }
1500
1501    #[test]
1502    fn no_voter_is_inert_because_voter_is_not_an_archetype() {
1503        // The one that cost real fleet state: it reads as an exclusion and
1504        // excludes nothing, so it sat on three nodes asserting a falsehood.
1505        let dir = tempdir().unwrap();
1506        let root = dir.path();
1507        write_machine(root, "us-west-015", &["no-server", "no-appliance", "no-voter"]);
1508        let found = check_inert_taints(root).unwrap();
1509        assert_eq!(found.len(), 1, "{found:?}");
1510        assert_eq!(found[0].key, "no-voter");
1511    }
1512
1513    #[test]
1514    fn findings_are_ordered_by_file_so_output_is_stable() {
1515        let dir = tempdir().unwrap();
1516        let root = dir.path();
1517        write_machine(root, "b-node", &["qa"]);
1518        write_machine(root, "a-node", &["staging"]);
1519        let found = check_inert_taints(root).unwrap();
1520        let names: Vec<&str> = found.iter().map(|f| f.machine.as_str()).collect();
1521        assert_eq!(names, vec!["a-node", "b-node"]);
1522    }
1523
1524    #[test]
1525    fn missing_machines_dir_is_not_error() {
1526        let dir = tempdir().unwrap();
1527        assert!(check_inert_taints(dir.path()).unwrap().is_empty());
1528    }
1529
1530    #[test]
1531    fn an_unparseable_machine_toml_is_skipped_not_fatal() {
1532        let dir = tempdir().unwrap();
1533        let root = dir.path();
1534        let mdir = root.join(".yah/infra/machines");
1535        std::fs::create_dir_all(&mdir).unwrap();
1536        std::fs::write(mdir.join("broken.toml"), "name = \n").unwrap();
1537        write_machine(root, "good", &["qa"]);
1538        // The broken file must not sink the sweep — a peer's half-written
1539        // scaffold is a normal state on a shared tree.
1540        let found = check_inert_taints(root).unwrap();
1541        assert_eq!(found.len(), 1);
1542        assert_eq!(found[0].machine, "good");
1543    }
1544
1545    // ── R787: the shared loader's Strict/Tolerant contract ──────────────────
1546
1547    #[test]
1548    fn tolerant_mode_skips_an_unparseable_toml_and_still_pairs_the_path() {
1549        let dir = tempdir().unwrap();
1550        let root = dir.path();
1551        let mdir = root.join(".yah/infra/machines");
1552        std::fs::create_dir_all(&mdir).unwrap();
1553        std::fs::write(mdir.join("broken.toml"), "name = \n").unwrap();
1554        write_machine(root, "good", &["qa"]);
1555
1556        let loaded = load_machine_tomls(root, MachineLoadMode::Tolerant).unwrap();
1557        assert_eq!(loaded.len(), 1, "{loaded:?}");
1558        assert_eq!(loaded[0].1.name, "good");
1559        assert!(loaded[0].0.ends_with("good.toml"));
1560    }
1561
1562    #[test]
1563    fn strict_mode_fails_the_whole_load_on_one_unparseable_toml() {
1564        // The behavior collate_workspace_ingress relies on: a bad machine toml
1565        // must not silently resolve a `required` placement onto the wrong node.
1566        let dir = tempdir().unwrap();
1567        let root = dir.path();
1568        let mdir = root.join(".yah/infra/machines");
1569        std::fs::create_dir_all(&mdir).unwrap();
1570        std::fs::write(mdir.join("broken.toml"), "name = \n").unwrap();
1571        write_machine(root, "good", &["qa"]);
1572
1573        let err = load_machine_tomls(root, MachineLoadMode::Strict).unwrap_err();
1574        assert!(format!("{err:#}").contains("broken.toml"), "{err:#}");
1575    }
1576
1577    // ── R742-F2 (W305): workspace ingress collation ──
1578
1579    /// A mirror fronting one hostname through one edge on `machines`.
1580    fn fronted_mirror(provider: &str, machines: &[&str], hostname: &str, port: u16) -> String {
1581        let list = machines
1582            .iter()
1583            .map(|m| format!("\"{m}\""))
1584            .collect::<Vec<_>>()
1585            .join(", ");
1586        format!(
1587            "schema_version = 1\nshape = \"single-machine\"\n\
1588             ingress = \"{provider}\"\ningress_machines = [{list}]\n\
1589             [providers.compute]\nuse = \"hetzner\"\nzone = \"{hostname}\"\n\
1590             port = {port}\nupstream_host = \"100.64.0.5\"\n"
1591        )
1592    }
1593
1594    #[test]
1595    fn two_services_fronting_one_node_collate_into_one_front_door() {
1596        let dir = tempdir().unwrap();
1597        let root = dir.path();
1598        write_service(root, "yah-marketing", "yah-marketing/site");
1599        write_mirror(
1600            root,
1601            "yah-marketing",
1602            "cloud",
1603            &fronted_mirror("passway", &["us-east-001"], "yah.dev", 8080),
1604        );
1605        write_service(root, "yah-issues", "yah-issues/site");
1606        write_mirror(
1607            root,
1608            "yah-issues",
1609            "cloud",
1610            &fronted_mirror("passway", &["us-east-001"], "issues.yah.dev", 8731),
1611        );
1612
1613        let report = collate_workspace_ingress(root).unwrap();
1614        assert!(report.problems.is_empty(), "{:?}", report.problems);
1615        assert_eq!(report.collation.front_doors.len(), 1);
1616        let door = &report.collation.front_doors[0];
1617        assert_eq!(door.machine, "us-east-001");
1618        assert_eq!(
1619            door.passway_upstreams().unwrap(),
1620            vec!["issues.yah.dev=100.64.0.5:8731", "yah.dev=100.64.0.5:8080"]
1621        );
1622    }
1623
1624    #[test]
1625    fn a_cross_service_hostname_clash_is_reported_with_both_declarations() {
1626        // Neither service's own `yah cloud apply` can see this: each plans its
1627        // own mirror, both look fine, and the box ends up with whichever
1628        // applied last.
1629        let dir = tempdir().unwrap();
1630        let root = dir.path();
1631        write_service(root, "svc-a", "svc-a/site");
1632        write_mirror(
1633            root,
1634            "svc-a",
1635            "cloud",
1636            &fronted_mirror("passway", &["us-east-001"], "yah.dev", 8080),
1637        );
1638        write_service(root, "svc-b", "svc-b/site");
1639        write_mirror(
1640            root,
1641            "svc-b",
1642            "cloud",
1643            &fronted_mirror("cloudflare-tunnel", &["us-west-001"], "yah.dev", 8080),
1644        );
1645
1646        let report = collate_workspace_ingress(root).unwrap();
1647        let fatal: Vec<String> = report
1648            .problems
1649            .iter()
1650            .filter(|p| p.is_fatal())
1651            .map(|p| p.message())
1652            .collect();
1653        assert_eq!(fatal.len(), 1, "{fatal:?}");
1654        assert!(fatal[0].contains("svc-a/cloud"), "{}", fatal[0]);
1655        assert!(fatal[0].contains("svc-b/cloud"), "{}", fatal[0]);
1656    }
1657
1658    #[test]
1659    fn one_mirrors_broken_declaration_does_not_hide_the_rest() {
1660        // A malformed edge is reported against the file that declared it, and
1661        // the sweep continues — one service's typo must not blind the operator
1662        // to every other service's front doors.
1663        let dir = tempdir().unwrap();
1664        let root = dir.path();
1665        write_service(root, "svc-broken", "svc-broken/site");
1666        write_mirror(
1667            root,
1668            "svc-broken",
1669            "cloud",
1670            "schema_version = 1\nshape = \"single-machine\"\n\
1671             ingress_machines = [\"us-east-001\"]\n\
1672             [providers.compute]\nuse = \"hetzner\"\nzone = \"a.yah.dev\"\nport = 8080\n",
1673        );
1674        write_service(root, "svc-ok", "svc-ok/site");
1675        write_mirror(
1676            root,
1677            "svc-ok",
1678            "cloud",
1679            &fronted_mirror("passway", &["us-east-001"], "b.yah.dev", 8080),
1680        );
1681
1682        let report = collate_workspace_ingress(root).unwrap();
1683        assert_eq!(report.problems.len(), 1);
1684        assert!(
1685            report.problems[0].message().contains("svc-broken/cloud"),
1686            "{}",
1687            report.problems[0].message()
1688        );
1689        assert_eq!(report.collation.front_doors.len(), 1, "svc-ok still collates");
1690    }
1691
1692    #[test]
1693    fn a_mirror_with_no_ingress_contributes_nothing_and_is_not_a_finding() {
1694        let dir = tempdir().unwrap();
1695        let root = dir.path();
1696        write_service(root, "svc", "svc/site");
1697        write_mirror(root, "svc", "dev", &local_static_mirror(4322));
1698        let report = collate_workspace_ingress(root).unwrap();
1699        assert!(report.problems.is_empty());
1700        assert!(report.collation.front_doors.is_empty());
1701    }
1702}