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 **not** resolved: discovery needs a live node, and this runs at
831/// validate time. A rule with no pinned `upstream_host` collates fine and simply
832/// has no address yet.
833pub fn collate_workspace_ingress(workspace_root: &Path) -> anyhow::Result<IngressReport> {
834    // Needed only to resolve a slot's `required = { … }` into a machine name
835    // (R772) — `plan_ingress` itself stays pure. Reads `.yah/infra/machines/`
836    // directly rather than going through the full `CloudConfig::load`: this
837    // walk collates every mirror in the workspace, and has no business
838    // hard-failing over an unrelated mirror's `providers.X.use = "<id>"` typo,
839    // which cross-ref validation would do. Strict mode: silently dropping a
840    // machine here could resolve a `required` constraint onto the wrong node
841    // with no error, unlike the tolerant lint sweeps below.
842    let machines: Vec<MachineConfig> =
843        load_machine_tomls(workspace_root, MachineLoadMode::Strict)?
844            .into_iter()
845            .map(|(_, m)| m)
846            .collect();
847
848    let mut planned = Vec::new();
849    let mut problems = Vec::new();
850
851    for m in load_service_mirrors(workspace_root)? {
852        let placements = match crate::reconciler::resolve_ingress_placements(&machines, &m.mirror) {
853            Ok(p) => p,
854            Err(e) => {
855                problems.push(IngressProblem::Declaration {
856                    service: m.service.clone(),
857                    env: m.env.clone(),
858                    mirror_toml: m.path.clone(),
859                    detail: format!("{e:#}"),
860                });
861                continue;
862            }
863        };
864        match crate::reconciler::plan_ingress(&m.mirror, &placements) {
865            Ok(plans) => planned.extend(plans.into_iter().map(|plan| {
866                crate::reconciler::PlannedEdge {
867                    service: m.service.clone(),
868                    env: m.env.clone(),
869                    plan,
870                }
871            })),
872            Err(e) => problems.push(IngressProblem::Declaration {
873                service: m.service.clone(),
874                env: m.env.clone(),
875                mirror_toml: m.path.clone(),
876                detail: format!("{e:#}"),
877            }),
878        }
879    }
880
881    let collation = match crate::reconciler::collate_front_doors(&planned) {
882        Ok(c) => c,
883        Err(e) => {
884            problems.push(IngressProblem::Collation {
885                detail: format!("{e:#}"),
886            });
887            Default::default()
888        }
889    };
890    for label in &collation.unplaced {
891        problems.push(IngressProblem::Unplaced {
892            label: label.clone(),
893        });
894    }
895
896    Ok(IngressReport {
897        collation,
898        problems,
899    })
900}
901
902// ── Tests ──────────────────────────────────────────────────────────────────
903
904#[cfg(test)]
905mod tests {
906    use super::*;
907    use tempfile::tempdir;
908
909    fn write_service(workspace: &Path, svc_name: &str, component_path: &str) {
910        let svc_dir = workspace.join(".yah/services").join(svc_name);
911        std::fs::create_dir_all(&svc_dir).unwrap();
912        let toml = format!(
913            "schema_version = 1\nname = \"{svc_name}\"\ndomain = \"{svc_name}.example.com\"\n\
914             [[components]]\nid = \"models\"\nkind = \"static-asset\"\n\
915             path = \"{component_path}\"\nrole = \"static\"\n"
916        );
917        std::fs::write(svc_dir.join("service.toml"), toml).unwrap();
918    }
919
920    fn write_workload_with_aliases(dir: &Path, aliases: &[(&str, &str)]) {
921        std::fs::create_dir_all(dir).unwrap();
922        let alias_lines: String = aliases
923            .iter()
924            .map(|(k, v)| format!("\"{k}\" = \"{v}\"\n"))
925            .collect();
926        let content = format!(
927            "kind = \"static-asset\"\nschema_version = \"V1\"\n\
928             [aliases]\n{alias_lines}"
929        );
930        std::fs::write(dir.join("workload.toml"), content).unwrap();
931    }
932
933    #[test]
934    fn cloud_validate_clean_workspace_returns_empty() {
935        let dir = tempdir().unwrap();
936        let root = dir.path();
937
938        write_service(root, "svc-a", "svc-a/models");
939        write_workload_with_aliases(
940            &root.join("svc-a/models"),
941            &[("whisper-default-ggml", "svc-a/whisper/model.bin")],
942        );
943
944        let collisions = check_alias_collisions(root).unwrap();
945        assert!(
946            collisions.is_empty(),
947            "expected no collisions: {collisions:?}"
948        );
949    }
950
951    #[test]
952    fn cloud_validate_rejects_alias_collision() {
953        let dir = tempdir().unwrap();
954        let root = dir.path();
955
956        write_service(root, "svc-a", "svc-a/models");
957        write_workload_with_aliases(
958            &root.join("svc-a/models"),
959            &[("whisper-default-ggml", "svc-a/whisper/model.bin")],
960        );
961
962        write_service(root, "svc-b", "svc-b/models");
963        write_workload_with_aliases(
964            &root.join("svc-b/models"),
965            &[("whisper-default-ggml", "svc-b/whisper/model.bin")],
966        );
967
968        let collisions = check_alias_collisions(root).unwrap();
969        assert_eq!(
970            collisions.len(),
971            1,
972            "expected one collision: {collisions:?}"
973        );
974        let c = &collisions[0];
975        assert_eq!(c.alias, "whisper-default-ggml");
976        assert_eq!(c.first.service, "svc-a");
977        assert_eq!(c.second.service, "svc-b");
978
979        let msg = c.message();
980        assert!(msg.contains("whisper-default-ggml"), "message: {msg}");
981        assert!(msg.contains("svc-a"), "message: {msg}");
982        assert!(msg.contains("svc-b"), "message: {msg}");
983    }
984
985    #[test]
986    fn cloud_validate_distinct_aliases_no_collision() {
987        let dir = tempdir().unwrap();
988        let root = dir.path();
989
990        write_service(root, "svc-a", "svc-a/models");
991        write_workload_with_aliases(
992            &root.join("svc-a/models"),
993            &[
994                ("whisper-default-ggml", "svc-a/model.bin"),
995                ("whisper-default", "svc-a/model.bin"),
996            ],
997        );
998
999        write_service(root, "svc-b", "svc-b/models");
1000        write_workload_with_aliases(
1001            &root.join("svc-b/models"),
1002            &[("whisper-default-coreml", "svc-b/model.tar.gz")],
1003        );
1004
1005        let collisions = check_alias_collisions(root).unwrap();
1006        assert!(collisions.is_empty());
1007    }
1008
1009    #[test]
1010    fn cloud_validate_multiple_collisions_all_reported() {
1011        let dir = tempdir().unwrap();
1012        let root = dir.path();
1013
1014        write_service(root, "svc-a", "svc-a/models");
1015        write_workload_with_aliases(
1016            &root.join("svc-a/models"),
1017            &[
1018                ("alias-one", "svc-a/one.bin"),
1019                ("alias-two", "svc-a/two.bin"),
1020            ],
1021        );
1022
1023        write_service(root, "svc-b", "svc-b/models");
1024        write_workload_with_aliases(
1025            &root.join("svc-b/models"),
1026            &[
1027                ("alias-one", "svc-b/one.bin"),
1028                ("alias-two", "svc-b/two.bin"),
1029            ],
1030        );
1031
1032        let collisions = check_alias_collisions(root).unwrap();
1033        assert_eq!(collisions.len(), 2);
1034        let names: Vec<_> = collisions.iter().map(|c| c.alias.as_str()).collect();
1035        assert!(names.contains(&"alias-one"));
1036        assert!(names.contains(&"alias-two"));
1037    }
1038
1039    #[test]
1040    fn cloud_validate_missing_services_dir_is_not_error() {
1041        let dir = tempdir().unwrap();
1042        let collisions = check_alias_collisions(dir.path()).unwrap();
1043        assert!(collisions.is_empty());
1044    }
1045
1046    #[test]
1047    fn cloud_validate_non_static_asset_workloads_ignored() {
1048        let dir = tempdir().unwrap();
1049        let root = dir.path();
1050
1051        write_service(root, "svc-a", "svc-a/api");
1052        // Write a container workload — no [aliases] block, should be silently skipped.
1053        let workload_dir = root.join("svc-a/api");
1054        std::fs::create_dir_all(&workload_dir).unwrap();
1055        // Just make the kind non-static-asset to ensure we skip it.
1056        // (Writes a valid mesofact-static workload which has no aliases)
1057        std::fs::write(
1058            workload_dir.join("workload.toml"),
1059            "schema_version = \"V1\"\nname = \"api\"\nkind = \"mesofact-static\"\n\
1060             bundle_dir = \"dist\"\n",
1061        )
1062        .unwrap();
1063
1064        let collisions = check_alias_collisions(root).unwrap();
1065        assert!(collisions.is_empty());
1066    }
1067
1068    // ── Port collisions (R602-B4) ────────────────────────────────────────────
1069
1070    fn write_mirror(workspace: &Path, svc: &str, env: &str, body: &str) {
1071        let dir = workspace.join(".yah/services").join(svc).join("mirrors");
1072        std::fs::create_dir_all(&dir).unwrap();
1073        std::fs::write(dir.join(format!("{env}.toml")), body).unwrap();
1074    }
1075
1076    fn local_static_mirror(port: u16) -> String {
1077        format!(
1078            "schema_version = 1\nshape = \"local\"\n\
1079             [providers.static]\nkind = \"local-static\"\nport = {port}\n"
1080        )
1081    }
1082
1083    #[test]
1084    fn port_collision_across_services_and_envs_is_flagged() {
1085        let dir = tempdir().unwrap();
1086        let root = dir.path();
1087        write_service(root, "scrabcake", "scrabcake/site");
1088        write_mirror(root, "scrabcake", "dev", &local_static_mirror(4322));
1089        write_service(root, "yah-marketing", "yah-marketing/site");
1090        write_mirror(
1091            root,
1092            "yah-marketing",
1093            "pond",
1094            "schema_version = 1\nshape = \"local\"\n\
1095             [providers.static]\nkind = \"miniflare-container\"\nport = 4322\n",
1096        );
1097
1098        let cols = check_port_collisions(root).unwrap();
1099        assert_eq!(cols.len(), 1, "{cols:?}");
1100        assert_eq!(cols[0].port, 4322);
1101        // Deterministic: "scrabcake" sorts before "yah-marketing".
1102        assert_eq!(cols[0].first.service, "scrabcake");
1103        assert_eq!(cols[0].second.service, "yah-marketing");
1104        assert!(cols[0].is_cross_service(), "different services collide");
1105        let msg = cols[0].message();
1106        assert!(msg.contains("4322"), "{msg}");
1107        assert!(msg.contains("scrabcake"), "{msg}");
1108        assert!(msg.contains("yah-marketing"), "{msg}");
1109    }
1110
1111    #[test]
1112    fn distinct_ports_no_collision() {
1113        let dir = tempdir().unwrap();
1114        let root = dir.path();
1115        write_service(root, "a", "a/site");
1116        write_mirror(root, "a", "dev", &local_static_mirror(4322));
1117        write_service(root, "b", "b/site");
1118        write_mirror(root, "b", "dev", &local_static_mirror(4323));
1119        assert!(check_port_collisions(root).unwrap().is_empty());
1120    }
1121
1122    #[test]
1123    fn same_service_two_envs_reusing_a_port_is_flagged() {
1124        // The ticket's "scrabcake cloud+dev reuse <port> twice more" shape.
1125        let dir = tempdir().unwrap();
1126        let root = dir.path();
1127        write_service(root, "scrabcake", "scrabcake/site");
1128        write_mirror(root, "scrabcake", "dev", &local_static_mirror(4352));
1129        write_mirror(root, "scrabcake", "cloud", &local_static_mirror(4352));
1130        let cols = check_port_collisions(root).unwrap();
1131        assert_eq!(cols.len(), 1, "{cols:?}");
1132        assert_eq!(cols[0].port, 4352);
1133        // "cloud" sorts before "dev".
1134        assert_eq!(cols[0].first.env, "cloud");
1135        assert_eq!(cols[0].second.env, "dev");
1136        assert!(
1137            !cols[0].is_cross_service(),
1138            "same service across envs is NOT cross-service"
1139        );
1140    }
1141
1142    #[test]
1143    fn reference_slots_do_not_bind_localhost_and_are_ignored() {
1144        // A `use = "..."` reference slot points at a cloud provider — reusing a
1145        // `port` field there is not a localhost collision.
1146        let dir = tempdir().unwrap();
1147        let root = dir.path();
1148        let ref_slot = "schema_version = 1\nshape = \"local\"\n\
1149             [providers.static]\nuse = \"cloudflare\"\nport = 8080\n";
1150        write_service(root, "a", "a/site");
1151        write_mirror(root, "a", "cloud", ref_slot);
1152        write_service(root, "b", "b/site");
1153        write_mirror(root, "b", "cloud", ref_slot);
1154        assert!(check_port_collisions(root).unwrap().is_empty());
1155    }
1156
1157    #[test]
1158    fn minio_api_and_console_ports_collide_across_ponds() {
1159        // Two pond MinIO slots on the same api_port bind the same host port.
1160        let dir = tempdir().unwrap();
1161        let root = dir.path();
1162        let minio = "schema_version = 1\nshape = \"local\"\n\
1163             [providers.object_store]\nkind = \"minio-container\"\napi_port = 9000\nconsole_port = 9001\n";
1164        write_service(root, "a", "a/site");
1165        write_mirror(root, "a", "pond", minio);
1166        write_service(root, "b", "b/site");
1167        write_mirror(root, "b", "pond", minio);
1168        let cols = check_port_collisions(root).unwrap();
1169        // Both api_port (9000) and console_port (9001) collide.
1170        assert_eq!(cols.len(), 2, "{cols:?}");
1171        let ports: Vec<u16> = cols.iter().map(|c| c.port).collect();
1172        assert!(ports.contains(&9000));
1173        assert!(ports.contains(&9001));
1174    }
1175
1176    #[test]
1177    fn missing_services_dir_is_not_error_for_ports() {
1178        let dir = tempdir().unwrap();
1179        assert!(check_port_collisions(dir.path()).unwrap().is_empty());
1180    }
1181
1182    // ── R763: the retired `tier:` arch mesh tag ────────────────────────────
1183
1184    /// Writes and re-parses the TOML immediately: [`load_machine_tomls`]'s
1185    /// tolerant mode *skips* a file that fails to deserialize, so a fixture
1186    /// missing a required `MachineConfig` field would otherwise make every
1187    /// assert-empty test using it pass vacuously instead of failing loud.
1188    fn assert_machine_toml_parses(path: &Path) {
1189        let src = std::fs::read_to_string(path).unwrap();
1190        toml::from_str::<MachineConfig>(&src)
1191            .unwrap_or_else(|e| panic!("fixture {} does not parse as MachineConfig: {e}\n{src}", path.display()));
1192    }
1193
1194    fn write_machine_tags(workspace: &Path, name: &str, mesh_tags: &[&str]) {
1195        let dir = workspace.join(".yah/infra/machines");
1196        std::fs::create_dir_all(&dir).unwrap();
1197        let list: Vec<String> = mesh_tags.iter().map(|t| format!("\"{t}\"")).collect();
1198        let path = dir.join(format!("{name}.toml"));
1199        std::fs::write(
1200            &path,
1201            format!(
1202                "name = \"{name}\"\nprovider = \"static\"\nmesh_tags = [{}]\n",
1203                list.join(", ")
1204            ),
1205        )
1206        .unwrap();
1207        assert_machine_toml_parses(&path);
1208    }
1209
1210    #[test]
1211    fn the_current_arch_tag_is_clean() {
1212        let dir = tempdir().unwrap();
1213        write_machine_tags(dir.path(), "n1", &["tag:build-worker", "arch:x86", "os:linux"]);
1214        assert!(check_retired_arch_tags(dir.path()).unwrap().is_empty());
1215    }
1216
1217    #[test]
1218    fn a_stale_tier_arch_tag_is_flagged_with_its_replacement() {
1219        let dir = tempdir().unwrap();
1220        write_machine_tags(dir.path(), "n1", &["tag:build-worker", "tier:arm", "os:linux"]);
1221        let found = check_retired_arch_tags(dir.path()).unwrap();
1222        assert_eq!(found.len(), 1, "{found:?}");
1223        assert_eq!(found[0].tag, "tier:arm");
1224        assert_eq!(found[0].replacement(), "arch:arm");
1225        let msg = found[0].message();
1226        // The message has to carry the fix, not just the complaint — this lint
1227        // exists precisely because the symptom ("no node") names nothing.
1228        assert!(msg.contains("arch:arm"), "{msg}");
1229        assert!(msg.contains("n1"), "{msg}");
1230    }
1231
1232    /// The whole point: a node with the old tag is not *rejected* by placement,
1233    /// it silently stops being a candidate. Superset matching has no way to say
1234    /// "you asked for arch:x86 and I have tier:x86".
1235    #[test]
1236    fn a_stale_tag_is_not_caught_by_the_inert_taint_lint() {
1237        let dir = tempdir().unwrap();
1238        write_machine_tags(dir.path(), "n1", &["tier:x86"]);
1239        assert!(
1240            check_inert_taints(dir.path()).unwrap().is_empty(),
1241            "mesh tags are not taints — this needs its own check"
1242        );
1243        assert_eq!(check_retired_arch_tags(dir.path()).unwrap().len(), 1);
1244    }
1245
1246    #[test]
1247    fn missing_machines_dir_is_not_error_for_arch_tags() {
1248        let dir = tempdir().unwrap();
1249        assert!(check_retired_arch_tags(dir.path()).unwrap().is_empty());
1250    }
1251
1252    // ── R605-F12: sovereign members with no stated role ────────────────────
1253
1254    /// `stamp` is the sovereign declaration under test; everything else is the
1255    /// minimum a `MachineConfig` deserializes from. `assert_machine_toml_parses`
1256    /// catches a future edit here that drops a required field before it can
1257    /// produce a vacuously-passing assert-empty test (see that fn's doc).
1258    fn write_sovereign_machine(workspace: &Path, name: &str, stamp: &str) {
1259        let dir = workspace.join(".yah/infra/machines");
1260        std::fs::create_dir_all(&dir).unwrap();
1261        let path = dir.join(format!("{name}.toml"));
1262        std::fs::write(
1263            &path,
1264            format!("name = \"{name}\"\nprovider = \"static\"\nmesh_tags = []\n{stamp}\n"),
1265        )
1266        .unwrap();
1267        assert_machine_toml_parses(&path);
1268    }
1269
1270    #[test]
1271    fn a_group_with_no_role_is_reported_with_the_declaring_file() {
1272        let dir = tempdir().unwrap();
1273        let root = dir.path();
1274        write_sovereign_machine(root, "us-west-001", "sovereign_group = \"prod\"");
1275        let found = check_unroled_sovereign_members(root).unwrap();
1276        assert_eq!(found.len(), 1, "{found:?}");
1277        assert_eq!(found[0].machine, "us-west-001");
1278        assert_eq!(found[0].group, "prod");
1279        assert!(found[0].machine_toml.ends_with("us-west-001.toml"));
1280        let msg = found[0].message();
1281        // The message has to say what the silence currently means, or the
1282        // operator reads it as pedantry and adds the line without deciding.
1283        assert!(msg.contains("voter") && msg.contains("non-voter"), "{msg}");
1284        assert!(msg.contains("TOP-LEVEL"), "{msg}");
1285    }
1286
1287    #[test]
1288    fn either_stated_role_is_clean() {
1289        let dir = tempdir().unwrap();
1290        let root = dir.path();
1291        write_sovereign_machine(
1292            root,
1293            "us-west-001",
1294            "sovereign_group = \"prod\"\nsovereign_role = \"voter\"",
1295        );
1296        write_sovereign_machine(
1297            root,
1298            "us-west-003",
1299            "sovereign_group = \"prod\"\nsovereign_role = \"non-voter\"",
1300        );
1301        assert!(check_unroled_sovereign_members(root).unwrap().is_empty());
1302    }
1303
1304    /// A standalone box has no quorum to be eligible for, so demanding a role
1305    /// of it would be noise — and noise is what trains an operator to stop
1306    /// reading the check that matters.
1307    #[test]
1308    fn a_machine_in_no_group_is_not_asked_for_a_role() {
1309        let dir = tempdir().unwrap();
1310        let root = dir.path();
1311        write_sovereign_machine(root, "us-west-002", "taints = [\"no-appliance\"]");
1312        assert!(check_unroled_sovereign_members(root).unwrap().is_empty());
1313    }
1314
1315    #[test]
1316    fn unroled_findings_are_ordered_by_file_so_output_is_stable() {
1317        let dir = tempdir().unwrap();
1318        let root = dir.path();
1319        write_sovereign_machine(root, "b-node", "sovereign_group = \"dev\"");
1320        write_sovereign_machine(root, "a-node", "sovereign_group = \"prod\"");
1321        let found = check_unroled_sovereign_members(root).unwrap();
1322        let names: Vec<&str> = found.iter().map(|f| f.machine.as_str()).collect();
1323        assert_eq!(names, vec!["a-node", "b-node"]);
1324    }
1325
1326    #[test]
1327    fn missing_machines_dir_is_not_error_for_unroled_members() {
1328        let dir = tempdir().unwrap();
1329        assert!(check_unroled_sovereign_members(dir.path())
1330            .unwrap()
1331            .is_empty());
1332    }
1333
1334    // ── R605-T10: LAN dial targets ─────────────────────────────────────────
1335
1336    /// `connect` is the raw body of the `[connect]` table; `registration` the
1337    /// raw body of `[registration]` (empty string omits the table).
1338    fn write_reach_machine(workspace: &Path, name: &str, connect: &str, registration: &str) {
1339        let dir = workspace.join(".yah/infra/machines");
1340        std::fs::create_dir_all(&dir).unwrap();
1341        let path = dir.join(format!("{name}.toml"));
1342        let reg = if registration.is_empty() {
1343            String::new()
1344        } else {
1345            format!("\n[registration]\n{registration}\n")
1346        };
1347        std::fs::write(
1348            &path,
1349            format!(
1350                "name = \"{name}\"\nprovider = \"static\"\nmesh_tags = []\n\n\
1351                 [connect]\n{connect}\n{reg}"
1352            ),
1353        )
1354        .unwrap();
1355        assert_machine_toml_parses(&path);
1356    }
1357
1358    #[test]
1359    fn a_lan_literal_in_the_dialed_field_is_reported_with_its_file() {
1360        let dir = tempdir().unwrap();
1361        let root = dir.path();
1362        // us-west-011's shape at filing time: LAN literal, no mesh address.
1363        write_reach_machine(
1364            root,
1365            "us-west-011",
1366            "address = \"192.168.10.11\"\nssh = \"yah@192.168.10.11\"\n\
1367             yubaba = \"http://192.168.10.11:7443\"",
1368            "",
1369        );
1370        let found = check_lan_dial_targets(root).unwrap();
1371        assert_eq!(found.len(), 1);
1372        assert_eq!(found[0].machine, "us-west-011");
1373        assert_eq!(found[0].url, "http://192.168.10.11:7443");
1374        assert_eq!(found[0].mesh_ipv4, None);
1375        let msg = found[0].message();
1376        assert!(msg.contains("mesh-join it"), "{msg}");
1377        assert!(msg.contains("us-west-011.toml"), "{msg}");
1378    }
1379
1380    /// A mesh-joined box gets the cheaper instruction, because the fix really
1381    /// is one deleted line — us-west-013/014's shape.
1382    #[test]
1383    fn a_mesh_joined_lan_declarer_is_told_to_delete_the_line() {
1384        let dir = tempdir().unwrap();
1385        let root = dir.path();
1386        write_reach_machine(
1387            root,
1388            "us-west-014",
1389            "address = \"192.168.10.14\"\nssh = \"yah@192.168.10.14\"\n\
1390             yubaba = \"http://192.168.10.14:7443\"",
1391            "mesh_ipv4 = \"100.64.0.6\"",
1392        );
1393        let found = check_lan_dial_targets(root).unwrap();
1394        assert_eq!(found.len(), 1);
1395        assert_eq!(found[0].mesh_ipv4.as_deref(), Some("100.64.0.6"));
1396        let msg = found[0].message();
1397        assert!(msg.contains("DELETE the `yubaba` line"), "{msg}");
1398        assert!(msg.contains("100.64.0.6"), "{msg}");
1399    }
1400
1401    /// The three shapes that must NOT be flagged: a mesh address, the pre-mesh
1402    /// loopback placeholder, and a LAN address confined to the metadata fields
1403    /// (which is the whole point — the operator keeps it, automation ignores it).
1404    #[test]
1405    fn mesh_loopback_and_metadata_only_lan_addresses_are_clean() {
1406        let dir = tempdir().unwrap();
1407        let root = dir.path();
1408        write_reach_machine(
1409            root,
1410            "meshed",
1411            "address = \"192.168.10.15\"\nssh = \"yah@192.168.10.15\"",
1412            "mesh_ipv4 = \"100.64.0.7\"",
1413        );
1414        write_reach_machine(
1415            root,
1416            "tunnelled",
1417            "address = \"192.168.10.16\"\nssh = \"yah@192.168.10.16\"\n\
1418             yubaba = \"http://127.0.0.1:7443\"",
1419            "",
1420        );
1421        write_reach_machine(
1422            root,
1423            "public",
1424            "address = \"45.32.194.254\"\nssh = \"debian@45.32.194.254\"\n\
1425             yubaba = \"http://45.32.194.254:7443\"",
1426            "",
1427        );
1428        assert!(check_lan_dial_targets(root).unwrap().is_empty());
1429    }
1430
1431    #[test]
1432    fn missing_machines_dir_is_not_error_for_lan_dial_targets() {
1433        let dir = tempdir().unwrap();
1434        assert!(check_lan_dial_targets(dir.path()).unwrap().is_empty());
1435    }
1436
1437    // ── R742-T4: inert taints ──────────────────────────────────────────────
1438
1439    fn write_machine(workspace: &Path, name: &str, taints: &[&str]) {
1440        let dir = workspace.join(".yah/infra/machines");
1441        std::fs::create_dir_all(&dir).unwrap();
1442        let list: Vec<String> = taints.iter().map(|t| format!("\"{t}\"")).collect();
1443        let path = dir.join(format!("{name}.toml"));
1444        std::fs::write(
1445            &path,
1446            format!(
1447                "name = \"{name}\"\nprovider = \"static\"\nmesh_tags = []\ntaints = [{}]\n",
1448                list.join(", ")
1449            ),
1450        )
1451        .unwrap();
1452        assert_machine_toml_parses(&path);
1453    }
1454
1455    #[test]
1456    fn archetype_repel_keys_and_affinity_keys_are_clean() {
1457        let dir = tempdir().unwrap();
1458        let root = dir.path();
1459        write_machine(root, "worker", &["no-server", "no-appliance", "no-job"]);
1460        write_machine(root, "edge", &["public-ip"]);
1461        write_machine(root, "plain", &[]);
1462        assert!(check_inert_taints(root).unwrap().is_empty());
1463    }
1464
1465    #[test]
1466    fn a_free_form_taint_is_reported_with_the_declaring_file() {
1467        let dir = tempdir().unwrap();
1468        let root = dir.path();
1469        // W305's headline example. Environment is not a taint.
1470        write_machine(root, "us-west-011", &["qa"]);
1471        let found = check_inert_taints(root).unwrap();
1472        assert_eq!(found.len(), 1, "{found:?}");
1473        assert_eq!(found[0].machine, "us-west-011");
1474        assert_eq!(found[0].key, "qa");
1475        assert!(found[0].machine_toml.ends_with("us-west-011.toml"));
1476        let msg = found[0].message();
1477        // The message has to carry the legal vocabulary, otherwise the
1478        // operator's only recourse is reading the scheduler.
1479        assert!(msg.contains("no-appliance"), "{msg}");
1480        assert!(msg.contains("public-ip"), "{msg}");
1481        assert!(msg.contains("mesh_tags"), "{msg}");
1482    }
1483
1484    #[test]
1485    fn no_voter_is_inert_because_voter_is_not_an_archetype() {
1486        // The one that cost real fleet state: it reads as an exclusion and
1487        // excludes nothing, so it sat on three nodes asserting a falsehood.
1488        let dir = tempdir().unwrap();
1489        let root = dir.path();
1490        write_machine(root, "us-west-015", &["no-server", "no-appliance", "no-voter"]);
1491        let found = check_inert_taints(root).unwrap();
1492        assert_eq!(found.len(), 1, "{found:?}");
1493        assert_eq!(found[0].key, "no-voter");
1494    }
1495
1496    #[test]
1497    fn findings_are_ordered_by_file_so_output_is_stable() {
1498        let dir = tempdir().unwrap();
1499        let root = dir.path();
1500        write_machine(root, "b-node", &["qa"]);
1501        write_machine(root, "a-node", &["staging"]);
1502        let found = check_inert_taints(root).unwrap();
1503        let names: Vec<&str> = found.iter().map(|f| f.machine.as_str()).collect();
1504        assert_eq!(names, vec!["a-node", "b-node"]);
1505    }
1506
1507    #[test]
1508    fn missing_machines_dir_is_not_error() {
1509        let dir = tempdir().unwrap();
1510        assert!(check_inert_taints(dir.path()).unwrap().is_empty());
1511    }
1512
1513    #[test]
1514    fn an_unparseable_machine_toml_is_skipped_not_fatal() {
1515        let dir = tempdir().unwrap();
1516        let root = dir.path();
1517        let mdir = root.join(".yah/infra/machines");
1518        std::fs::create_dir_all(&mdir).unwrap();
1519        std::fs::write(mdir.join("broken.toml"), "name = \n").unwrap();
1520        write_machine(root, "good", &["qa"]);
1521        // The broken file must not sink the sweep — a peer's half-written
1522        // scaffold is a normal state on a shared tree.
1523        let found = check_inert_taints(root).unwrap();
1524        assert_eq!(found.len(), 1);
1525        assert_eq!(found[0].machine, "good");
1526    }
1527
1528    // ── R787: the shared loader's Strict/Tolerant contract ──────────────────
1529
1530    #[test]
1531    fn tolerant_mode_skips_an_unparseable_toml_and_still_pairs_the_path() {
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
1539        let loaded = load_machine_tomls(root, MachineLoadMode::Tolerant).unwrap();
1540        assert_eq!(loaded.len(), 1, "{loaded:?}");
1541        assert_eq!(loaded[0].1.name, "good");
1542        assert!(loaded[0].0.ends_with("good.toml"));
1543    }
1544
1545    #[test]
1546    fn strict_mode_fails_the_whole_load_on_one_unparseable_toml() {
1547        // The behavior collate_workspace_ingress relies on: a bad machine toml
1548        // must not silently resolve a `required` placement onto the wrong node.
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 err = load_machine_tomls(root, MachineLoadMode::Strict).unwrap_err();
1557        assert!(format!("{err:#}").contains("broken.toml"), "{err:#}");
1558    }
1559
1560    // ── R742-F2 (W305): workspace ingress collation ──
1561
1562    /// A mirror fronting one hostname through one edge on `machines`.
1563    fn fronted_mirror(provider: &str, machines: &[&str], hostname: &str, port: u16) -> String {
1564        let list = machines
1565            .iter()
1566            .map(|m| format!("\"{m}\""))
1567            .collect::<Vec<_>>()
1568            .join(", ");
1569        format!(
1570            "schema_version = 1\nshape = \"single-machine\"\n\
1571             ingress = \"{provider}\"\ningress_machines = [{list}]\n\
1572             [providers.compute]\nuse = \"hetzner\"\nzone = \"{hostname}\"\n\
1573             port = {port}\nupstream_host = \"100.64.0.5\"\n"
1574        )
1575    }
1576
1577    #[test]
1578    fn two_services_fronting_one_node_collate_into_one_front_door() {
1579        let dir = tempdir().unwrap();
1580        let root = dir.path();
1581        write_service(root, "yah-marketing", "yah-marketing/site");
1582        write_mirror(
1583            root,
1584            "yah-marketing",
1585            "cloud",
1586            &fronted_mirror("passway", &["us-east-001"], "yah.dev", 8080),
1587        );
1588        write_service(root, "yah-issues", "yah-issues/site");
1589        write_mirror(
1590            root,
1591            "yah-issues",
1592            "cloud",
1593            &fronted_mirror("passway", &["us-east-001"], "issues.yah.dev", 8731),
1594        );
1595
1596        let report = collate_workspace_ingress(root).unwrap();
1597        assert!(report.problems.is_empty(), "{:?}", report.problems);
1598        assert_eq!(report.collation.front_doors.len(), 1);
1599        let door = &report.collation.front_doors[0];
1600        assert_eq!(door.machine, "us-east-001");
1601        assert_eq!(
1602            door.passway_upstreams().unwrap(),
1603            vec!["issues.yah.dev=100.64.0.5:8731", "yah.dev=100.64.0.5:8080"]
1604        );
1605    }
1606
1607    #[test]
1608    fn a_cross_service_hostname_clash_is_reported_with_both_declarations() {
1609        // Neither service's own `yah cloud apply` can see this: each plans its
1610        // own mirror, both look fine, and the box ends up with whichever
1611        // applied last.
1612        let dir = tempdir().unwrap();
1613        let root = dir.path();
1614        write_service(root, "svc-a", "svc-a/site");
1615        write_mirror(
1616            root,
1617            "svc-a",
1618            "cloud",
1619            &fronted_mirror("passway", &["us-east-001"], "yah.dev", 8080),
1620        );
1621        write_service(root, "svc-b", "svc-b/site");
1622        write_mirror(
1623            root,
1624            "svc-b",
1625            "cloud",
1626            &fronted_mirror("cloudflare-tunnel", &["us-west-001"], "yah.dev", 8080),
1627        );
1628
1629        let report = collate_workspace_ingress(root).unwrap();
1630        let fatal: Vec<String> = report
1631            .problems
1632            .iter()
1633            .filter(|p| p.is_fatal())
1634            .map(|p| p.message())
1635            .collect();
1636        assert_eq!(fatal.len(), 1, "{fatal:?}");
1637        assert!(fatal[0].contains("svc-a/cloud"), "{}", fatal[0]);
1638        assert!(fatal[0].contains("svc-b/cloud"), "{}", fatal[0]);
1639    }
1640
1641    #[test]
1642    fn one_mirrors_broken_declaration_does_not_hide_the_rest() {
1643        // A malformed edge is reported against the file that declared it, and
1644        // the sweep continues — one service's typo must not blind the operator
1645        // to every other service's front doors.
1646        let dir = tempdir().unwrap();
1647        let root = dir.path();
1648        write_service(root, "svc-broken", "svc-broken/site");
1649        write_mirror(
1650            root,
1651            "svc-broken",
1652            "cloud",
1653            "schema_version = 1\nshape = \"single-machine\"\n\
1654             ingress_machines = [\"us-east-001\"]\n\
1655             [providers.compute]\nuse = \"hetzner\"\nzone = \"a.yah.dev\"\nport = 8080\n",
1656        );
1657        write_service(root, "svc-ok", "svc-ok/site");
1658        write_mirror(
1659            root,
1660            "svc-ok",
1661            "cloud",
1662            &fronted_mirror("passway", &["us-east-001"], "b.yah.dev", 8080),
1663        );
1664
1665        let report = collate_workspace_ingress(root).unwrap();
1666        assert_eq!(report.problems.len(), 1);
1667        assert!(
1668            report.problems[0].message().contains("svc-broken/cloud"),
1669            "{}",
1670            report.problems[0].message()
1671        );
1672        assert_eq!(report.collation.front_doors.len(), 1, "svc-ok still collates");
1673    }
1674
1675    #[test]
1676    fn a_mirror_with_no_ingress_contributes_nothing_and_is_not_a_finding() {
1677        let dir = tempdir().unwrap();
1678        let root = dir.path();
1679        write_service(root, "svc", "svc/site");
1680        write_mirror(root, "svc", "dev", &local_static_mirror(4322));
1681        let report = collate_workspace_ingress(root).unwrap();
1682        assert!(report.problems.is_empty());
1683        assert!(report.collation.front_doors.is_empty());
1684    }
1685}