Skip to main content

cloud/
config.rs

1//! @yah:ticket(R040-F16, "pg-on-mesh service recipe: bind tailscale0 + pg_hba.conf snippet + ufw rules")
2//! @yah:at(2026-05-05T00:32:34Z)
3//! @yah:assignee(agent:claude)
4//! @yah:status(review)
5//! @yah:parent(R040)
6//! @yah:handoff("Companion to R040-F15. Inter-node TCP (Postgres primary↔replica, NATS clusters, anything raw-protocol) lives on the Headscale mesh, not on Hetzner public IPs. Each node has a stable 100.64.x.x mesh IP that survives replacement of the underlying box, so DNS / config / pg_hba never churn when a CPX-11 is rebuilt. WireGuard already encrypts the wire — TLS becomes defense-in-depth, not load-bearing. This ticket carries the concrete pg-shaped recipe so the first stateful service deploy doesn't have to re-derive the pattern; subsequent services (redis, NATS, etc.) cargo-cult from it.")
7//! @yah:next("ServiceConfig gains a `bind_interface: Option<String>` field (e.g. `Some(\"tailscale0\")` for mesh-only services). The cloud-init/podman compose renderer translates this into either `--network host` + `pg listen_addresses = '<mesh-ip>'` OR a podman macvlan/host-binding pattern that achieves the same.")
8//! @yah:next("Generated pg_hba.conf snippet: allow the mesh subnet (100.64.0.0/10) for replication + app users. Postgres binds to the node's tailscale0 mesh IP only — `listen_addresses` is templated from the node's `tailscale ip --4` at first boot.")
9//! @yah:next("Generated ufw rules: `ufw allow in on tailscale0 to any port 5432; ufw deny 5432` — mirrors the existing yah-yubaba 7443 pattern in mirror.yml. Same shape works for any mesh-only port.")
10//! @yah:next("Replica connection string uses primary's mesh IP, NOT its public IP. Stable across box replacement.")
11//! @yah:next("Out of scope: pg_basebackup orchestration, failover, WAL archiving — those belong in noisetable's domain; this ticket only standardizes the binding/firewall/auth shape so noisetable's pg deployment doesn't reinvent it.")
12//!
13//!
14//! @yah:ticket(R323-F9, "Add sync-wave ordering to ServiceComponent (deploy-panel wave order)")
15//! @yah:assignee(agent:claude)
16//! @yah:at(2026-05-26T15:20:25Z)
17//! @yah:status(review)
18//! @yah:phase(P2)
19//! @yah:parent(R323)
20//! @yah:next("ServiceComponent gains a wave/order field (or depends_on between components) so the deploy panel (R323-F4) can group workload rollout rows into sync waves (wave 0 parallel, wait healthy, wave 1, …). Today all components are implicitly wave 0.")
21//! @yah:next("compute_service/compute_cell in reconciler/sync_status.rs surface the wave per workload so F4 doesn't re-derive it.")
22//! @yah:gotcha("Until this lands, F4 should render every workload as wave 0 (no ordering).")
23//! @yah:handoff("Added wave: u32 (serde default=0, skip_serializing_if zero) to ServiceComponent in config.rs. Added is_zero_u32 helper. Fixed the three struct literal call-sites that now need wave: 0 (config.rs test, local_sim.rs x2, mesofact_static.rs). Added wave?: number to the TS ServiceComponent interface with a doc comment. Deploy panel now reads c.wave ?? 0 for each WorkloadRow instead of hardcoded 0. SyncFooter computes maxWave from the components array and renders 'wave 0' (all-zero case) or 'waves 0–N' (multi-wave). All 218 cloud lib tests pass; bun run typecheck clean.")
24//! @yah:verify("cargo test -p cloud --lib  # 218 passed")
25//! @yah:verify("cd packages/yah/ui && bun run typecheck  # no new errors")
26//! @yah:verify("In service.toml: add wave = 1 to a component, rebuild, open the deploy panel — that workload row shows 'w1' badge; SyncFooter shows 'waves 0–1'")
27//! @yah:verify("Component with no wave field in TOML deserializes as wave=0 (default). Saving a wave=0 component omits the field from the output TOML (skip_serializing_if).")
28//!
29//! @arch:see(.yah/docs/working/W142-pond.md)
30//!
31//! @yah:relay(R615, "Linked infra sources: sources.toml overlay so a camp can borrow another camp's substrate")
32//! @yah:at(2026-07-20T18:18:05Z)
33//! @yah:status(open)
34//! @arch:see(.yah/docs/working/W274-linked-infra-sources.md)
35//!
36//! @yah:ticket(R615-F1, "InfraSource types + SourcesConfig::load(infra_dir) parsing .yah/infra/sources.toml")
37//! @yah:status(review)
38//! @yah:assignee(agent:bundle-anthropic-miravel)
39//! @yah:at(2026-08-08T19:55:57Z)
40//! @yah:phase(P1)
41//! @yah:parent(R615)
42//! @yah:next("Add InfraSourceKind { Path { path }, Git(GitSource) } + InfraSource { owner, kind, mode, select } to cloud/src/config.rs. Reuse the existing GitSource (config.rs:1205, { repo, ref, subdir }) verbatim — do not invent a second git-source shape.")
43//! @yah:next("SourcesConfig::load(infra_dir) reads .yah/infra/sources.toml (schema_version = 1, ordered [[source]] array). Absent file = empty list, never an error — every existing camp has no sources.toml.")
44//! @yah:next("mode is the write-gate: read-only (borrower cannot mutate) vs owner-manages. Model it as an enum, not a bool, so a future read-write-with-approval tier is additive.")
45//! @yah:verify("cargo check -p cloud && cargo test -p cloud")
46//! @arch:see(.yah/docs/working/W274-linked-infra-sources.md)
47//! @yah:tier(Cleric)
48//! @yah:handoff("InfraSourceKind{Path{path},Git(GitSource)} + SourceMode{ReadOnly,Manage} + InfraSource{owner,kind,mode,select} + SourcesConfig{schema_version,source} all landed in oss/yubaba/crates/cloud/src/config.rs (after default_git_ref, ~line 1550). GitSource reused verbatim -- Git(GitSource) wraps the existing R561 type unchanged, no second git-source shape. InfraSourceKind is internally tagged (#[serde(tag=\"kind\", rename_all=\"kebab-case\")]) and flattened into InfraSource so a [[source]] table reads exactly like W274's example: owner/kind/path-or-repo+ref+subdir/mode/select all at one table level. mode: SourceMode defaults ReadOnly via #[serde(default)] on the field (enum, not bool, per the ticket's own instruction -- Manage is the explicit escape hatch). SourcesConfig::load(infra_dir) returns Ok(default()) -- schema_version=1, empty source list -- when sources.toml is absent; only parses+errors when the file exists and is malformed.")
49//! @yah:handoff("Tree anchor 85801e7f. Pathspec: oss/yubaba/crates/cloud/src/config.rs (only file touched). Tests: cargo test -p yah-cloud --lib (from oss/yubaba) 710 passed / 0 failed / 4 ignored, +6 new over the 704 baseline your R707-T6 verification recorded (sources_load_is_empty_when_the_file_is_absent, sources_parses_a_path_kind_exactly_like_w274s_example, sources_parses_a_git_kind_reusing_gitsource_verbatim, sources_mode_defaults_to_read_only_and_manage_is_explicit, sources_preserves_declaration_order, sources_round_trips_through_serialize). cargo check -p cloud also green (implied by the test build).")
50//! @yah:handoff("Tree anchor at handoff: 85801e7f6b76b369c0c8ecd2e5c7874990cd9286 — the shared tree as I left it. Diff against it (`git diff 85801e7f6b76b369c0c8ecd2e5c7874990cd9286..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
51//! @yah:next("R615-F2 picks this straight up: overlay these sources into CloudConfig::load, tagging origin{owner,source} and merging camp-local-wins-on-collision.")
52//! @yah:handoff("Verified pre-existing work: InfraSourceKind{Path,Git(GitSource)} + SourceMode + InfraSource + SourcesConfig all present in oss/yubaba/crates/cloud/src/config.rs at tree anchor 871fde1c, matching the inline @yah:handoff notes already on this ticket. GitSource reused verbatim, no second git-source shape. This session added no new code -- only ran verification and closed the board state, which a prior session left stuck in `open` despite the work being done (code + handoff notes landed, but board.review/handoff was never called).")
53//! @yah:verify("cargo check -p yah-cloud -- clean (2 pre-existing unrelated warnings)")
54//! @yah:verify("cargo test -p yah-cloud --lib -- 723 passed; 0 failed; 4 ignored (from oss/yubaba)")
55//!
56//! @yah:ticket(R615-F2, "Overlay loader: resolve sources in CloudConfig::load, tag origin, camp-local wins on collision")
57//! @yah:status(review)
58//! @yah:assignee(agent:bundle-anthropic-miravel)
59//! @yah:at(2026-08-08T19:56:05Z)
60//! @yah:phase(P1)
61//! @yah:parent(R615)
62//! @yah:next("In CloudConfig::load, after loading camp-local machines/providers/rules, resolve each source to an infra root (git sources read from the .yah/cache/infra/ sync cache — load stays offline), load that root's machines/providers/rules, tag each entry with origin { owner, source }, and overlay UNDER camp-local. Camp-local wins on name collision.")
63//! @yah:next("The machine load site is config.rs:533 (load_dir::<MachineConfig>(paths::machines_dir(...))). Note config.rs:575 load_from_config_dir is a SECOND machine load site that deliberately skips the inherit_machines redirect for multi-root/sibling trees (W206) — decide explicitly whether sources overlay applies there too, and document the answer either way.")
64//! @yah:verify("cargo check -p cloud && cargo test -p cloud")
65//! @yah:verify("A camp with sources.toml [[source]] kind=path to a sibling camp sees that camp's machines in CloudConfig::load, each tagged with the source owner")
66//! @yah:gotcha("Cross-camp MachineConfig schema skew is real: noisetable ships an older machine schema (location/server_type/hosts_mirrors) while yah's use region/arch/[connect]. A borrowed source can carry fields the borrower's binary predates. Overlay load MUST tolerate/skip unparseable foreign entries per-file and warn — never fail the whole load.")
67//! @arch:see(.yah/docs/working/W274-linked-infra-sources.md)
68//! @yah:depends_on(R615-F1)
69//! @yah:tier(Warrior)
70//! @yah:handoff("Overlay landed in CloudConfig::load (oss/yubaba/crates/cloud/src/config.rs). After camp-local machines/providers/legacy-merge finish, SourcesConfig::load(paths::infra_dir(workspace_root)) resolves + overlay_infra_sources() merges each source's machines/providers UNDER what's already there -- camp-local wins any name collision, and among sources themselves the earlier-declared one wins (both proven by dedicated tests). Provenance is NOT a field on MachineConfig/ProviderConfig: added CloudConfig.machine_origins/provider_origins: BTreeMap<String, InfraOrigin> instead, keyed by name/id. Reason recorded in a doc comment on InfraOrigin -- MachineConfig/ProviderConfig are constructed by struct literal in test helpers across several crates (including crates/yah/agent-tools/src/cloud_tools.rs, which is fenced/live-owned this session), so widening either shape would have forced an edit there for zero semantic gain; origin is a property of the LOAD, not the machine.")
71//! @yah:handoff("GOTCHA closed: added load_dir_tolerant<T>() -- a per-file-tolerant sibling of the existing (strict) load_dir -- so one unparseable foreign machine/provider (schema skew) skips-with-a-tracing::warn! and never sinks the rest of that source's directory or this camp's own load. Proven by one_unparseable_foreign_machine_does_not_sink_the_rest_of_the_directory_or_the_load. load_dir itself is untouched -- camp-local files still hard-fail on a bad TOML, which is correct, only borrowed roots get the tolerant path.")
72//! @yah:handoff("Git sources: InfraSource::infra_root() resolves kind=path to <workspace_root>/<path>/.yah/infra (live tree, no I/O beyond building the path) and kind=git to paths::infra_source_cache_dir(workspace_root, owner)/infra -- a NEW path helper in paths.rs, also what R615-T3's `yah infra sync` target directory must be so the two line up. An unsynced git source (cache dir absent) overlays nothing and is explicitly NOT an error (test: an_unsynced_git_source_overlays_nothing_and_is_not_an_error) -- load() stays fully offline as W274 §3 requires.")
73//! @yah:handoff("select filtering implemented for machines only (name exact-match or literal mesh_tags membership -- not a glob engine, matches W274's own example verbatim) via machine_matches_select(); does NOT apply to providers -- documented as a deliberate choice, nothing in W274 or the ticket describes a provider-scoped filter.")
74//! @yah:handoff("EXPLICIT DECISION on the config.rs:575-equivalent gotcha (now load_from_config_dir): sources overlay does NOT apply there. Multi-root sibling config dirs (W206 layout (b)) are a second config root INSIDE the same camp, not a second camp -- .yah/infra/sources.toml is tied to paths::infra_dir(workspace_root) specifically, which has no well-defined meaning for an arbitrary config_dir. Documented in the function's doc comment and proven by load_from_config_dir_never_applies_sources_overlay (a sources.toml at the real workspace root does NOT leak into a load_from_config_dir call against a sibling .noisetable/ dir under that same root).")
75//! @yah:handoff("Tree anchor 85801e7f. Pathspec: oss/yubaba/crates/cloud/src/config.rs, oss/yubaba/crates/cloud/src/paths.rs (added infra_source_cache_dir + 1 test), oss/yubaba/crates/cloud/src/reconciler/mesofact_bundle.rs (CloudConfig test-literal fixed for the 2 new fields), app/yah/cli/src/cloud.rs (3 CloudConfig test-literal sites fixed, same reason). Tests: cargo test -p yah-cloud --lib (from oss/yubaba) 720 passed / 0 failed / 4 ignored, +10 over R615-F1's 710 baseline (9 overlay tests in config.rs + 1 in paths.rs). cargo build -p yah --lib (repo root) green -- confirms nothing downstream (agent-tools, cloud.rs, hub) broke from CloudConfig's two new fields.")
76//! @yah:handoff("Tree anchor at handoff: 85801e7f6b76b369c0c8ecd2e5c7874990cd9286 — the shared tree as I left it. Diff against it (`git diff 85801e7f6b76b369c0c8ecd2e5c7874990cd9286..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
77//! @yah:next("R615-T3 (yah infra sync) is unblocked and has everything it needs: paths::infra_source_cache_dir(workspace_root, owner) is the exact target directory to clone/pull git sources into, already matching what F2's overlay reads from.")
78//! @yah:next("R615-F4 (Infra tab origin badge, not in my assigned lane) can read CloudConfig.machine_origins/provider_origins directly -- no further backend plumbing needed for the badge itself.")
79//! @yah:handoff("Verified pre-existing work: overlay landed in CloudConfig::load (oss/yubaba/crates/cloud/src/config.rs) at tree anchor 871fde1c -- SourcesConfig::load resolves sources, overlay_infra_sources() merges under camp-local with camp-local-wins and earlier-source-wins collision rules, machine_origins/provider_origins BTreeMaps added to CloudConfig, load_dir_tolerant() added for per-file-tolerant foreign schema skew, InfraSource::infra_root() resolves path/git kinds, load_from_config_dir explicitly does NOT get the overlay (documented). Matches this ticket's own inline @yah:handoff notes. This session added no new code -- only ran verification and closed board state that a prior session left stuck in `open` despite the work being done.")
80//! @yah:verify("cargo check -p yah-cloud -- clean (2 pre-existing unrelated warnings)")
81//! @yah:verify("cargo test -p yah-cloud --lib -- 723 passed; 0 failed; 4 ignored (from oss/yubaba), includes overlay tests + load_dir_tolerant test + infra_source_cache_dir test in paths.rs")
82
83use anyhow::{Context, Result};
84use serde::{Deserialize, Serialize};
85use std::collections::{BTreeMap, HashMap};
86use std::path::Path;
87use thiserror::Error;
88use workload_spec::secrets::SecretAccess;
89use workload_spec::{validate, LifecycleArchetype, TenantId, WorkloadSpec};
90
91/// Static node capacity declaration on `machine.toml` (R572-F3).
92///
93/// `memory_mb` and `cpu_millis` express the node's *total* hardware budget.
94/// F5's bin-packer subtracts the sum of committed workload requests from
95/// this floor to determine available headroom; an absent `allocatable`
96/// block means no capacity constraint is enforced (any workload fits).
97#[derive(Debug, Clone, Serialize, Deserialize)]
98#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
99pub struct NodeAllocatable {
100    /// Total physical RAM in mebibytes (e.g. 512 for a 512 MB node).
101    pub memory_mb: u32,
102    /// Total CPU in k8s millicores (1000 = 1 core, 250 = 0.25 CPU).
103    pub cpu_millis: u32,
104}
105
106/// `[registration]` — facts **observed** about a running box, written by the
107/// fleet rather than declared by an operator (R707-T1).
108///
109/// The rest of `machine.toml` is *declaration*: intent, operator-authored,
110/// reviewed and diffed like any other source. This block is the other half —
111/// what the box turned out to be once it booted and joined. Keeping the two
112/// apart is what lets the published fleet index (R707-F3) say which half it is
113/// carrying; publishing them under one schema would bake the confusion into a
114/// permanent record.
115///
116/// The split is a **provenance** boundary, not a trust or reach one:
117/// - *Declaration* answers "what did we ask for" — `name`, `region`, `arch`,
118///   `mesh_tags`, `[allocatable]`, and the declared reach in [`ConnectSpec`].
119/// - *Registration* answers "what did we observe" — the hostkey TOFU'd at
120///   attach, the mesh address headscale assigned at join.
121///
122/// It stays in the git-tracked TOML on purpose. Registration is not local
123/// scratch state: every consumer needs the mesh address to dial a node, so it
124/// has to travel with the declaration. (`.yah/infra/state/machines/<name>.json`
125/// — [`crate::state::MachineState`] — remains the *gitignored* sidecar for
126/// provider-side derivatives that nobody but this camp needs.)
127#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
128#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
129pub struct MachineRegistration {
130    /// Yubaba's ed25519 `/identity` fingerprint, TOFU-recorded by
131    /// `yah cloud machine attach` on first contact (`SHA256:…`). An observed
132    /// property of a running process — not the operator's intent — which is
133    /// why it moved out of the top level here.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub hostkey_fingerprint: Option<String>,
136    /// Mesh (headscale/tailnet) IPv4 assigned at join, e.g. `"100.64.0.1"`.
137    /// Bare address, not a URL: the *port* is declared reach and lives on
138    /// [`ConnectSpec::yubaba_port`]. [`MachineConfig::yubaba_url`] composes the
139    /// two. Absent until the node has joined the mesh.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub mesh_ipv4: Option<String>,
142    /// RFC3339 timestamp of the mesh join that produced `mesh_ipv4`. Free-form
143    /// audit; nothing keys off it.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub joined_at: Option<String>,
146}
147
148impl MachineRegistration {
149    /// True when nothing has been observed yet — used to omit the whole
150    /// `[registration]` table from a serialized machine TOML.
151    pub fn is_empty(&self) -> bool {
152        self.hostkey_fingerprint.is_none() && self.mesh_ipv4.is_none() && self.joined_at.is_none()
153    }
154}
155
156/// Per-machine TOML from `.yah/infra/machines/<name>.toml`.
157///
158/// Two halves, split by provenance (R707-T1): everything here is *declaration*
159/// — operator intent under review and blame — except [`registration`], which
160/// carries what the fleet observed. See [`MachineRegistration`] for why the
161/// boundary is drawn there and what depends on it.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
164pub struct MachineConfig {
165    pub name: String,
166    pub provider: String,
167    /// Who the hardware actually comes from (`"ovh"`, `"vultr"`, `"on-prem"`).
168    ///
169    /// Deliberately *not* [`provider`](Self::provider), which selects the
170    /// auto-provision driver: a box we rented by hand and brought up over SSH
171    /// is `provider = "static"` for its whole life, and writing the vendor
172    /// there instead would flip it driver-backed and make
173    /// [`validate`](Self::validate) demand `location` + `server_type` it has no
174    /// answer for. The two axes genuinely differ — vendor is who bills you,
175    /// `provider` is who yah can call an API against.
176    ///
177    /// Worth recording because vendor-scoped policy is invisible in every other
178    /// field and decides real work: outbound port 25, rDNS/PTR control, IP
179    /// reputation, egress billing. It survived only in TOML prose until now,
180    /// which made it ungreppable at exactly the moment you need it.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub vendor: Option<String>,
183    /// Human label for the box (`"gamer"`, `"the GEEKOM"`). Free-form and never
184    /// matched on — [`name`](Self::name) stays the identity everywhere. This is
185    /// only so operators and agents can say which box they mean out loud.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub nickname: Option<String>,
188    /// Provider DC code (e.g. Hetzner `"hil"`). **Provisioning-only**: required
189    /// iff the provider has an auto-provision driver ([`provider_has_machine_driver`]);
190    /// a BYO `static` node we brought up over SSH has no such code. Optional at
191    /// load time so static machine.tomls omit it; [`MachineConfig::validate`]
192    /// enforces presence at the right moment for driver-backed providers.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub location: Option<String>,
195    /// Provider SKU/size (e.g. Hetzner `"ccx13"`). Provisioning-only, same
196    /// optionality contract as [`location`](Self::location).
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub server_type: Option<String>,
199    /// **Deprecated (R330-F16).** A machine should describe *itself* (region,
200    /// zone, provider, mesh_tags); *which* mirrors run on it is derived by the
201    /// reconciler from each mirror's `required` placement spec, not declared
202    /// here. Now optional + omitted-when-empty so new machine.tomls leave it
203    /// out. The legacy `resolve_mirror_machine` topology fallback still reads
204    /// it until yubaba's reverse-index supersedes the topology.toml path; once
205    /// that lands, this field and its readers are removed wholesale.
206    #[serde(default, skip_serializing_if = "Vec::is_empty")]
207    pub hosts_mirrors: Vec<String>,
208    pub mesh_tags: Vec<String>,
209    /// Canonical geo region label (latency axis), e.g. `"us-west"`. F16's three
210    /// topology axes are orthogonal: `region` = geo (latency), `zone` = failure
211    /// domain within a region (HA), `provider` = network/cost. `region` is
212    /// distinct from `location` (the provider's DC code, e.g. Hetzner `"hil"`):
213    /// `location` is provider-scoped, `region` is our provider-neutral label.
214    /// Optional for backward-compat; a machine without it never satisfies a
215    /// `required.regions` constraint.
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub region: Option<String>,
218    /// Failure-domain label within a region (HA axis), e.g. `"hil"`. For
219    /// single-DC Hetzner this typically mirrors `location`. F16 placement
220    /// matches `required.zones` against this. Optional for backward-compat.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub zone: Option<String>,
223    /// Declared CPU architecture (`"x86_64"` / `"aarch64"`). A machine has
224    /// exactly one — it's a first-class property of the box, not a reach
225    /// detail and not a mesh tag. Drives the yubaba release triple. Optional
226    /// only because there's no provider API to probe it (static nodes declare
227    /// it; a driver-backed provider may leave it unset until known).
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub arch: Option<String>,
230    pub bucket: Option<BucketSpec>,
231    /// **Legacy location, superseded by `[registration].hostkey_fingerprint`**
232    /// (R707-T1). Still deserialized so machine TOMLs written before the split
233    /// keep parsing; never *read* directly — go through
234    /// [`MachineConfig::hostkey_fingerprint`], which prefers the registration
235    /// block. [`MachineConfig::normalize`] folds this into `registration`, and
236    /// [`MachineConfig::save`] normalizes before writing, so a load→save cycle
237    /// migrates the file rather than dropping the value.
238    #[serde(
239        rename = "hostkey_fingerprint",
240        default,
241        skip_serializing_if = "Option::is_none"
242    )]
243    pub legacy_hostkey_fingerprint: Option<String>,
244    /// Provider-side SSH-key IDs (Hetzner: from `GET /v1/ssh_keys`)
245    /// authorized for `root` at create time. Defaults to empty for
246    /// backwards-compat with existing machine declarations; an empty
247    /// list yields a Hetzner-emailed random root password (which the
248    /// driver currently discards). Populate this when you want pre-mesh
249    /// SSH access for bootstrap deploys or recovery.
250    #[serde(default, skip_serializing_if = "Vec::is_empty")]
251    pub ssh_keys: Vec<u64>,
252    /// Cloudflare Tunnel ID this machine joins (e.g. `abc123.cfargotunnel.com`).
253    /// `None` → no tunnel (mesh-only node, no public ingress).
254    /// When set, `yah cloud machine provision` reads `cloudflare-tunnel-token`
255    /// from the keys vault and injects the cloudflared install block into
256    /// cloud-init so the new machine connects to CF edge on first boot.
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub cloudflared: Option<String>,
259    /// When `true`, this machine hosts operator-bridge workloads (Tailscale
260    /// operator access to mesh-internal services). `yah cloud machine provision`
261    /// will install tailscaled and run `tailscale up` during cloud-init via the
262    /// `{{OPERATOR_BRIDGE_BLOCK}}` placeholder. Defaults to `false` for
263    /// backward-compat with existing machine declarations.
264    #[serde(default)]
265    pub hosts_operator_bridge: bool,
266    /// BYO `static`-node reach descriptor. Static nodes have no provider API to
267    /// probe, so how the camp reaches them (SSH user@host + the yubaba URL,
268    /// which is loopback until the WireGuard mesh lands) is *declared* here.
269    /// `None` for driver-backed providers (Hetzner/Vultr), whose address is
270    /// resolved from the provider API / mesh at provision time.
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub connect: Option<ConnectSpec>,
273    /// Static node capacity (R572-F3). Declares the node's total hardware
274    /// budget; F5's scheduler subtracts committed workload requests from this
275    /// to check whether a new workload fits. Absent means unconstrained.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub allocatable: Option<NodeAllocatable>,
278    /// Repel-unless-tolerate taint keys (R572-F3). A workload must tolerate
279    /// every taint on a candidate node for the scheduler to place it there.
280    /// Examples: `"no-appliance"` prevents Appliance workloads; `"no-voter"`
281    /// prevents a node from joining quorum; `"public-ip"` is a positive
282    /// requirement marker the ingress appliance (W267) demands.
283    #[serde(default, skip_serializing_if = "Vec::is_empty")]
284    pub taints: Vec<String>,
285    /// `[registration]` — the observed half (R707-T1). Empty until the box has
286    /// been attached / mesh-joined. See [`MachineRegistration`].
287    #[serde(default, skip_serializing_if = "MachineRegistration::is_empty")]
288    pub registration: MachineRegistration,
289}
290
291/// True iff `provider` has an auto-provision driver (create/destroy via API).
292/// Driver-backed providers require `location` + `server_type`; BYO `static`
293/// nodes (brought up over SSH) do not. The cloud-vs-vps distinction the fleet
294/// cares about lives here — at the provider-capability layer — not as a
295/// separate machine type (W242 BYO Phase-0 decision).
296pub fn provider_has_machine_driver(provider: &str) -> bool {
297    matches!(provider, "hetzner" | "vultr" | "digitalocean")
298}
299
300impl MachineConfig {
301    /// Provider DC code, or `""` when omitted (static nodes). Most readers want
302    /// a `&str`; the driver-backed provision/status paths still go through
303    /// [`validate`](Self::validate) which guarantees presence for those.
304    pub fn location(&self) -> &str {
305        self.location.as_deref().unwrap_or("")
306    }
307
308    /// Provider SKU, or `""` when omitted (static nodes).
309    pub fn server_type(&self) -> &str {
310        self.server_type.as_deref().unwrap_or("")
311    }
312
313    /// Enforce the provisioning-only-field contract: a machine whose provider
314    /// has an auto-provision driver MUST declare `location` + `server_type`
315    /// (the driver can't create a server without them). Static nodes may omit
316    /// both. Call this before any provision/diff that assumes a driver.
317    pub fn validate(&self) -> Result<()> {
318        if provider_has_machine_driver(&self.provider) {
319            if self.location.is_none() {
320                anyhow::bail!(
321                    "machine '{}' (provider '{}') has an auto-provision driver but no `location`",
322                    self.name,
323                    self.provider
324                );
325            }
326            if self.server_type.is_none() {
327                anyhow::bail!(
328                    "machine '{}' (provider '{}') has an auto-provision driver but no `server_type`",
329                    self.name,
330                    self.provider
331                );
332            }
333        }
334        Ok(())
335    }
336
337    /// Yubaba's TOFU'd hostkey fingerprint, from `[registration]` and falling
338    /// back to the pre-R707-T1 top-level field. **The only read path** — a
339    /// caller that reaches for `legacy_hostkey_fingerprint` directly sees
340    /// `None` on every migrated machine.
341    pub fn hostkey_fingerprint(&self) -> Option<&str> {
342        self.registration
343            .hostkey_fingerprint
344            .as_deref()
345            .or(self.legacy_hostkey_fingerprint.as_deref())
346    }
347
348    /// Record (or clear) the observed hostkey fingerprint. Writes
349    /// `[registration]` and drops any pre-R707-T1 top-level value, so the two
350    /// locations can never disagree after a writeback.
351    pub fn set_hostkey_fingerprint(&mut self, fingerprint: Option<String>) {
352        self.registration.hostkey_fingerprint = fingerprint;
353        self.legacy_hostkey_fingerprint = None;
354    }
355
356    /// Mesh (tailnet) IPv4 for this node, or `None` pre-mesh.
357    ///
358    /// Prefers `[registration].mesh_ipv4`; falls back to the host of a legacy
359    /// `[connect].yubaba` URL when that host is in the `100.64.0.0/10` CGNAT
360    /// range the mesh uses. A loopback placeholder (`http://127.0.0.1:7443`,
361    /// meaning "pre-mesh, reachable only through an SSH tunnel") is *not* a
362    /// mesh address and yields `None`.
363    pub fn mesh_ipv4(&self) -> Option<&str> {
364        if let Some(ip) = self.registration.mesh_ipv4.as_deref() {
365            return Some(ip);
366        }
367        let url = self.connect.as_ref()?.yubaba.as_deref()?;
368        mesh_ipv4_from_url(url)
369    }
370
371    /// Base URL for this node's yubaba, or `None` when it declares no reach.
372    ///
373    /// A declared `[connect].yubaba` wins whenever present — full stop, not
374    /// only for the pre-mesh loopback placeholder. `[registration].mesh_ipv4`
375    /// + `[connect].yubaba_port` is the *derivation* used only when nothing is
376    /// declared (R707-T6 / W295).
377    ///
378    /// This was narrower once: a declared literal won only when there was no
379    /// registered mesh address, on the reasoning that the loopback placeholder
380    /// (`http://127.0.0.1:7443`, "reach me through the SSH tunnel") is a
381    /// genuine declaration and not a stale observation. That reasoning still
382    /// holds — it just never considered a *non-loopback* literal coexisting
383    /// with a mesh address, which is exactly R608-F18's forcing case:
384    /// us-west-014 is mesh-joined (`mesh_ipv4`) but its raft peers are
385    /// LAN-only, so `rollout::yubaba::membership_to_nodes` needs the LAN
386    /// literal, not the mesh-derived URL, to match the raft membership
387    /// address. A declared literal is *always* the more specific statement —
388    /// whether it says "SSH tunnel only" or "reach me on the LAN" — and
389    /// `mesh_ipv4` is only ever a convenience for the common case where
390    /// nothing more specific was declared. There is no third state to add: the
391    /// fields already say everything needed, only their precedence was wrong
392    /// for a declared-and-mesh-joined node.
393    pub fn yubaba_url(&self) -> Option<String> {
394        let connect = self.connect.as_ref()?;
395        if let Some(literal) = &connect.yubaba {
396            return Some(literal.clone());
397        }
398        let ip = self.registration.mesh_ipv4.as_deref()?;
399        Some(format!("http://{ip}:{}", connect.yubaba_port()))
400    }
401
402    /// Fold the pre-R707-T1 top-level `hostkey_fingerprint` into
403    /// `[registration]`, and lift a mesh IP out of a legacy `[connect].yubaba`
404    /// URL. Idempotent; a machine already on the split shape is untouched.
405    ///
406    /// [`save`](Self::save) calls this, so writing a machine TOML migrates it
407    /// rather than round-tripping the old shape back out.
408    pub fn normalize(&mut self) {
409        if let Some(fp) = self.legacy_hostkey_fingerprint.take() {
410            self.registration.hostkey_fingerprint.get_or_insert(fp);
411        }
412        if self.registration.mesh_ipv4.is_none() {
413            if let Some(ip) = self
414                .connect
415                .as_ref()
416                .and_then(|c| c.yubaba.as_deref())
417                .and_then(mesh_ipv4_from_url)
418                .map(str::to_string)
419            {
420                self.registration.mesh_ipv4 = Some(ip);
421                // The URL was pure derivation from mesh IP + port; keep only
422                // the declared half so the two can't drift apart.
423                if let Some(c) = self.connect.as_mut() {
424                    c.yubaba = None;
425                }
426            }
427        }
428    }
429
430    /// Persist to `<cloud_dir>/machines/<name>.toml`, creating the dir if needed.
431    ///
432    /// ⚠ Serializes the struct, so **operator comments in the target file are
433    /// lost**. Pre-existing behaviour, not introduced here, but it is why
434    /// registration writeback (`yah cloud machine attach`) goes through
435    /// [`crate::state::MachineState`] and the comment-preserving path in the
436    /// CLI rather than calling this on a hand-authored inventory file.
437    pub fn save(&self, cloud_dir: &Path) -> Result<()> {
438        let dir = cloud_dir.join("machines");
439        std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
440        let path = dir.join(format!("{}.toml", self.name));
441        let mut normalized = self.clone();
442        normalized.normalize();
443        let s = toml::to_string_pretty(&normalized)
444            .with_context(|| format!("serializing machine {}", self.name))?;
445        std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
446    }
447}
448
449/// Host of an `http://host:port` URL iff it is a mesh (headscale) IPv4 in the
450/// `100.64.0.0/10` CGNAT range. String-level rather than URL-parsed: the
451/// inventory format is stable and this crate carries no URL dependency (same
452/// reasoning as `fleet_metrics::extract_host` and
453/// `hub::coordinator::is_loopback_url`).
454fn mesh_ipv4_from_url(url: &str) -> Option<&str> {
455    let after_scheme = url.split("://").nth(1).unwrap_or(url);
456    let host = after_scheme.split(['/', ':']).next()?;
457    let ip: std::net::Ipv4Addr = host.parse().ok()?;
458    let [a, b, ..] = ip.octets();
459    // 100.64.0.0/10 ⇒ first octet 100, second octet 64..=127.
460    (a == 100 && (64..=127).contains(&b)).then_some(host)
461}
462
463/// Declared **reach** for a BYO `static` node (no provider API). Lives under
464/// `[connect]` in the machine TOML.
465///
466/// Reach only — how the camp gets to the box. *Permission* is a separate axis
467/// that belongs to cheers' scopes (W295 §"Deliberately deferred"); the two
468/// collapse in practice today (mesh membership grants everything) and the data
469/// model must not fuse them, so do not add an authorization field here.
470///
471/// `address` and `ssh` stay whole, literal, operator-authored strings even
472/// though their values often *look* derived. They are not: us-west-001 dials
473/// SSH over its public IP while us-west-002 was deliberately repointed at its
474/// tailnet IP (R608-F10) precisely because the LAN address is unreachable
475/// off-LAN. Decomposing them into user + host and recomposing would silently
476/// undo per-machine decisions like that one. `yubaba` is the field that *was*
477/// derived — mesh IP plus a fixed port, rewritten by mesh-join — so that is
478/// where R707-T1 cut.
479#[derive(Debug, Clone, Serialize, Deserialize)]
480#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
481pub struct ConnectSpec {
482    /// Reachable IPv4/host for the box, e.g. `"45.32.194.254"`. Declared: which
483    /// of a machine's several addresses the camp should use is an operator
484    /// choice (public IP vs. LAN IP vs. tailnet IP).
485    pub address: String,
486    /// SSH target the camp dials for bootstrap + (pre-mesh) tunneled deploys,
487    /// e.g. `"root@45.32.194.254"` or `"struc@100.64.0.4"`. Uses the operator's
488    /// `~/.ssh/yah` key. Declared, whole — see the type doc.
489    pub ssh: String,
490    /// Port yubaba listens on. Declared reach; defaults to 7443 when omitted,
491    /// which is every machine in the fleet today. Composed with the *observed*
492    /// [`MachineRegistration::mesh_ipv4`] by [`MachineConfig::yubaba_url`].
493    #[serde(default, skip_serializing_if = "Option::is_none")]
494    pub yubaba_port: Option<u16>,
495    /// Explicit yubaba base URL, overriding the composed form.
496    ///
497    /// Two live uses, both genuine declarations: a pre-mesh node saying
498    /// `"http://127.0.0.1:7443"` — "I have no mesh address; reach me through
499    /// the SSH tunnel to `ssh`" — and any node whose yubaba is not at
500    /// `mesh_ipv4:port`. A URL here whose host *is* a mesh IP is the
501    /// pre-R707-T1 shape; [`MachineConfig::normalize`] lifts it into
502    /// `[registration].mesh_ipv4` and clears this field so the two cannot
503    /// drift apart.
504    #[serde(default, skip_serializing_if = "Option::is_none")]
505    pub yubaba: Option<String>,
506}
507
508/// Default yubaba listen port, used when `[connect].yubaba_port` is omitted.
509pub const DEFAULT_YUBABA_PORT: u16 = 7443;
510
511impl ConnectSpec {
512    /// Declared yubaba port, defaulting to [`DEFAULT_YUBABA_PORT`].
513    pub fn yubaba_port(&self) -> u16 {
514        self.yubaba_port.unwrap_or(DEFAULT_YUBABA_PORT)
515    }
516}
517
518#[derive(Debug, Clone, Serialize, Deserialize)]
519#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
520pub struct BucketSpec {
521    pub name: String,
522    pub public_read: bool,
523}
524
525/// Per-camp mirror declaration from `.yah/cloud/mirrors/<id>/mirror.toml`
526/// (folder form) or the legacy `.yah/cloud/mirrors/<id>.toml` (flat form).
527///
528/// The folder form is preferred for new mirrors so that per-mirror secrets
529/// and override files can sit next to `mirror.toml` without polluting the
530/// top-level `mirrors/` directory.
531#[derive(Debug, Clone, Serialize, Deserialize)]
532pub struct LegacyMirrorConfig {
533    /// Logical camp name this mirror hosts, e.g. `"yah"` or `"noisetable"`.
534    ///
535    /// Serialised as `camp`; accepts the legacy `rig` spelling for files that
536    /// predate the R137 rig→camp rename (one-time migration: `sed -i ''
537    /// 's/^rig = /camp = /' ~/.yah/cloud/mirrors/*.toml`).
538    #[serde(rename = "camp", alias = "rig")]
539    pub camp: String,
540    pub regions: Vec<String>,
541    /// Workload names deployed as part of this mirror (references `workloads/<name>.toml`).
542    /// Renamed from `services` in R092-F1; use `yah cloud config migrate-services-to-workloads`
543    /// on repos that still have the old `services/` layout.
544    #[serde(alias = "services")]
545    pub workloads: Vec<String>,
546    /// Base domain for Cloudflare-fronted services on this mirror's machines.
547    /// Combined with the machine's `location` to build virtual-host names:
548    /// e.g. `cloud_domain = "cloud.noisetable.example"` on machine in location
549    /// `pdx` → Caddyfile site address `pdx.cloud.noisetable.example`.
550    /// Optional: if unset the Caddyfile falls back to `:port` listeners.
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub cloud_domain: Option<String>,
553}
554
555/// Error from loading or validating a single workload TOML file.
556#[derive(Debug, Error)]
557pub enum WorkloadConfigError {
558    #[error("reading {path}: {source}")]
559    Io {
560        path: String,
561        source: std::io::Error,
562    },
563    #[error("parsing {path}: {source}")]
564    Toml {
565        path: String,
566        source: toml::de::Error,
567    },
568    #[error("invalid WorkloadSpec in {path}: {source}")]
569    Shape {
570        path: String,
571        source: validate::ShapeError,
572    },
573}
574
575/// A workload declaration loaded from `.yah/cloud/workloads/<name>.toml`.
576///
577/// Each file is the human-authored TOML serialization of a [`WorkloadSpec`].
578/// On load, the spec is validated against the shape layer; failures surface as
579/// a [`CloudConfigError::Workload`] with the file path and field path.
580#[derive(Debug, Clone, Serialize, Deserialize)]
581pub struct WorkloadConfig {
582    /// The validated spec.
583    #[serde(flatten)]
584    pub spec: WorkloadSpec,
585}
586
587impl WorkloadConfig {
588    /// Persist to `<cloud_dir>/workloads/<name>.toml`, creating the dir if needed.
589    pub fn save(&self, cloud_dir: &Path) -> Result<()> {
590        let dir = cloud_dir.join("workloads");
591        std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
592        let path = dir.join(format!("{}.toml", self.spec.name));
593        let s = toml::to_string_pretty(self)
594            .with_context(|| format!("serializing workload {}", self.spec.name))?;
595        std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
596    }
597}
598
599/// Error surfaced by [`CloudConfig::load`] when a workload TOML fails validation.
600#[derive(Debug, Error)]
601pub enum CloudConfigError {
602    #[error(transparent)]
603    Anyhow(#[from] anyhow::Error),
604    #[error("workload validation failed: {0}")]
605    Workload(WorkloadConfigError),
606}
607
608/// Mirror-to-machine assignment table from `.yah/cloud/topology.toml`.
609///
610/// Declares which logical mirror names are assigned to which machines.
611/// This is the source-canonical placement until yubaba raft observes it
612/// (per the migration tracker in the arch doc).
613#[derive(Debug, Clone, Serialize, Deserialize, Default)]
614pub struct TopologyConfig {
615    /// Mirror→machine assignments.
616    #[serde(default)]
617    pub assignments: Vec<MirrorAssignment>,
618    /// Declared buckets, logged by `yah cloud bucket create`.
619    /// Source-canonical until yubaba raft observes actual placement.
620    #[serde(default, skip_serializing_if = "Vec::is_empty")]
621    pub buckets: Vec<BucketLogEntry>,
622}
623
624impl TopologyConfig {
625    /// Load from a `topology.toml` file, returning `Default` when absent.
626    pub fn load(path: &Path) -> Result<Self> {
627        if !path.exists() {
628            return Ok(Self::default());
629        }
630        let s =
631            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
632        toml::from_str(&s).with_context(|| format!("parsing {}", path.display()))
633    }
634
635    /// Persist to `topology.toml`, creating parent dirs if needed.
636    pub fn save(&self, path: &Path) -> Result<()> {
637        if let Some(parent) = path.parent() {
638            std::fs::create_dir_all(parent)
639                .with_context(|| format!("creating {}", parent.display()))?;
640        }
641        let s = toml::to_string_pretty(self).context("serializing topology")?;
642        std::fs::write(path, s).with_context(|| format!("writing {}", path.display()))
643    }
644
645    /// Find a declared bucket by name.
646    pub fn bucket_by_name(&self, name: &str) -> Option<&BucketLogEntry> {
647        self.buckets.iter().find(|b| b.name == name)
648    }
649
650    /// Find a mutable declared bucket by name.
651    pub fn bucket_by_name_mut(&mut self, name: &str) -> Option<&mut BucketLogEntry> {
652        self.buckets.iter_mut().find(|b| b.name == name)
653    }
654
655    /// Returns true if the bucket is declared as cross-machine (no owning machine).
656    pub fn is_cross_machine_bucket(&self, name: &str) -> bool {
657        self.buckets
658            .iter()
659            .any(|b| b.name == name && b.machine.is_none())
660    }
661}
662
663/// One mirror→machine placement entry in `topology.toml`.
664#[derive(Debug, Clone, Serialize, Deserialize)]
665pub struct MirrorAssignment {
666    /// Logical mirror name, e.g. `"noisetable-pdx"`.
667    pub mirror: String,
668    /// Machine that hosts this mirror, e.g. `"noisetable-pdx-1"`.
669    pub machine: String,
670}
671
672/// A bucket declaration logged in `topology.toml` by `yah cloud bucket create`.
673#[derive(Debug, Clone, Serialize, Deserialize)]
674pub struct BucketLogEntry {
675    pub name: String,
676    /// Machine that owns this bucket. `None` marks it as cross-machine
677    /// (no single-machine ownership; requires an explicit declaration in
678    /// `topology.toml` before `yah cloud bucket create` will proceed without
679    /// `--machine`).
680    #[serde(default, skip_serializing_if = "Option::is_none")]
681    pub machine: Option<String>,
682    /// Logical location of the bucket, e.g. `"pdx"`.
683    pub location: String,
684    /// Current declared policy: `"private"` | `"public-read"` | `"signed-only"`.
685    #[serde(default = "default_bucket_policy")]
686    pub policy: String,
687}
688
689fn default_bucket_policy() -> String {
690    "private".to_string()
691}
692
693/// Per-service config from `.yah/cloud/services/<name>.toml`.
694///
695/// **Deprecated.** The `services/` layout was replaced by `workloads/` in R092-F1.
696/// Kept to allow in-place reads for repos that haven't migrated yet; use
697/// `yah cloud config migrate-services-to-workloads` to upgrade.
698#[derive(Debug, Clone, Serialize, Deserialize)]
699pub struct LegacyServiceConfig {
700    pub name: String,
701    pub image: String,
702    pub version: String,
703    #[serde(default)]
704    pub env: HashMap<String, String>,
705    #[serde(default)]
706    pub ports: Vec<PortMapping>,
707    #[serde(default)]
708    pub mesh_only: bool,
709    /// Network interface this service binds to exclusively (e.g. `"tailscale0"`).
710    ///
711    /// When set the compose renderer emits `network_mode: "host"` and the
712    /// service is NOT joined to the shared compose bridge network. The service
713    /// process must bind its listen socket to the named interface's IP — for
714    /// Postgres this means setting `POSTGRES_LISTEN_ADDRESSES` to the node's
715    /// `tailscale ip --4` output at first boot. See [`crate::mesh_service`] for
716    /// the standard pg_hba.conf snippet and ufw rules to pair with this field.
717    #[serde(default, skip_serializing_if = "Option::is_none")]
718    pub bind_interface: Option<String>,
719
720    /// Tenant this service belongs to (W206 isolation axis). Absent in the
721    /// service TOML → [`TenantId::singleton`], keeping single-tenant machines
722    /// on one shared compose network. When a machine hosts services from two
723    /// or more distinct tenants, the compose renderer (R558-T2) splits them
724    /// into per-tenant `<tenant>-<tier>` networks so cross-tenant stacks on the
725    /// same host are not bridged together.
726    #[serde(default = "TenantId::singleton")]
727    pub tenant: TenantId,
728}
729
730#[derive(Debug, Clone, Serialize, Deserialize)]
731pub struct PortMapping {
732    pub host: u16,
733    pub container: u16,
734}
735
736/// A loaded service plus its per-environment mirrors.
737///
738/// Wraps the `service.toml` body and the directory of `mirrors/<env>.toml`
739/// files that project the service onto concrete infra.
740#[derive(Debug, Clone, Serialize, Deserialize)]
741pub struct ServiceWithMirrors {
742    pub service: ServiceConfig,
743    /// Mirrors keyed by environment name (file stem of `mirrors/<env>.toml`).
744    pub mirrors: BTreeMap<String, MirrorConfig>,
745    /// Transform recipe names keyed by component id. Populated from each
746    /// static-asset component's `workload.toml` at load time — not stored
747    /// in service.toml. Only present for components that declare
748    /// `[asset.derive.transform] recipe = "..."`.
749    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
750    pub component_transform_recipes: BTreeMap<String, String>,
751}
752
753/// All cloud config loaded from a workspace root (the parent of `.yah/`).
754///
755/// Reads two trees:
756/// - `.yah/infra/` — `machines/`, `providers/`
757/// - `.yah/services/<svc>/` — `service.toml` + `mirrors/<env>.toml`
758///
759/// Pre-R215 fields (`legacy_mirrors`, `legacy_services`, `workloads`,
760/// `topology`) are still populated from `.yah/cloud/` when present so
761/// pre-R215 callers (compose.rs, bucket commands) keep compiling — they
762/// just see empty collections in a post-B1 workspace where the legacy
763/// data was deleted. These fields are scheduled for removal in B3-T3.
764#[derive(Debug)]
765pub struct CloudConfig {
766    /// Workspace root that was loaded — useful for path-resolving
767    /// component references on a [`ServiceComponent`].
768    pub workspace_root: std::path::PathBuf,
769
770    // ─── R215+ tree ────────────────────────────────────────────────────────
771    /// `.yah/infra/machines/<name>.toml`
772    pub machines: Vec<MachineConfig>,
773    /// `.yah/infra/providers/<id>.toml`
774    pub providers: Vec<ProviderConfig>,
775    /// Provenance for every entry in `machines` that came from a linked
776    /// `.yah/infra/sources.toml` source rather than this camp's own
777    /// `.yah/infra/machines/` (R615-F2 / W274). Keyed by
778    /// [`MachineConfig::name`]; a name absent here is camp-local. Empty from
779    /// [`CloudConfig::load_from_config_dir`] — see its doc for why sources
780    /// don't apply to multi-root sibling trees.
781    pub machine_origins: BTreeMap<String, InfraOrigin>,
782    /// Same as [`machine_origins`](Self::machine_origins), keyed by
783    /// [`ProviderConfig::id`].
784    pub provider_origins: BTreeMap<String, InfraOrigin>,
785    /// `.yah/services/<svc>/` — service.toml plus mirrors/<env>.toml.
786    pub services: BTreeMap<String, ServiceWithMirrors>,
787    /// `.yah/domains/<name>.toml` — public-facing routing manifests
788    /// (R347). Single file per domain; no nested per-env tree because
789    /// domains themselves aren't projected onto infra — they describe
790    /// how a Worker bundle ingresses requests onto services.
791    pub domains: BTreeMap<String, DomainConfig>,
792
793    // ─── Pre-R215 legacy (slated for removal in B3-T3) ────────────────────
794    /// Legacy mirrors from `.yah/cloud/mirrors/`.
795    pub legacy_mirrors: Vec<LegacyMirrorConfig>,
796    /// Workloads from `.yah/cloud/workloads/*.toml` (R092-F1 schema).
797    pub workloads: Vec<WorkloadConfig>,
798    /// Topology from `.yah/cloud/topology.toml` (mirror→machine assignments).
799    pub topology: TopologyConfig,
800    /// Legacy services from `.yah/cloud/services/*.toml` (pre-R092 layout).
801    pub legacy_services: Vec<LegacyServiceConfig>,
802}
803
804impl CloudConfig {
805    /// Load all cloud config rooted at `workspace_root` (the parent of `.yah/`).
806    ///
807    /// Reads the R215+ tree (`.yah/infra/`, `.yah/services/<svc>/`) eagerly
808    /// and the pre-R215 `.yah/cloud/` tree opportunistically. Returns `Err`
809    /// immediately if any TOML fails to parse or a workload TOML fails
810    /// shape validation; the error includes the file path and field path.
811    ///
812    /// Cross-ref validation runs after both trees finish loading: every
813    /// `mirror.providers.X.use = "<id>"` must resolve to a real provider
814    /// declared under `.yah/infra/providers/`.
815    pub fn load(workspace_root: &Path) -> Result<Self> {
816        let mut providers = load_providers(&crate::paths::providers_dir(workspace_root))?;
817        let services = load_services(&crate::paths::services_dir(workspace_root), workspace_root)?;
818        let domains = load_domains(&crate::paths::domains_dir(workspace_root))?;
819
820        Self::cross_ref_validate(&providers, &services, &domains)?;
821
822        // Legacy `.yah/cloud/` reads — empty in post-B1 workspaces. Wrapped in
823        // a helper so a missing tree is silent (no error, no warning).
824        let cloud_dir = crate::paths::legacy_cloud_dir(workspace_root);
825        let (legacy_machines, legacy_mirrors, legacy_workloads, topology, legacy_services) =
826            if cloud_dir.exists() {
827                (
828                    load_dir::<MachineConfig>(cloud_dir.join("machines"))?,
829                    load_mirrors(cloud_dir.join("mirrors"))?,
830                    load_workloads(cloud_dir.join("workloads"))?,
831                    load_topology(cloud_dir.join("topology.toml"))?,
832                    load_dir::<LegacyServiceConfig>(cloud_dir.join("services"))?,
833                )
834            } else {
835                Default::default()
836            };
837
838        // Workloads come from `.yah/infra/workloads/` (R215+). R568-T7: before
839        // that path was read here, this field was populated *only* from the
840        // legacy tree above — which R222-B1 emptied — so `cfg.workload(name)`
841        // resolved nothing in every post-R215 camp and `yah cloud workload
842        // deploy` could not find any declaration at all. The bug survived
843        // because the only workloads ever deployed were forge/QED runs, which
844        // build their spec in memory and never come through here. Same
845        // dedupe-by-name shape as machines below: R215+ wins.
846        let mut workloads = load_workloads(crate::paths::workloads_dir(workspace_root))?;
847        let workload_names: std::collections::HashSet<String> =
848            workloads.iter().map(|w| w.spec.name.clone()).collect();
849        for w in legacy_workloads {
850            if !workload_names.contains(&w.spec.name) {
851                workloads.push(w);
852            }
853        }
854
855        // Machines come from `.yah/infra/machines/` (R215+); the pre-R215
856        // tree shouldn't have any since B1 moved them, but if it does we
857        // dedupe by name (R215 wins).
858        let mut machines = load_dir::<MachineConfig>(crate::paths::machines_dir(workspace_root))?;
859        let names: std::collections::HashSet<String> =
860            machines.iter().map(|m| m.name.clone()).collect();
861        for m in legacy_machines {
862            if !names.contains(&m.name) {
863                machines.push(m);
864            }
865        }
866
867        // R615-F2: overlay every linked `.yah/infra/sources.toml` source's
868        // machines/providers UNDER what's already loaded above, so camp-local
869        // (including the legacy-tree entries just merged in) always wins on a
870        // name collision. `SourcesConfig::load` itself never touches the
871        // network — git sources are read from `yah infra sync`'s cache
872        // (R615-T3), so this call keeps `load()`'s whole offline contract.
873        let sources = SourcesConfig::load(&crate::paths::infra_dir(workspace_root))?;
874        let mut machine_origins = BTreeMap::new();
875        let mut provider_origins = BTreeMap::new();
876        overlay_infra_sources(
877            workspace_root,
878            &sources,
879            &mut machines,
880            &mut providers,
881            &mut machine_origins,
882            &mut provider_origins,
883        );
884
885        Ok(Self {
886            workspace_root: workspace_root.to_path_buf(),
887            machines,
888            providers,
889            machine_origins,
890            provider_origins,
891            services,
892            domains,
893            legacy_mirrors,
894            workloads,
895            topology,
896            legacy_services,
897        })
898    }
899
900    /// Load the R215+ tree (`infra/`, `services/`, `domains/`) rooted at an
901    /// arbitrary config directory instead of the hardcoded `.yah/`. This is the
902    /// building block for multi-root deployments (W206 config layout (b), sibling
903    /// `.noisetable/` trees) — see [`crate::multi_root`]. Part of R558-F4.
904    ///
905    /// `config_dir` is the `.X/` directory itself (e.g. `<parent>/.noisetable`);
906    /// `workspace_root` remains the camp dir (the config dir's parent) so a
907    /// component's `path` reference resolves against the same tree the classic
908    /// [`CloudConfig::load`] uses. The legacy `.yah/cloud/` reads are skipped —
909    /// multi-root deployments are post-R215 by construction — so `legacy_*`,
910    /// `workloads`, and `topology` come back empty. Machines are read from
911    /// `config_dir/infra/machines` directly (sibling trees declare their own
912    /// inventory or none).
913    ///
914    /// R615-F2 decision, explicit rather than silent: **sources.toml overlay
915    /// does NOT apply here.** This function
916    /// exists specifically because a multi-root sibling tree (W206 layout
917    /// (b), e.g. `.noisetable/`) is a *second config root inside the same
918    /// camp*, not a second camp — `config_dir` is already wherever the
919    /// caller decided this tree's infra lives, and `.yah/infra/sources.toml`
920    /// (singular, tied to `paths::infra_dir(workspace_root)`) has no
921    /// well-defined meaning for an arbitrary `config_dir` that isn't that
922    /// path. A sibling tree that wants borrowed infra declares its own
923    /// `sources.toml` under whichever root actually calls
924    /// [`CloudConfig::load`] for it; `machine_origins`/`provider_origins`
925    /// come back empty here, not wrong — there is nothing to overlay.
926    pub fn load_from_config_dir(config_dir: &Path, workspace_root: &Path) -> Result<Self> {
927        let providers = load_providers(&config_dir.join("infra").join("providers"))?;
928        let services = load_services(&config_dir.join("services"), workspace_root)?;
929        let domains = load_domains(&config_dir.join("domains"))?;
930
931        Self::cross_ref_validate(&providers, &services, &domains)?;
932
933        let machines = load_dir::<MachineConfig>(config_dir.join("infra").join("machines"))?;
934
935        Ok(Self {
936            workspace_root: workspace_root.to_path_buf(),
937            machines,
938            providers,
939            machine_origins: BTreeMap::new(),
940            provider_origins: BTreeMap::new(),
941            services,
942            domains,
943            legacy_mirrors: vec![],
944            workloads: vec![],
945            topology: TopologyConfig::default(),
946            legacy_services: vec![],
947        })
948    }
949
950    /// Cross-reference validation shared by [`CloudConfig::load`] and
951    /// [`CloudConfig::load_from_config_dir`]: every mirror `providers.X.use =
952    /// "<id>"` must resolve to a declared provider, and every domain route's
953    /// `component = "<service>/<component-id>"` must resolve to a real component.
954    fn cross_ref_validate(
955        providers: &[ProviderConfig],
956        services: &BTreeMap<String, ServiceWithMirrors>,
957        domains: &BTreeMap<String, DomainConfig>,
958    ) -> Result<()> {
959        // Mirror `use = "<id>"` slots must resolve to a declared provider.
960        let provider_ids: std::collections::HashSet<&str> =
961            providers.iter().map(|p| p.id.as_str()).collect();
962        for (svc_name, svc) in services {
963            for (env, mirror) in &svc.mirrors {
964                for (slot, body) in &mirror.providers {
965                    if let Some(id) = body.provider_id() {
966                        if !provider_ids.contains(id) {
967                            anyhow::bail!(
968                                "services/{svc_name}/mirrors/{env}.toml: \
969                                 providers.{slot}.use = \"{id}\" — no such provider; \
970                                 declare it at infra/providers/{id}.toml"
971                            );
972                        }
973                    }
974                }
975            }
976        }
977
978        // Every domain route's `component = "<service>/<component-id>"` must
979        // resolve to a real component.
980        for (dom_name, dom) in domains {
981            for (idx, route) in dom.routes.iter().enumerate() {
982                let Some(component_ref) = route.mode.component() else {
983                    continue; // redirects don't reference components
984                };
985                let Some((svc_name, comp_id)) = split_component_ref(component_ref) else {
986                    anyhow::bail!(
987                        "domains/{dom_name}.toml: routes[{idx}].component = \
988                         \"{component_ref}\" — expected \"<service>/<component-id>\""
989                    );
990                };
991                let Some(svc) = services.get(svc_name) else {
992                    anyhow::bail!(
993                        "domains/{dom_name}.toml: routes[{idx}].component = \
994                         \"{component_ref}\" — no such service \"{svc_name}\" \
995                         under services/"
996                    );
997                };
998                if !svc.service.components.iter().any(|c| c.id == comp_id) {
999                    anyhow::bail!(
1000                        "domains/{dom_name}.toml: routes[{idx}].component = \
1001                         \"{component_ref}\" — service \"{svc_name}\" has no \
1002                         component with id \"{comp_id}\""
1003                    );
1004                }
1005            }
1006        }
1007        Ok(())
1008    }
1009
1010    /// Look up a domain manifest by name (file stem under `.yah/domains/`).
1011    pub fn domain(&self, name: &str) -> Option<&DomainConfig> {
1012        self.domains.get(name)
1013    }
1014
1015    pub fn machine(&self, name: &str) -> Option<&MachineConfig> {
1016        self.machines.iter().find(|m| m.name == name)
1017    }
1018
1019    /// Look up a provider by id (matches `provider.id`, not the file stem).
1020    pub fn provider(&self, id: &str) -> Option<&ProviderConfig> {
1021        self.providers.iter().find(|p| p.id == id)
1022    }
1023
1024    /// Look up a service by name (matches `service.toml`'s `name` field).
1025    pub fn service(&self, name: &str) -> Option<&ServiceWithMirrors> {
1026        self.services.get(name)
1027    }
1028
1029    /// Look up a legacy mirror by camp name (pre-R215 .yah/cloud/mirrors/).
1030    pub fn legacy_mirror(&self, camp: &str) -> Option<&LegacyMirrorConfig> {
1031        self.legacy_mirrors.iter().find(|m| m.camp == camp)
1032    }
1033
1034    pub fn workload(&self, name: &str) -> Option<&WorkloadConfig> {
1035        self.workloads.iter().find(|w| w.spec.name == name)
1036    }
1037
1038    /// F16 placement v1: the first machine satisfying every hard axis of `req`
1039    /// (region/zone/provider membership + mesh_tags superset). Declaration order
1040    /// in `.yah/infra/machines/` decides ties — deterministic-greedy, no
1041    /// backtracking. A fully-unconstrained `req` matches the first machine.
1042    ///
1043    /// Fails loud with the constraint summary and the candidate machine names
1044    /// when nothing matches, so `yah cloud apply` surfaces *why* placement
1045    /// failed instead of a silent empty set.
1046    pub fn resolve_machine(&self, req: &RequiredSpec) -> Result<&MachineConfig> {
1047        self.machines
1048            .iter()
1049            .find(|m| req.matches(m))
1050            .ok_or_else(|| {
1051                let candidates = if self.machines.is_empty() {
1052                    "(no machines declared under .yah/infra/machines/)".to_string()
1053                } else {
1054                    self.machines
1055                        .iter()
1056                        .map(|m| m.name.as_str())
1057                        .collect::<Vec<_>>()
1058                        .join(", ")
1059                };
1060                anyhow::anyhow!(
1061                    "no candidates matching {} — declared machines: {candidates}",
1062                    req.describe()
1063                )
1064            })
1065    }
1066
1067    /// F16 placement: first machine whose `mesh_tags` is a superset of
1068    /// `required`. Declaration order in `.yah/infra/machines/` decides ties.
1069    /// Empty `required` matches the first machine; callers should treat
1070    /// empty-required as "no constraint" and skip this lookup.
1071    ///
1072    /// Back-compat thin wrapper over [`CloudConfig::resolve_machine`] for the
1073    /// mesh-tags-only call sites that predate the topology axes.
1074    pub fn resolve_machine_by_mesh_tags(&self, required: &[String]) -> Option<&MachineConfig> {
1075        let req = RequiredSpec {
1076            mesh_tags: required.to_vec(),
1077            ..Default::default()
1078        };
1079        self.resolve_machine(&req).ok()
1080    }
1081
1082    /// Admission: resolve the target machine for a remote [`WorkloadSpec`],
1083    /// honoring the R594 mesh-tag node-selector annotation
1084    /// (`velveteen_exec::remote::NODE_SELECTOR_MESH_TAGS_ANNOTATION` =
1085    /// `yah.node-selector.mesh-tags`, comma-joined).
1086    ///
1087    /// The producer side (`velveteen_exec::remote::build_workload_spec`, R594) writes
1088    /// `TaskLocation::RemoteAny.mesh_tags` — e.g. `[tag:build-worker, tier:x86]`
1089    /// from [`qed::platform::build_worker_mesh_tags`] — into the workload's
1090    /// annotations. This is the consumer: candidates are restricted to machines
1091    /// whose `mesh_tags` are a **superset** of the requested set, so an amd64
1092    /// build lands on the `tier:x86` build-worker (us-west-002) and an arm64
1093    /// build on a `tier:arm` Pi5. Declaration order in `.yah/infra/machines/`
1094    /// breaks ties.
1095    ///
1096    /// An absent or empty annotation means "no mesh-tag constraint" — pre-R594
1097    /// behavior (any node), matching [`RequiredSpec::is_unconstrained`].
1098    ///
1099    /// This is the single admission seam: R572-F5 extends it with the capacity
1100    /// floor (workload request fits node allocatable−committed) and
1101    /// repel-unless-tolerate taints by enriching [`RequiredSpec::matches`] /
1102    /// [`Self::resolve_machine`]. Do not fork a second selector.
1103    pub fn admit_workload(&self, ws: &WorkloadSpec) -> Result<&MachineConfig> {
1104        let req = RequiredSpec {
1105            mesh_tags: node_selector_mesh_tags(ws),
1106            // R572-F5: capacity floor from the workload's resource request.
1107            memory_mb: ws.resources.memory_mb,
1108            cpu_millis: ws.resources.cpu_millis,
1109            // R572-F5: taint repulsion derived from the workload's effective archetype.
1110            repel_archetype: Some(ws.effective_archetype()),
1111            // R572-F5: taint affinity from the requires-taint annotation.
1112            requires_taint: ws.requires_taint().map(str::to_owned),
1113            ..Default::default()
1114        };
1115        self.resolve_machine(&req)
1116    }
1117}
1118
1119/// Parse the R594 mesh-tag node-selector off a workload's annotations into the
1120/// requested tag set. Absent annotation or empty value ⇒ empty vec ("no
1121/// constraint"). Whitespace around each comma-separated tag is trimmed and
1122/// empty segments are dropped, so `"tag:build-worker, tier:x86"` and
1123/// `"tag:build-worker,tier:x86"` parse identically.
1124pub fn node_selector_mesh_tags(ws: &WorkloadSpec) -> Vec<String> {
1125    ws.annotations
1126        .get(velveteen_exec::remote::NODE_SELECTOR_MESH_TAGS_ANNOTATION)
1127        .map(|v| {
1128            v.split(',')
1129                .map(str::trim)
1130                .filter(|s| !s.is_empty())
1131                .map(String::from)
1132                .collect()
1133        })
1134        .unwrap_or_default()
1135}
1136
1137/// Load every `.yah/infra/providers/*.toml` into a [`ProviderConfig`] list.
1138/// Missing directory → empty list.
1139fn load_providers(dir: &Path) -> Result<Vec<ProviderConfig>> {
1140    if !dir.exists() {
1141        return Ok(vec![]);
1142    }
1143    let mut items = vec![];
1144    let mut entries: Vec<_> = std::fs::read_dir(dir)
1145        .with_context(|| format!("reading {}", dir.display()))?
1146        .filter_map(|e| e.ok())
1147        .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
1148        .collect();
1149    entries.sort_by_key(|e| e.file_name());
1150    for entry in entries {
1151        items.push(ProviderConfig::load(&entry.path())?);
1152    }
1153    Ok(items)
1154}
1155
1156/// Map legacy mirror file stems to their canonical tier names.
1157///
1158/// Canonical tiers: `dev` / `pond` / `cloud` / `ha`.
1159/// Legacy stems pre-R362: `local` (dev tier), `local-sim` / `sim` (pond tier), `prod` (cloud tier).
1160/// Both forms are accepted; canonical names are preferred for new files.
1161pub fn canonical_tier(stem: &str) -> &str {
1162    match stem {
1163        "local" => "dev",
1164        "local-sim" | "sim" => "pond",
1165        "prod" => "cloud",
1166        other => other,
1167    }
1168}
1169
1170/// Walk `.yah/services/<svc>/` for every service and its mirrors.
1171/// Missing directory → empty map. Mirror file stems are normalized to canonical
1172/// tier names via [`canonical_tier`] so callers always see `dev/pond/cloud/ha`.
1173fn load_services(
1174    dir: &Path,
1175    workspace_root: &Path,
1176) -> Result<BTreeMap<String, ServiceWithMirrors>> {
1177    if !dir.exists() {
1178        return Ok(BTreeMap::new());
1179    }
1180    let mut out = BTreeMap::new();
1181    let mut entries: Vec<_> = std::fs::read_dir(dir)
1182        .with_context(|| format!("reading {}", dir.display()))?
1183        .filter_map(|e| e.ok())
1184        .filter(|e| e.path().is_dir())
1185        .collect();
1186    entries.sort_by_key(|e| e.file_name());
1187
1188    for entry in entries {
1189        let svc_dir = entry.path();
1190        let service_toml = svc_dir.join("service.toml");
1191        if !service_toml.exists() {
1192            // Skip directories without a service.toml — leaves room for
1193            // future siblings (e.g. `secrets/`, `README.md`) without
1194            // triggering false-positive parse errors.
1195            continue;
1196        }
1197        let service = ServiceConfig::load(&service_toml)?;
1198        let mut mirrors = BTreeMap::new();
1199        let mirrors_dir = svc_dir.join("mirrors");
1200        if mirrors_dir.exists() {
1201            let mut menv: Vec<_> = std::fs::read_dir(&mirrors_dir)
1202                .with_context(|| format!("reading {}", mirrors_dir.display()))?
1203                .filter_map(|e| e.ok())
1204                .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
1205                .collect();
1206            menv.sort_by_key(|e| e.file_name());
1207            for m in menv {
1208                let path = m.path();
1209                let stem = path
1210                    .file_stem()
1211                    .and_then(|s| s.to_str())
1212                    .unwrap_or("")
1213                    .to_string();
1214                let tier = canonical_tier(&stem).to_string();
1215                // Last-write wins if both legacy and canonical forms coexist
1216                // (e.g. local-sim.toml + pond.toml). Sort order ensures the
1217                // canonical file (pond.toml) wins because 'p' > 'l'.
1218                mirrors.insert(tier, MirrorConfig::load(&path)?);
1219            }
1220        }
1221        let mut component_transform_recipes = BTreeMap::new();
1222        for component in &service.components {
1223            if component.kind == "static-asset" {
1224                if let Some(recipe) =
1225                    read_component_transform_recipe(workspace_root, &component.path)
1226                {
1227                    component_transform_recipes.insert(component.id.clone(), recipe);
1228                }
1229            }
1230        }
1231        out.insert(
1232            service.name.clone(),
1233            ServiceWithMirrors {
1234                service,
1235                mirrors,
1236                component_transform_recipes,
1237            },
1238        );
1239    }
1240    Ok(out)
1241}
1242
1243/// Read the first transform recipe name from a component's `workload.toml`.
1244/// Returns `None` when the file is absent or has no `[asset.derive.transform]`
1245/// section. Best-effort — parse failures are silently ignored so a malformed
1246/// workload.toml doesn't abort the entire service catalog load.
1247fn read_component_transform_recipe(workspace_root: &Path, component_path: &str) -> Option<String> {
1248    let workload_path = workspace_root.join(component_path).join("workload.toml");
1249    let text = std::fs::read_to_string(&workload_path).ok()?;
1250    let value: toml::Value = toml::from_str(&text).ok()?;
1251    let assets = value.get("asset")?.as_array()?;
1252    for asset in assets {
1253        if let Some(recipe) = asset
1254            .get("derive")
1255            .and_then(|d| d.get("transform"))
1256            .and_then(|t| t.get("recipe"))
1257            .and_then(|r| r.as_str())
1258        {
1259            return Some(recipe.to_string());
1260        }
1261    }
1262    None
1263}
1264
1265/// Load every `.yah/domains/*.toml` into a [`DomainConfig`] map keyed by
1266/// file stem. Missing directory → empty map.
1267fn load_domains(dir: &Path) -> Result<BTreeMap<String, DomainConfig>> {
1268    if !dir.exists() {
1269        return Ok(BTreeMap::new());
1270    }
1271    let mut out = BTreeMap::new();
1272    let mut entries: Vec<_> = std::fs::read_dir(dir)
1273        .with_context(|| format!("reading {}", dir.display()))?
1274        .filter_map(|e| e.ok())
1275        .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
1276        .collect();
1277    entries.sort_by_key(|e| e.file_name());
1278    for entry in entries {
1279        let path = entry.path();
1280        let stem = path
1281            .file_stem()
1282            .and_then(|s| s.to_str())
1283            .unwrap_or("")
1284            .to_string();
1285        let dom = DomainConfig::load(&path)?;
1286        if dom.name != stem {
1287            anyhow::bail!(
1288                "domains/{}.toml: name = \"{}\" must match the file stem",
1289                stem,
1290                dom.name
1291            );
1292        }
1293        out.insert(dom.name.clone(), dom);
1294    }
1295    Ok(out)
1296}
1297
1298/// Load and shape-validate all `*.toml` files in `dir` as [`WorkloadConfig`].
1299fn load_workloads(dir: std::path::PathBuf) -> Result<Vec<WorkloadConfig>> {
1300    if !dir.exists() {
1301        return Ok(vec![]);
1302    }
1303    let mut items = vec![];
1304    let mut entries: Vec<_> = std::fs::read_dir(&dir)
1305        .with_context(|| format!("reading {}", dir.display()))?
1306        .filter_map(|e| e.ok())
1307        .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
1308        .collect();
1309    entries.sort_by_key(|e| e.file_name());
1310
1311    for entry in entries {
1312        let path = entry.path();
1313        let path_str = path.display().to_string();
1314        let src =
1315            std::fs::read_to_string(&path).with_context(|| format!("reading {}", path_str))?;
1316        let spec: WorkloadSpec =
1317            toml::from_str(&src).with_context(|| format!("parsing {}", path_str))?;
1318
1319        // Shape-validate before accepting into the loaded config.
1320        validate::shape(&spec)
1321            .map_err(|e| anyhow::anyhow!("workload {} failed shape validation: {e}", path_str))?;
1322
1323        items.push(WorkloadConfig { spec });
1324    }
1325    Ok(items)
1326}
1327
1328/// Load all mirror configs from the `mirrors/` directory.
1329///
1330/// Handles two layouts that may coexist:
1331/// - **Folder**: `mirrors/<id>/mirror.toml` — preferred; allows secrets and
1332///   per-mirror overrides to live next to the config file.
1333/// - **Flat**: `mirrors/<id>.toml` — legacy; still supported.
1334///
1335/// Each file is parsed as [`LegacyMirrorConfig`]. A malformed file returns an error
1336/// that includes the file path and the TOML field path + line/column, so the
1337/// caller can surface it to the user directly.
1338fn load_mirrors(dir: std::path::PathBuf) -> Result<Vec<LegacyMirrorConfig>> {
1339    if !dir.exists() {
1340        return Ok(vec![]);
1341    }
1342    let mut mirrors = vec![];
1343    let mut entries: Vec<_> = std::fs::read_dir(&dir)
1344        .with_context(|| format!("reading {}", dir.display()))?
1345        .filter_map(|e| e.ok())
1346        .collect();
1347    entries.sort_by_key(|e| e.file_name());
1348
1349    for entry in entries {
1350        let path = entry.path();
1351        if path.is_dir() {
1352            // Folder layout: mirrors/<id>/mirror.toml
1353            let mirror_toml = path.join("mirror.toml");
1354            if mirror_toml.exists() {
1355                let src = std::fs::read_to_string(&mirror_toml)
1356                    .with_context(|| format!("reading {}", mirror_toml.display()))?;
1357                let cfg: LegacyMirrorConfig = toml::from_str(&src)
1358                    .with_context(|| format!("parsing {}", mirror_toml.display()))?;
1359                mirrors.push(cfg);
1360            }
1361        } else if path.extension().map_or(false, |e| e == "toml") {
1362            // Flat layout: mirrors/<id>.toml
1363            let src = std::fs::read_to_string(&path)
1364                .with_context(|| format!("reading {}", path.display()))?;
1365            let cfg: LegacyMirrorConfig =
1366                toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
1367            mirrors.push(cfg);
1368        }
1369    }
1370    Ok(mirrors)
1371}
1372
1373/// Load `topology.toml` if it exists; return a default (empty) topology otherwise.
1374fn load_topology(path: std::path::PathBuf) -> Result<TopologyConfig> {
1375    if !path.exists() {
1376        return Ok(TopologyConfig::default());
1377    }
1378    let src =
1379        std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
1380    toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
1381}
1382
1383/// R555-S1: entries are sorted by file name before parsing, so "declaration
1384/// order in `.yah/infra/machines/` breaks ties" — the contract
1385/// [`CloudConfig::admit_workload`] documents — is actually true. `read_dir`
1386/// yields filesystem order, which is unspecified and differs between APFS and
1387/// a hashed-dir ext4; without the sort, *which* of two equally-matching nodes a
1388/// workload admits to could change when an unrelated file is added to the
1389/// directory. That was latent while each tag set had one match and became
1390/// observable the day us-west-003 joined us-west-002 on
1391/// `[tag:build-worker, tier:x86, os:linux]`. Same sort `load_providers` has
1392/// always done.
1393fn load_dir<T: for<'de> Deserialize<'de>>(dir: std::path::PathBuf) -> Result<Vec<T>> {
1394    if !dir.exists() {
1395        return Ok(vec![]);
1396    }
1397    let mut entries: Vec<_> = std::fs::read_dir(&dir)
1398        .with_context(|| format!("reading {}", dir.display()))?
1399        .collect::<std::io::Result<Vec<_>>>()
1400        .with_context(|| format!("reading {}", dir.display()))?;
1401    entries.sort_by_key(|e| e.file_name());
1402
1403    let mut items = vec![];
1404    for entry in entries {
1405        let path = entry.path();
1406        if path.extension().map_or(false, |e| e == "toml") {
1407            let src = std::fs::read_to_string(&path)
1408                .with_context(|| format!("reading {}", path.display()))?;
1409            let item: T =
1410                toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
1411            items.push(item);
1412        }
1413    }
1414    Ok(items)
1415}
1416
1417// ─── New manifest shapes (R222 B2) ───────────────────────────────────────────
1418//
1419// The post-R215 layout splits substrate from service declarations:
1420//
1421//   .yah/infra/providers/<id>.toml      → ProviderConfig
1422//   .yah/services/<svc>/service.toml    → ServiceConfig
1423//   .yah/services/<svc>/mirrors/<env>.toml → MirrorConfig
1424//
1425// CloudConfig::load still reads the legacy layout — B3 swaps in these types
1426// and removes the Legacy* shapes plus TopologyConfig.
1427
1428/// Tag for the infrastructure provider kind. Drives which fields are valid in
1429/// a [`ProviderConfig`] body or a [`MirrorProviderSlot::Inline`] block.
1430///
1431/// Two flavors:
1432/// - **Account/runtime providers** (`cloudflare`, `hetzner`, `local-container`)
1433///   live as files under `.yah/infra/providers/<id>.toml` and are referenced
1434///   from a mirror via `use = "<id>"`.
1435/// - **Inline-only providers** (`local-static`, `miniflare-container`,
1436///   `minio-container`) declare an operator-local stand-in directly inside a
1437///   mirror via `kind = "..."`. They carry no credentials and have no provider
1438///   file. The container-backed kinds ride on top of whichever
1439///   `local-container` runtime is declared in infra (orbstack/colima/docker);
1440///   the reconciler resolves the runtime at up-time.
1441#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1442#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1443#[serde(rename_all = "kebab-case")]
1444pub enum Provider {
1445    /// Cloudflare account: R2 buckets, DNS, Workers, Tunnels.
1446    Cloudflare,
1447    /// Hetzner Cloud + Object Storage account.
1448    Hetzner,
1449    /// Vultr cloud VPS — auto-provisioned via the `cloud.vps.*` Envoy
1450    /// (`VultrEnvoy`), the burst/scaling counterpart to Hetzner. Driver-backed.
1451    Vultr,
1452    /// BYO bare/static node (OVH, on-prem, anything we did NOT provision via a
1453    /// cloud API). Brought up over SSH (`stand-up-yubaba.sh` / `yah cloud
1454    /// machine bootstrap`); reach is declared in the machine's `[connect]`
1455    /// block. No create/destroy driver — placement-only.
1456    Static,
1457    /// Built-in static-file server bound to localhost. Inline-only; never
1458    /// declared as a standalone provider file because it carries no creds.
1459    LocalStatic,
1460    /// Local container runtime (orbstack/colima/docker). Configured by a
1461    /// provider file under `.yah/infra/providers/` so the discovery hints +
1462    /// runtime override sit in one place.
1463    LocalContainer,
1464    /// Dev-tier compute: the component runs as a kamaji-supervised host
1465    /// process against the operator's real workspace, no container and no
1466    /// build step per edit. Inline-only — it carries no credentials, and
1467    /// "the machine you are sitting at" is not an account to point at.
1468    /// See `reconciler::local_process`.
1469    LocalProcess,
1470    /// Containerized miniflare (workerd subprocess) fronting MinIO — the
1471    /// pond-tier stand-in for a CF Worker + R2 static surface. Inline-only;
1472    /// the reconciler spawns miniflare via the JS runtime and starts a MinIO
1473    /// container on the local-container runtime.
1474    MiniflareContainer,
1475    /// Containerized MinIO providing an S3-compatible API — the pond-tier
1476    /// stand-in for Cloudflare R2. Inline-only; the reconciler spins up the
1477    /// container on the local-container runtime and auto-creates the declared
1478    /// bucket on first up.
1479    MinioContainer,
1480    /// Dev-tier PostgreSQL — a real server speaking real pgwire on loopback,
1481    /// supervised by kamaji as the `yah-pg-dev` workload (W265, R584-F1). No
1482    /// docker daemon: the driver fetches a per-arch PostgreSQL tarball on first
1483    /// run and `initdb`s a cluster under `.yah/infra/state/dev/pg/`.
1484    ///
1485    /// Inline-only — it carries no credentials worth a provider file (the
1486    /// cluster is loopback-bound with a fixed dev password). Declared under
1487    /// [`MirrorConfig::drivers`], not `providers`:
1488    ///
1489    /// ```toml
1490    /// [drivers.pg]
1491    /// kind = "local-pg-dev"
1492    /// ```
1493    LocalPgDev,
1494}
1495
1496/// A provider account/runtime binding from `.yah/infra/providers/<id>.toml`.
1497///
1498/// The `kind` discriminator picks the schema for the remaining fields. Strict
1499/// on `kind` (unknown values are a parse error); permissive on per-kind fields
1500/// (carried as a free-form map so this loader stays stable as new fields land).
1501/// B3/B4 will tighten by introducing typed variants alongside JSON Schema.
1502#[derive(Debug, Clone, Serialize, Deserialize)]
1503#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1504pub struct ProviderConfig {
1505    pub schema_version: u32,
1506    pub id: String,
1507    pub kind: Provider,
1508    /// Reference into the OS keystore for live credentials (e.g.
1509    /// `"keystore://cloudflare/yah"`). `None` for providers that don't need
1510    /// creds (local-static, optionally local-container).
1511    #[serde(default, skip_serializing_if = "Option::is_none")]
1512    pub credentials: Option<String>,
1513    /// Kind-specific fields. Examples:
1514    /// - cloudflare: `default_zone`
1515    /// - hetzner:    `default_location`, `default_server_type`, `ssh_keys`
1516    /// - local-container: `runtime`, `discovery`
1517    #[serde(flatten)]
1518    #[cfg_attr(
1519        feature = "json-schema",
1520        schemars(with = "std::collections::BTreeMap<String, serde_json::Value>")
1521    )]
1522    pub fields: BTreeMap<String, toml::Value>,
1523}
1524
1525impl ProviderConfig {
1526    /// Parse a single `providers/<id>.toml` file.
1527    pub fn load(path: &Path) -> Result<Self> {
1528        let src =
1529            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
1530        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
1531    }
1532}
1533
1534/// An operator-facing service declaration from
1535/// `.yah/services/<svc>/service.toml`.
1536///
1537/// A service groups one or more components (a static surface, a containerized
1538/// API, an almanac…) under a single domain. Mirrors project the service onto
1539/// concrete infra; see [`MirrorConfig`].
1540#[derive(Debug, Clone, Serialize, Deserialize)]
1541#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1542pub struct ServiceConfig {
1543    pub schema_version: u32,
1544    pub name: String,
1545    pub domain: String,
1546    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1547    pub components: Vec<ServiceComponent>,
1548    /// Databases this service exposes, grouped by environment (W241). Every
1549    /// entry becomes a data-workbench / `sql_*` catalog id of the shape
1550    /// `<env>:<service>:<name>` (e.g. `pond:scrabcake:main`). Optional and
1551    /// default-empty — services without databases omit the `[db]` table
1552    /// entirely.
1553    #[serde(default, skip_serializing_if = "DbCatalog::is_empty")]
1554    pub db: DbCatalog,
1555}
1556
1557impl ServiceConfig {
1558    /// Parse a single `services/<svc>/service.toml` file.
1559    pub fn load(path: &Path) -> Result<Self> {
1560        let src =
1561            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
1562        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
1563    }
1564
1565    /// Persist to `.yah/services/<name>/service.toml`, creating the service
1566    /// directory if needed. Create-or-overwrite — the canonical replacement
1567    /// for the legacy `sites.json` write path. `workspace_root` is the camp
1568    /// dir (the parent of `.yah/`).
1569    pub fn save(&self, workspace_root: &Path) -> Result<()> {
1570        let dir = crate::paths::service_dir(workspace_root, &self.name);
1571        std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
1572        let path = crate::paths::service_toml(workspace_root, &self.name);
1573        let s = toml::to_string_pretty(self)
1574            .with_context(|| format!("serializing service {}", self.name))?;
1575        std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
1576    }
1577
1578    /// Remove `.yah/services/<name>/` and everything under it (service.toml
1579    /// plus its `mirrors/`). Returns `false` when the directory was already
1580    /// absent, so callers can distinguish "deleted" from "no-op".
1581    pub fn delete(workspace_root: &Path, name: &str) -> Result<bool> {
1582        let dir = crate::paths::service_dir(workspace_root, name);
1583        if !dir.exists() {
1584            return Ok(false);
1585        }
1586        std::fs::remove_dir_all(&dir).with_context(|| format!("removing {}", dir.display()))?;
1587        Ok(true)
1588    }
1589}
1590
1591/// A git source for a component (R561-F1, "BYO git").
1592///
1593/// When a [`ServiceComponent`] sets `git`, the component's code is NOT in this
1594/// workspace — it lives in an external repo that the reconciler shallow-clones
1595/// into a source cache before build (approach A: clone-at-reconcile, so config
1596/// load + validation stay offline). The component's `path` is then interpreted
1597/// relative to `<checkout>/<subdir>` instead of the workspace root.
1598#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1599#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1600pub struct GitSource {
1601    /// Clone URL (https or ssh) of the tenant repo.
1602    pub repo: String,
1603    /// Branch, tag, or commit SHA to check out. Defaults to `"main"`.
1604    #[serde(default = "default_git_ref")]
1605    pub r#ref: String,
1606    /// Optional sub-directory within the repo that the workspace is rooted at
1607    /// (e.g. a monorepo's `site/`). `path` is resolved relative to this.
1608    #[serde(default, skip_serializing_if = "Option::is_none")]
1609    pub subdir: Option<String>,
1610}
1611
1612fn default_git_ref() -> String {
1613    "main".to_string()
1614}
1615
1616/// How to reach an external infra root (R615-F1 / W274, "linked infra
1617/// sources"): a filesystem link to a sibling camp's live tree, or a git
1618/// checkout of an extracted infra repo.
1619#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1620#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1621#[serde(tag = "kind", rename_all = "kebab-case")]
1622pub enum InfraSourceKind {
1623    /// Filesystem link — reads the owner's live tree. The dev-loop shortcut,
1624    /// and the whole story until W274's "infra as its own repo" end-state.
1625    /// `path` is relative to *this* camp's root; infra is read from
1626    /// `<path>/.yah/infra/`.
1627    Path {
1628        path: String,
1629    },
1630    /// Git link — reused verbatim from [`GitSource`] (R561, "BYO git"),
1631    /// lifted here from "a component's code" to "a camp's infra registry."
1632    /// Loading stays offline (W274 §3): `yah infra sync` (R615-T3) is what
1633    /// clones/pulls this into `.yah/cache/infra/<owner>/`; `CloudConfig::load`
1634    /// only ever reads that cache, never the network.
1635    Git(GitSource),
1636}
1637
1638/// Write-gate for a linked [`InfraSource`] (R615-F1 / W274).
1639///
1640/// An enum, not a bool: the two states today are "borrower renders/plans but
1641/// cannot reconcile" and "this camp genuinely co-administers the shared
1642/// root," and a future read-write-with-approval tier is a third variant, not
1643/// a renamed boolean.
1644#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1645#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1646#[serde(rename_all = "kebab-case")]
1647pub enum SourceMode {
1648    /// Borrower can render and plan against the linked entries but cannot
1649    /// reconcile/mutate them — the owner remains the single manager. Default:
1650    /// a borrower is opt-in to write access, never opt-out of the safe state.
1651    #[default]
1652    ReadOnly,
1653    /// Escape hatch for a camp that genuinely co-administers a shared root.
1654    Manage,
1655}
1656
1657/// One `[[source]]` entry in `.yah/infra/sources.toml` (R615-F1 / W274) — an
1658/// external infra root this camp borrows machines/providers from.
1659#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1660#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1661pub struct InfraSource {
1662    /// Logical owner name, badged in the Infra tab (e.g. `"yah"`). Distinct
1663    /// from any camp/repo name the `kind` resolves through — this is what an
1664    /// operator sees on a borrowed row, not a path.
1665    pub owner: String,
1666    #[serde(flatten)]
1667    pub kind: InfraSourceKind,
1668    #[serde(default)]
1669    pub mode: SourceMode,
1670    /// Optional filter — name globs or mesh-tag selectors — to borrow a
1671    /// subset of the source root rather than everything it declares. Empty
1672    /// (the default) borrows everything.
1673    #[serde(default)]
1674    pub select: Vec<String>,
1675}
1676
1677fn default_sources_schema_version() -> u32 {
1678    1
1679}
1680
1681/// `.yah/infra/sources.toml` — the ordered list of external infra roots this
1682/// camp borrows from (R615-F1 / W274).
1683#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1684#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1685pub struct SourcesConfig {
1686    #[serde(default = "default_sources_schema_version")]
1687    pub schema_version: u32,
1688    /// `[[source]]` entries, in declaration order — overlay order matters
1689    /// when two linked sources both name the same machine (R615-F2).
1690    #[serde(default, rename = "source")]
1691    pub source: Vec<InfraSource>,
1692}
1693
1694impl Default for SourcesConfig {
1695    fn default() -> Self {
1696        Self {
1697            schema_version: default_sources_schema_version(),
1698            source: Vec::new(),
1699        }
1700    }
1701}
1702
1703impl SourcesConfig {
1704    /// Load `<infra_dir>/sources.toml`. A missing file is not an error —
1705    /// every camp without linked infra has none, which today is every camp —
1706    /// and yields an empty source list rather than `Err`.
1707    pub fn load(infra_dir: &Path) -> Result<Self> {
1708        let path = infra_dir.join("sources.toml");
1709        if !path.exists() {
1710            return Ok(Self::default());
1711        }
1712        let src =
1713            std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
1714        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
1715    }
1716}
1717
1718impl InfraSource {
1719    /// Human-readable descriptor of *which* source this is, for
1720    /// [`InfraOrigin::source`] — distinguishes two linked sources from the
1721    /// same owner. Never includes credentials: `GitSource.repo` is a clone
1722    /// URL (https/ssh), the same thing R561 already treats as safe to log,
1723    /// with any real secret resolved separately via `keystore://` (W274's
1724    /// own precedent).
1725    fn describe(&self) -> String {
1726        match &self.kind {
1727            InfraSourceKind::Path { path } => format!("path:{path}"),
1728            InfraSourceKind::Git(g) => format!("git:{}@{}", g.repo, g.r#ref),
1729        }
1730    }
1731
1732    /// Resolve this source to an infra root directory (R615-F2 / W274 §3).
1733    /// Does no I/O and touches no network: `path` sources read the owner's
1734    /// live tree directly; `git` sources read wherever `yah infra sync`
1735    /// (R615-T3) last synced to, which may not exist yet (an unsynced git
1736    /// source overlays nothing, not an error — see [`load_dir_tolerant`]).
1737    ///
1738    /// `git.subdir` (reused verbatim from [`GitSource`]/R561) is honoured
1739    /// exactly like the component case: the checkout root when unset, or
1740    /// `<checkout>/<subdir>` when set — e.g. `subdir = "infra"` for a
1741    /// monorepo whose infra registry lives under `infra/` rather than at the
1742    /// clone's root. `yah infra sync` (R615-T3) clones into the *checkout*
1743    /// root ([`crate::paths::infra_source_cache_dir`]), never into a
1744    /// subdir-suffixed path, so this is the one place that appends `subdir`.
1745    fn infra_root(&self, workspace_root: &Path) -> std::path::PathBuf {
1746        match &self.kind {
1747            InfraSourceKind::Path { path } => workspace_root.join(path).join(".yah").join("infra"),
1748            InfraSourceKind::Git(g) => {
1749                let checkout = crate::paths::infra_source_cache_dir(workspace_root, &self.owner);
1750                match g.subdir.as_deref() {
1751                    Some(subdir) => checkout.join(subdir),
1752                    None => checkout,
1753                }
1754            }
1755        }
1756    }
1757}
1758
1759/// Provenance for a [`MachineConfig`] or [`ProviderConfig`] pulled in from a
1760/// linked `.yah/infra/sources.toml` entry, rather than declared in this
1761/// camp's own `.yah/infra/` (R615-F2 / W274).
1762///
1763/// Lives in [`CloudConfig::machine_origins`] / `provider_origins`, keyed by
1764/// name/id, rather than as a field on `MachineConfig`/`ProviderConfig`
1765/// themselves: those two types are constructed by struct literal in test
1766/// helpers across several crates (including ones this ticket has no reason to
1767/// touch), so widening either shape would ripple out past this crate for no
1768/// semantic gain — origin is a property of *this load*, not an inherent
1769/// property of the machine/provider. A name absent from the map is
1770/// camp-local; present means borrowed, and the Infra tab (R615-F4) / reconcile
1771/// gating (`InfraSource::mode`, copied onto `mode` below) read it from here.
1772#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1773#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1774pub struct InfraOrigin {
1775    /// The [`InfraSource::owner`] that supplied this entry, e.g. `"yah"`.
1776    pub owner: String,
1777    /// Which source, rendered — see [`InfraSource::describe`].
1778    pub source: String,
1779    /// The write-gate that applied when this entry was overlaid — copied
1780    /// from [`InfraSource::mode`] so a caller holding just the machine/
1781    /// provider doesn't need the source list in hand to know it's borrowed
1782    /// read-only.
1783    pub mode: SourceMode,
1784}
1785
1786/// Like [`load_dir`], but tolerant **per file**: a foreign infra root (an
1787/// owner's live tree, or a synced git checkout) can carry entries this
1788/// binary's `T` predates — noisetable's pre-migration machines used an older
1789/// schema than yah's, and the reverse will happen too as each side evolves
1790/// independently. One unparseable file on a source this camp doesn't own must
1791/// never sink every other entry in the same directory, let alone this camp's
1792/// own load (R615-F2 gotcha). Contrast [`load_dir`], which stays strict for
1793/// camp-local files, where a malformed TOML genuinely should be a hard error.
1794///
1795/// Returns the entries that parsed, plus `(path, error)` for every file that
1796/// didn't — the caller logs those, it doesn't drop them silently. A missing
1797/// or unreadable directory yields `(vec![], vec![])`, same "no entries" as
1798/// `load_dir`'s `!dir.exists()` case (an unsynced git source, or a source
1799/// root with no `providers/` at all, are both normal, not warnings).
1800fn load_dir_tolerant<T: for<'de> Deserialize<'de>>(
1801    dir: &Path,
1802) -> (Vec<T>, Vec<(std::path::PathBuf, anyhow::Error)>) {
1803    let Ok(read_dir) = std::fs::read_dir(dir) else {
1804        return (Vec::new(), Vec::new());
1805    };
1806    let mut entries: Vec<_> = read_dir.filter_map(|e| e.ok()).collect();
1807    entries.sort_by_key(|e| e.file_name());
1808
1809    let mut items = Vec::new();
1810    let mut skipped = Vec::new();
1811    for entry in entries {
1812        let path = entry.path();
1813        if path.extension().map_or(true, |e| e != "toml") {
1814            continue;
1815        }
1816        let parsed = std::fs::read_to_string(&path)
1817            .with_context(|| format!("reading {}", path.display()))
1818            .and_then(|src| {
1819                toml::from_str::<T>(&src).with_context(|| format!("parsing {}", path.display()))
1820            });
1821        match parsed {
1822            Ok(item) => items.push(item),
1823            Err(e) => skipped.push((path, e)),
1824        }
1825    }
1826    (items, skipped)
1827}
1828
1829/// Whether a borrowed machine passes an [`InfraSource::select`] filter
1830/// (R615-F2 / W274). Empty `select` borrows everything. A non-empty `select`
1831/// entry matches either the machine's exact `name` or literal membership in
1832/// its `mesh_tags` — the one shape W274's own example uses
1833/// (`select = ["tag:cloud-runner"]`). Not a glob engine: mesh tags are
1834/// already flat strings compared for exact equality everywhere else in this
1835/// crate (see `resolve_machine_by_mesh_tags`), so a select entry is that same
1836/// comparison, not a new pattern language.
1837fn machine_matches_select(machine: &MachineConfig, select: &[String]) -> bool {
1838    select.is_empty()
1839        || select
1840            .iter()
1841            .any(|s| *s == machine.name || machine.mesh_tags.contains(s))
1842}
1843
1844/// Overlay every linked `.yah/infra/sources.toml` source's machines and
1845/// providers into `machines`/`providers`, recording provenance into
1846/// `machine_origins`/`provider_origins` (R615-F2 / W274). Must be called
1847/// AFTER camp-local entries are already in both vectors and both origin maps
1848/// are seeded with every camp-local name/id already `HashSet`-tracked as
1849/// "seen": collision resolution is "first writer wins," so seeding with
1850/// camp-local first is what makes camp-local win over every source, and an
1851/// earlier source win over a later one.
1852///
1853/// `select` filters which machines a source contributes; it does not apply
1854/// to providers (nothing in W274 or the source ticket describes a
1855/// provider-scoped filter — every provider a source declares either overlays
1856/// whole or, on a name collision, doesn't).
1857fn overlay_infra_sources(
1858    workspace_root: &Path,
1859    sources: &SourcesConfig,
1860    machines: &mut Vec<MachineConfig>,
1861    providers: &mut Vec<ProviderConfig>,
1862    machine_origins: &mut BTreeMap<String, InfraOrigin>,
1863    provider_origins: &mut BTreeMap<String, InfraOrigin>,
1864) {
1865    let mut seen_machine_names: std::collections::HashSet<String> =
1866        machines.iter().map(|m| m.name.clone()).collect();
1867    let mut seen_provider_ids: std::collections::HashSet<String> =
1868        providers.iter().map(|p| p.id.clone()).collect();
1869
1870    for source in &sources.source {
1871        let root = source.infra_root(workspace_root);
1872        let origin = InfraOrigin {
1873            owner: source.owner.clone(),
1874            source: source.describe(),
1875            mode: source.mode,
1876        };
1877
1878        let (foreign_machines, skipped) = load_dir_tolerant::<MachineConfig>(&root.join("machines"));
1879        for (path, e) in skipped {
1880            tracing::warn!(
1881                "infra source {:?} ({}): skipping unparseable machine {}: {e:#}",
1882                source.owner,
1883                root.display(),
1884                path.display()
1885            );
1886        }
1887        for m in foreign_machines {
1888            if seen_machine_names.contains(&m.name) {
1889                continue; // camp-local, or an earlier source, already claimed this name
1890            }
1891            if !machine_matches_select(&m, &source.select) {
1892                continue;
1893            }
1894            seen_machine_names.insert(m.name.clone());
1895            machine_origins.insert(m.name.clone(), origin.clone());
1896            machines.push(m);
1897        }
1898
1899        let (foreign_providers, skipped) = load_dir_tolerant::<ProviderConfig>(&root.join("providers"));
1900        for (path, e) in skipped {
1901            tracing::warn!(
1902                "infra source {:?} ({}): skipping unparseable provider {}: {e:#}",
1903                source.owner,
1904                root.display(),
1905                path.display()
1906            );
1907        }
1908        for p in foreign_providers {
1909            if seen_provider_ids.contains(&p.id) {
1910                continue;
1911            }
1912            seen_provider_ids.insert(p.id.clone());
1913            provider_origins.insert(p.id.clone(), origin.clone());
1914            providers.push(p);
1915        }
1916    }
1917}
1918
1919/// One component of a [`ServiceConfig`]. The `kind` (e.g. `"mesofact-static"`,
1920/// `"almanac"`, `"container"`) selects which reconciler runs against the
1921/// pointed-at workload manifest.
1922#[derive(Debug, Clone, Serialize, Deserialize)]
1923#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1924pub struct ServiceComponent {
1925    pub id: String,
1926    pub kind: String,
1927    /// Path of the directory holding this component's `workload.toml`. Relative
1928    /// to the workspace root for in-tree components, or to the materialized
1929    /// `<checkout>/<subdir>` when [`git`](Self::git) is set.
1930    pub path: String,
1931    /// Optional external git source (R561-F1). When set, the component's code
1932    /// is materialized by shallow-clone before build; see [`GitSource`].
1933    #[serde(default, skip_serializing_if = "Option::is_none")]
1934    pub git: Option<GitSource>,
1935    /// Operator-facing role label, e.g. `"static"`, `"dynamic"`, `"compute"`.
1936    pub role: String,
1937    /// Optional artifact kind this component publishes (`"static"`,
1938    /// `"container-image"`, …). Drives mirror provider-slot routing.
1939    #[serde(default, skip_serializing_if = "Option::is_none")]
1940    pub publishes: Option<String>,
1941    /// Sync-wave index (0-based). Components in wave 0 roll out in parallel
1942    /// first; the reconciler waits for all wave-N components to become healthy
1943    /// before starting wave N+1. Defaults to 0 (all components in one wave).
1944    #[serde(default, skip_serializing_if = "is_zero_u32")]
1945    pub wave: u32,
1946}
1947
1948#[inline]
1949fn is_zero_u32(n: &u32) -> bool {
1950    *n == 0
1951}
1952
1953/// A service's declared databases, grouped by environment (W241 §Sections).
1954/// Parsed from the `[db]` table of `service.toml`; each `[[db.<env>]]` array
1955/// entry names one database. The environment tag drives backend selection at
1956/// query time (see the data-workbench's `db.query` / the `sql_*` MCP tools):
1957/// `dev` = local file, `pond` = a DB inside the running pond container stack
1958/// (reached on a declared localhost port), `cloud` = a remote libSQL/Turso or
1959/// Postgres endpoint whose auth comes from an env var (never stored in TOML).
1960#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1961#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1962pub struct DbCatalog {
1963    /// Local-file SQLite databases used in dev mode.
1964    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1965    pub dev: Vec<DevDb>,
1966    /// Databases running inside the pond container stack.
1967    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1968    pub pond: Vec<PondDb>,
1969    /// Remote cloud databases (Turso, Postgres).
1970    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1971    pub cloud: Vec<CloudDb>,
1972}
1973
1974impl DbCatalog {
1975    /// True when no database is declared in any environment. Lets
1976    /// [`ServiceConfig`] skip serializing an empty `[db]` table.
1977    pub fn is_empty(&self) -> bool {
1978        self.dev.is_empty() && self.pond.is_empty() && self.cloud.is_empty()
1979    }
1980}
1981
1982/// A dev-mode local SQLite database (`[[db.dev]]`). `path` is resolved
1983/// relative to the workspace root and opened as a local file — read/write, no
1984/// network, no auth.
1985#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1986#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1987pub struct DevDb {
1988    /// Logical name, unique within the service's `dev` list. Forms the `name`
1989    /// segment of the catalog id `dev:<service>:<name>`.
1990    pub name: String,
1991    /// On-disk SQLite path, relative to the workspace root (or absolute).
1992    pub path: String,
1993}
1994
1995/// A database running inside the pond container stack (`[[db.pond]]`). The
1996/// pond publishes the DB on a localhost TCP port; the hub connects to
1997/// `127.0.0.1:<port>` when the pond is up and returns a clear error when it is
1998/// not. Either `port` (defaulting to a libSQL/`sqld` HTTP endpoint) or a full
1999/// `url` must be given.
2000#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2001#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2002pub struct PondDb {
2003    /// Logical name, unique within the service's `pond` list.
2004    pub name: String,
2005    /// Localhost TCP port the pond publishes the DB on. Interpreted per
2006    /// [`kind`](Self::kind). Mutually complete with `url` (provide one).
2007    #[serde(default, skip_serializing_if = "Option::is_none")]
2008    pub port: Option<u16>,
2009    /// Full connection URL, overriding `port` when set (e.g. a non-localhost
2010    /// host or an explicit scheme).
2011    #[serde(default, skip_serializing_if = "Option::is_none")]
2012    pub url: Option<String>,
2013    /// Wire protocol the pond DB speaks. Selects how a bare `port` becomes a
2014    /// URL: `turso` → `http://127.0.0.1:<port>` (libSQL/`sqld` over Hrana),
2015    /// `postgres` → `postgres://127.0.0.1:<port>`.
2016    #[serde(default)]
2017    pub kind: PondDbKind,
2018}
2019
2020/// Wire protocol of a [`PondDb`].
2021#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
2022#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2023#[serde(rename_all = "kebab-case")]
2024pub enum PondDbKind {
2025    /// libSQL / `sqld` over Hrana HTTP — the default.
2026    #[default]
2027    Turso,
2028    /// PostgreSQL wire protocol.
2029    Postgres,
2030}
2031
2032/// A remote cloud database (`[[db.cloud]]`). The connection `url` is stored in
2033/// TOML but the credential never is — `auth_token_env` names an environment
2034/// variable the daemon reads at connect time, so the same declaration works
2035/// whether the token is provisioned service-locally or camp-shared (W241;
2036/// operator confirmed both scopes are needed). A camp-wide cloud DB not owned
2037/// by any single service is declared identically in `.yah/db/cloud.toml`.
2038#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2039#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2040pub struct CloudDb {
2041    /// Logical name, unique within its `cloud` list.
2042    pub name: String,
2043    /// Connection URL: `libsql://…` / `http(s)://…` (Turso, `sqld`) or
2044    /// `postgres://…`.
2045    pub url: String,
2046    /// Name of the environment variable holding the auth token. Resolved in
2047    /// the daemon at connect time (value never stored on disk). For a libSQL
2048    /// URL the token is threaded as `?auth_token=…`.
2049    #[serde(default, skip_serializing_if = "Option::is_none")]
2050    pub auth_token_env: Option<String>,
2051}
2052
2053/// A camp-shared cloud database catalog, parsed from `.yah/db/cloud.toml`.
2054/// These are cloud DBs not owned by any single service — declared once at camp
2055/// scope and addressed as `cloud:<name>` (two-segment id), distinct from a
2056/// service-local `cloud:<service>:<name>`.
2057#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
2058#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2059pub struct CampCloudDbs {
2060    #[serde(default, rename = "cloud", skip_serializing_if = "Vec::is_empty")]
2061    pub cloud: Vec<CloudDb>,
2062}
2063
2064impl CampCloudDbs {
2065    /// Load `<camp_root>/.yah/db/cloud.toml`, or an empty catalog if the file
2066    /// is absent (the common case — most camps declare no shared cloud DBs).
2067    pub fn load(camp_root: &Path) -> Result<Self> {
2068        let path = camp_root.join(".yah/db/cloud.toml");
2069        if !path.exists() {
2070            return Ok(Self::default());
2071        }
2072        let src = std::fs::read_to_string(&path)
2073            .with_context(|| format!("reading {}", path.display()))?;
2074        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
2075    }
2076}
2077
2078/// Topological shape of a mirror — how its providers sit relative to each other.
2079#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2080#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2081#[serde(rename_all = "kebab-case")]
2082pub enum MirrorShape {
2083    /// Single machine hosts compute (and any non-Cloudflare-fronted static).
2084    SingleMachine,
2085    /// Operator-local dev mirror — static via built-in file server, compute
2086    /// via the local container runtime.
2087    Local,
2088    /// Multi-machine deployment (machines listed per provider slot).
2089    MultiMachine,
2090}
2091
2092/// Which public-ingress provider fronts this mirror's compute (W267, R594-F11).
2093///
2094/// Both arms answer exactly one question — *given these local workload ports,
2095/// make them publicly reachable at these hostnames* — and they differ only in
2096/// where the ingress rules live and who supervises the front door:
2097///
2098/// | | [`CloudflareTunnel`](Self::CloudflareTunnel) | [`Passway`](Self::Passway) |
2099/// |---|---|---|
2100/// | Ingress rules live | Cloudflare's API (token-form tunnels are remotely-managed) | the pingora `Backends` set in the proxy process |
2101/// | How they get there | an API call per deployed workload | passway polls `GET /service-records?ready=true` |
2102/// | Front door lifecycle | a kamaji-supervised `cloudflared` appliance | a kamaji-supervised passway appliance |
2103///
2104/// Flipping this field is the whole tier ladder: rented edge → sovereign edge
2105/// is a one-line mirror edit, not a rewrite. The provider owns **addressing**
2106/// and never **rendering** — the W173 render cube stays in mesofact's manifest.
2107#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
2108#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2109#[serde(rename_all = "kebab-case")]
2110pub enum IngressProvider {
2111    /// No public front door for this mirror. The default: a mirror that
2112    /// publishes to R2 behind a Worker, or a mesh-only compute tier, has no
2113    /// ingress provider to reconcile.
2114    #[default]
2115    None,
2116    /// Rented edge — `cloudflared` dials *out* from the node to Cloudflare's
2117    /// edge. Zero inbound ports, no TLS to manage on the box, hostname rules
2118    /// held in Cloudflare's API.
2119    CloudflareTunnel,
2120    /// Sovereign edge — passway terminates TLS on the node and load-balances
2121    /// an upstream set discovered from yubaba's service records.
2122    Passway,
2123}
2124
2125impl IngressProvider {
2126    /// `true` when this mirror declares a front door that has to be reconciled.
2127    pub fn is_declared(self) -> bool {
2128        !matches!(self, Self::None)
2129    }
2130
2131    /// Kebab-case wire name, as it appears in `mirrors/<env>.toml`.
2132    pub fn as_str(self) -> &'static str {
2133        match self {
2134            Self::None => "none",
2135            Self::CloudflareTunnel => "cloudflare-tunnel",
2136            Self::Passway => "passway",
2137        }
2138    }
2139}
2140
2141/// A service mirror — the projection of a [`ServiceConfig`] onto concrete
2142/// infra. Lives at `.yah/services/<svc>/mirrors/<env>.toml`.
2143#[derive(Debug, Clone, Serialize, Deserialize)]
2144#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2145pub struct MirrorConfig {
2146    pub schema_version: u32,
2147    pub shape: MirrorShape,
2148    /// Public-ingress provider fronting this mirror (W267). Defaults to
2149    /// [`IngressProvider::None`].
2150    ///
2151    /// Declared at mirror scope rather than per provider slot because a front
2152    /// door does **fan-in**: one `cloudflared` (or one passway) on a node
2153    /// multiplexes every hostname→port rule the mirror needs, so pinning it to
2154    /// a single slot would mint one edge connection per slot for no gain.
2155    #[serde(default, skip_serializing_if = "not_declared")]
2156    pub ingress: IngressProvider,
2157    /// Provider slots, keyed by role (`"static"`, `"compute"`, …). Each value
2158    /// either references a provider declared under `.yah/infra/providers/` or
2159    /// inlines a local-only provider (no creds, no infra file).
2160    #[serde(default)]
2161    pub providers: BTreeMap<String, MirrorProviderSlot>,
2162    /// Capability→driver bindings, keyed by **capability** (`"pg"`, `"s3"`, …)
2163    /// rather than by slot role (W265 §Drivers).
2164    ///
2165    /// This is the generalization of [`Self::providers`]: `providers.static` /
2166    /// `providers.object_store` are the special case where the slot name and
2167    /// the capability happen to coincide, and keying by capability is what stops
2168    /// the slot enum growing one arm per tier-specific implementation. A service
2169    /// says "I need pg"; the mirror says which implementation of pg *this tier*
2170    /// uses; the app talks the same wire protocol either way and never forks.
2171    ///
2172    /// ```toml
2173    /// [drivers.pg]
2174    /// kind = "local-pg-dev"     # dev  — kamaji-supervised loopback postgres
2175    /// ```
2176    ///
2177    /// Additive in P1: `drivers` lands *alongside* `providers`, and migrating
2178    /// the existing `providers.static` / `providers.object_store` declarations
2179    /// over is a separate pass (W265 §"Open follow-ups"). A mirror that declares
2180    /// neither is unchanged.
2181    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2182    pub drivers: BTreeMap<String, MirrorProviderSlot>,
2183    /// Per-environment alias overrides for `kind = "static-asset"` components.
2184    ///
2185    /// Keys are logical names (e.g. `"whisper-default"`); values must be
2186    /// filenames present in the component's `workload.toml` catalog.
2187    /// **Resolution only** — this table may never introduce a filename absent
2188    /// from the catalog. Validated against the workload catalog at sync time.
2189    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2190    pub asset_aliases: BTreeMap<String, String>,
2191}
2192
2193/// `skip_serializing_if` predicate for [`MirrorConfig::ingress`] — an
2194/// undeclared front door round-trips as an absent key, not `ingress = "none"`.
2195fn not_declared(ingress: &IngressProvider) -> bool {
2196    !ingress.is_declared()
2197}
2198
2199impl MirrorConfig {
2200    /// Parse a single `mirrors/<env>.toml` file.
2201    pub fn load(path: &Path) -> Result<Self> {
2202        let src =
2203            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
2204        toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
2205    }
2206
2207    /// Persist to `.yah/services/<service>/mirrors/<env>.toml`, creating the
2208    /// `mirrors/` directory if needed. Create-or-overwrite. The mirror file is
2209    /// named by `env` (its stem); `service` selects the owning service dir.
2210    pub fn save(&self, workspace_root: &Path, service: &str, env: &str) -> Result<()> {
2211        let dir = crate::paths::service_mirrors_dir(workspace_root, service);
2212        std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
2213        let path = crate::paths::service_mirror_toml(workspace_root, service, env);
2214        let s = toml::to_string_pretty(self)
2215            .with_context(|| format!("serializing mirror {service}/{env}"))?;
2216        std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
2217    }
2218
2219    /// Remove `.yah/services/<service>/mirrors/<env>.toml`. Returns `false`
2220    /// when the file was already absent. Leaves the service and its other
2221    /// mirrors untouched.
2222    ///
2223    /// Also checks legacy stems (e.g. `local-sim` when `env = "pond"`) so
2224    /// deleting a canonical tier name removes whichever file exists on disk.
2225    pub fn delete(workspace_root: &Path, service: &str, env: &str) -> Result<bool> {
2226        let path = crate::paths::service_mirror_toml(workspace_root, service, env);
2227        if path.exists() {
2228            std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
2229            return Ok(true);
2230        }
2231        // Try legacy file stems for canonical tier names.
2232        let legacy: &[&str] = match env {
2233            "dev" => &["local"],
2234            "pond" => &["local-sim", "sim"],
2235            "cloud" => &["prod"],
2236            _ => &[],
2237        };
2238        for stem in legacy {
2239            let alt = crate::paths::service_mirror_toml(workspace_root, service, stem);
2240            if alt.exists() {
2241                std::fs::remove_file(&alt)
2242                    .with_context(|| format!("removing {}", alt.display()))?;
2243                return Ok(true);
2244            }
2245        }
2246        Ok(false)
2247    }
2248}
2249
2250/// A provider slot inside a [`MirrorConfig`]. Two shapes:
2251/// - **Reference** (`use = "<provider-id>"`) — point at an infra-declared
2252///   provider; extra fields are slot-specific (bucket, zone, dns, …).
2253/// - **Inline** (`kind = "local-*"`) — for providers that need no infra
2254///   declaration because they carry no credentials.
2255#[derive(Debug, Clone, Serialize, Deserialize)]
2256#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2257#[serde(untagged)]
2258pub enum MirrorProviderSlot {
2259    Reference {
2260        #[serde(rename = "use")]
2261        provider_id: String,
2262        #[serde(flatten)]
2263        #[cfg_attr(
2264            feature = "json-schema",
2265            schemars(with = "std::collections::BTreeMap<String, serde_json::Value>")
2266        )]
2267        fields: BTreeMap<String, toml::Value>,
2268    },
2269    Inline {
2270        kind: Provider,
2271        #[serde(flatten)]
2272        #[cfg_attr(
2273            feature = "json-schema",
2274            schemars(with = "std::collections::BTreeMap<String, serde_json::Value>")
2275        )]
2276        fields: BTreeMap<String, toml::Value>,
2277    },
2278}
2279
2280impl MirrorProviderSlot {
2281    /// Provider id this slot references, or `None` for inline slots.
2282    pub fn provider_id(&self) -> Option<&str> {
2283        match self {
2284            Self::Reference { provider_id, .. } => Some(provider_id),
2285            Self::Inline { .. } => None,
2286        }
2287    }
2288
2289    /// Provider kind for inline slots, or `None` for reference slots
2290    /// (resolve via the referenced [`ProviderConfig`]).
2291    pub fn inline_kind(&self) -> Option<Provider> {
2292        match self {
2293            Self::Reference { .. } => None,
2294            Self::Inline { kind, .. } => Some(*kind),
2295        }
2296    }
2297
2298    pub fn fields(&self) -> &BTreeMap<String, toml::Value> {
2299        match self {
2300            Self::Reference { fields, .. } | Self::Inline { fields, .. } => fields,
2301        }
2302    }
2303
2304    /// F16 placement: parse the optional `required = { … }` sub-table on this
2305    /// slot. Returns `None` when absent or unparseable (callers treat as no
2306    /// constraint). See [`RequiredSpec`] for the field grammar.
2307    pub fn required(&self) -> Option<RequiredSpec> {
2308        let v = self.fields().get("required")?.clone();
2309        v.try_into().ok()
2310    }
2311}
2312
2313/// F16 placement constraints declared on a [`MirrorProviderSlot`], lives under
2314/// `[providers.<role>] required = { regions = [...], mesh_tags = [...] }` in
2315/// `mirrors/<env>.toml`.
2316///
2317/// Hard (must-satisfy) axes, all AND-ed together:
2318/// - `regions` / `zones` / `providers` — *membership*: the machine's
2319///   `region` / `zone` / `provider` must be one of the listed values.
2320/// - `mesh_tags` — *superset*: the machine's `mesh_tags` must contain every
2321///   listed tag.
2322/// - `memory_mb` / `cpu_millis` — *capacity floor* (R572-F5): the machine's
2323///   `allocatable` budget must cover the demand. `0` = no constraint.
2324/// - `repel_archetype` — *taint repulsion* (R572-F5): the machine must not
2325///   carry the taint `"no-<archetype.taint_key()>"` for the workload's class.
2326///   `None` = no repulsion check.
2327/// - `requires_taint` — *taint affinity* (R572-F5): the machine must carry
2328///   this taint key (in `taints` or `mesh_tags`). `None` = no affinity.
2329///
2330/// An empty / zero / None on every axis means "no constraint on that axis".
2331/// A fully-unconstrained `RequiredSpec` matches every machine (see
2332/// [`RequiredSpec::is_unconstrained`]).
2333#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2334#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2335pub struct RequiredSpec {
2336    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2337    pub regions: Vec<String>,
2338    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2339    pub zones: Vec<String>,
2340    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2341    pub providers: Vec<String>,
2342    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2343    pub mesh_tags: Vec<String>,
2344
2345    /// R572-F5: minimum memory (MiB) the target node must have in its
2346    /// declared `allocatable` budget. `0` = no constraint. Filled by
2347    /// [`CloudConfig::admit_workload`] from the workload's `resources.memory_mb`.
2348    #[serde(default, skip_serializing_if = "is_zero_u32")]
2349    pub memory_mb: u32,
2350    /// R572-F5: minimum CPU (millicores) the target node must have in its
2351    /// declared `allocatable` budget. `0` = no constraint. Filled by
2352    /// [`CloudConfig::admit_workload`] from the workload's `resources.cpu_millis`.
2353    #[serde(default, skip_serializing_if = "is_zero_u32")]
2354    pub cpu_millis: u32,
2355    /// R572-F5: effective archetype of the workload being placed. The scheduler
2356    /// rejects any node that carries the taint `"no-<archetype.taint_key()>"`.
2357    /// `None` = no repulsion check (backwards-compat for callers that don't
2358    /// thread a spec through).
2359    #[serde(skip)]
2360    pub repel_archetype: Option<LifecycleArchetype>,
2361    /// R572-F5: taint the workload requires the target node to carry
2362    /// (annotation `yah.placement.requires-taint`). The node must have the
2363    /// key in its `taints` list or `mesh_tags`. `None` = no affinity constraint.
2364    #[serde(skip)]
2365    pub requires_taint: Option<String>,
2366}
2367
2368impl RequiredSpec {
2369    /// True when no axis carries a constraint — every machine matches.
2370    pub fn is_unconstrained(&self) -> bool {
2371        self.regions.is_empty()
2372            && self.zones.is_empty()
2373            && self.providers.is_empty()
2374            && self.mesh_tags.is_empty()
2375            && self.memory_mb == 0
2376            && self.cpu_millis == 0
2377            && self.repel_archetype.is_none()
2378            && self.requires_taint.is_none()
2379    }
2380
2381    /// Whether `machine` satisfies every hard axis.
2382    ///
2383    /// - Membership axes (region/zone/provider): machine must carry the field
2384    ///   and it must appear in the constraint list.
2385    /// - `mesh_tags`: machine tags must be a superset of the required set.
2386    /// - **R572-F5 capacity floor**: `machine.allocatable.{memory,cpu}` must
2387    ///   cover `self.{memory,cpu}`. A machine with no `allocatable` block passes
2388    ///   unconditionally (capacity unknown → no constraint enforced).
2389    /// - **R572-F5 taint repulsion**: machine must not carry the taint
2390    ///   `"no-<archetype.taint_key()>"` for the workload's class.
2391    /// - **R572-F5 taint affinity**: if `requires_taint` is set, the machine
2392    ///   must carry that key in its `taints` list or `mesh_tags`.
2393    pub fn matches(&self, machine: &MachineConfig) -> bool {
2394        let member_ok = |constraint: &[String], value: Option<&str>| -> bool {
2395            constraint.is_empty() || value.map_or(false, |v| constraint.iter().any(|c| c == v))
2396        };
2397
2398        // Membership + mesh-tags (pre-existing axes).
2399        if !member_ok(&self.regions, machine.region.as_deref())
2400            || !member_ok(&self.zones, machine.zone.as_deref())
2401            || !member_ok(&self.providers, Some(machine.provider.as_str()))
2402            || !self
2403                .mesh_tags
2404                .iter()
2405                .all(|t| machine.mesh_tags.iter().any(|mt| mt == t))
2406        {
2407            return false;
2408        }
2409
2410        // R572-F5: capacity floor. Skipped when machine has no allocatable
2411        // declaration (unknown capacity → passes, consistent with pre-F5 behaviour).
2412        if self.memory_mb > 0 || self.cpu_millis > 0 {
2413            if let Some(alloc) = &machine.allocatable {
2414                if self.memory_mb > alloc.memory_mb || self.cpu_millis > alloc.cpu_millis {
2415                    return false;
2416                }
2417            }
2418        }
2419
2420        // R572-F5: taint repulsion. A node taint "no-<archetype>" repels the
2421        // workload class unless it explicitly tolerates it.
2422        if let Some(arch) = self.repel_archetype {
2423            let repel_key = format!("no-{}", arch.taint_key());
2424            if machine.taints.iter().any(|t| *t == repel_key) {
2425                return false;
2426            }
2427        }
2428
2429        // R572-F5: taint affinity. Machine must carry the required taint key
2430        // in either its `taints` list or `mesh_tags`.
2431        if let Some(req) = &self.requires_taint {
2432            let has_it = machine.taints.iter().any(|t| t == req)
2433                || machine.mesh_tags.iter().any(|t| t == req);
2434            if !has_it {
2435                return false;
2436            }
2437        }
2438
2439        true
2440    }
2441
2442    /// Human-readable summary of the constraints, for fail-loud error messages.
2443    /// Example: `required.regions=[us-west] + required.mesh_tags=[tag:cloud-runner]`.
2444    pub fn describe(&self) -> String {
2445        let mut parts = Vec::new();
2446        let mut push = |label: &str, vals: &[String]| {
2447            if !vals.is_empty() {
2448                parts.push(format!("required.{label}=[{}]", vals.join(",")));
2449            }
2450        };
2451        push("regions", &self.regions);
2452        push("zones", &self.zones);
2453        push("providers", &self.providers);
2454        push("mesh_tags", &self.mesh_tags);
2455        if self.memory_mb > 0 {
2456            parts.push(format!("memory_mb>={}", self.memory_mb));
2457        }
2458        if self.cpu_millis > 0 {
2459            parts.push(format!("cpu_millis>={}", self.cpu_millis));
2460        }
2461        if let Some(arch) = self.repel_archetype {
2462            parts.push(format!("not-tainted(no-{})", arch.taint_key()));
2463        }
2464        if let Some(req) = &self.requires_taint {
2465            parts.push(format!("requires_taint={req}"));
2466        }
2467        if parts.is_empty() {
2468            "no constraints".to_string()
2469        } else {
2470            parts.join(" + ")
2471        }
2472    }
2473}
2474
2475/// Which front door actually serves a domain's requests (R594-F12).
2476///
2477/// Every domain manifest must say this out loud. Before it existed the
2478/// difference between "R2 serves this hostname directly" and "a Worker
2479/// serves it" was expressed *only* by whether the file happened to carry
2480/// `[[routes]]` — so binding a route-carrying domain straight to R2 was
2481/// accepted silently and served 200s on its SSG half while losing clean
2482/// URLs, SPA shell fallback, deferred-route pointers and branded error
2483/// pages. All of those live in the Worker
2484/// (`oss/mesofact/packages/mesofact-edge/src/router.ts`) or in
2485/// mesofact-serve; an R2 custom domain has none of them.
2486///
2487/// The vocabulary mirrors `scripts/cf-apex-mode.sh` (worker | grey | orange)
2488/// — this moves the choice into the config where it can be checked instead
2489/// of living in one bash script.
2490///
2491/// A front door does **fan-in** only. The render cube (SSG / SPA / SSR /
2492/// deferred / 404) is mesofact's manifest, not this one — see W173 and
2493/// `.yah/docs/working/W267-sovereign-public-ingress.md`
2494/// §"Two front doors, one render contract".
2495#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2496#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2497#[serde(rename_all = "kebab-case")]
2498pub enum FrontDoor {
2499    /// Cloudflare R2 custom domain. Requests hit R2 objects with edge
2500    /// caching and nothing else — no clean URLs, no SPA fallback, no
2501    /// branded errors. Correct for a pure asset tier (W175's verdict for
2502    /// `cdn.yah.dev`) and wrong for anything that renders pages.
2503    /// Implies zero `[[routes]]` and no `worker_bundle_path`.
2504    BucketDirect,
2505    /// Cloudflare Worker generated from this manifest's route table.
2506    Worker,
2507    /// Sovereign L7 ingress — the `passway` proxy on yah-owned metal
2508    /// (`oss/passway`, W267). Same route table as `worker`; different
2509    /// machine terminates TLS.
2510    Passway,
2511}
2512
2513impl FrontDoor {
2514    /// Whether this front door consumes the manifest's `[[routes]]` table.
2515    /// `bucket-direct` does not; the other two are nothing without it.
2516    pub fn is_route_driven(self) -> bool {
2517        matches!(self, FrontDoor::Worker | FrontDoor::Passway)
2518    }
2519
2520    /// The manifest spelling, for error messages.
2521    pub fn as_str(self) -> &'static str {
2522        match self {
2523            FrontDoor::BucketDirect => "bucket-direct",
2524            FrontDoor::Worker => "worker",
2525            FrontDoor::Passway => "passway",
2526        }
2527    }
2528}
2529
2530/// A routing manifest for one domain, from `.yah/domains/<name>.toml`.
2531///
2532/// The domain manifest is the *only* place that knows about path routing:
2533/// services declare static/backend components by opaque ID, and this
2534/// manifest binds those components to URL paths on a public-facing
2535/// domain. Generated Worker bundles consume this. See
2536/// `.yah/docs/working/W118-yah-domain-tiers.md` (R347).
2537#[derive(Debug, Clone, Serialize, Deserialize)]
2538#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2539pub struct DomainConfig {
2540    pub schema_version: u32,
2541    /// Stable identifier for this domain (file stem of the manifest).
2542    /// Example: `"yah-dev"` for the `yah.dev` zone.
2543    pub name: String,
2544    /// The fully-qualified domain this manifest routes for. Example:
2545    /// `"yah.dev"`, `"app.yah.dev"`.
2546    pub domain: String,
2547    /// Which front door serves this domain (R594-F12). **Required** — a
2548    /// default here would silently re-create the defect the field exists to
2549    /// close. Cross-checked against `routes` / `worker_bundle_path` by
2550    /// [`DomainConfig::validate_front_door`] at load time.
2551    pub front_door: FrontDoor,
2552    /// Public CDN bucket name. Static-mode route components publish into
2553    /// this bucket. Owned by the domain, *not* by any single service.
2554    pub cdn_bucket: String,
2555    /// Optional path (relative to workspace root) where the generated
2556    /// Worker bundle lands. `None` while the bundle generator (R347-F4)
2557    /// is still being wired up.
2558    #[serde(default, skip_serializing_if = "Option::is_none")]
2559    pub worker_bundle_path: Option<String>,
2560    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2561    pub routes: Vec<DomainRoute>,
2562}
2563
2564/// One entry in a [`DomainConfig`]'s route table.
2565///
2566/// The `mode` discriminator picks the variant's body via serde's
2567/// internally-tagged enum representation. Path patterns follow the
2568/// Worker convention: a trailing `*` matches everything underneath.
2569#[derive(Debug, Clone, Serialize, Deserialize)]
2570#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2571pub struct DomainRoute {
2572    /// URL pattern this route matches. Examples: `"/"`, `"/dashboard/*"`,
2573    /// `"/camp/ws"`.
2574    pub path: String,
2575    #[serde(flatten)]
2576    pub mode: RouteMode,
2577}
2578
2579/// Body of a [`DomainRoute`]. Three modes:
2580/// - **Static** — Worker reads from the domain's CDN bucket. Component
2581///   ref points at a `kind = "mesofact-static"` (or similar) service
2582///   component.
2583/// - **Backend** — Worker proxies to an HTTP origin owned by a backend
2584///   component (yubaba workload, gateway, etc.).
2585/// - **Redirect** — Worker emits a 30x to the target URL. Used to keep
2586///   old paths alive during domain refactors.
2587#[derive(Debug, Clone, Serialize, Deserialize)]
2588#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2589#[serde(tag = "mode", rename_all = "kebab-case")]
2590pub enum RouteMode {
2591    Static {
2592        /// Component reference `"<service>/<component-id>"`. Validated
2593        /// at [`CloudConfig::load`] time.
2594        component: String,
2595    },
2596    Backend {
2597        /// Component reference `"<service>/<component-id>"`. Validated
2598        /// at [`CloudConfig::load`] time.
2599        component: String,
2600        /// Origin URL the Worker `fetch()`es. Schema-permissive — could
2601        /// be `https://...`, `wss://...`, or a yah-internal mesh URL
2602        /// resolved by yubaba.
2603        origin: String,
2604    },
2605    Redirect {
2606        /// Absolute URL or path the Worker emits a 30x to.
2607        target: String,
2608        /// HTTP status code. Defaults to 308 (permanent + method-preserving)
2609        /// so deprecations don't silently turn POSTs into GETs.
2610        #[serde(default = "default_redirect_status")]
2611        status: u16,
2612    },
2613}
2614
2615fn default_redirect_status() -> u16 {
2616    308
2617}
2618
2619impl DomainConfig {
2620    /// Parse a single `.yah/domains/<name>.toml`, rejecting a manifest whose
2621    /// declared front door contradicts its route table
2622    /// ([`Self::validate_front_door`]).
2623    pub fn load(path: &Path) -> Result<Self> {
2624        let src =
2625            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
2626        let dom: Self =
2627            toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
2628        dom.validate_front_door()
2629            .with_context(|| format!("validating {}", path.display()))?;
2630        Ok(dom)
2631    }
2632
2633    /// R594-F12 — the front door must agree with the rest of the manifest.
2634    ///
2635    /// - `bucket-direct` is an R2 custom domain: a Worker route table would
2636    ///   never be consulted, so declaring one means the author expected
2637    ///   Worker behaviour (clean URLs, SPA fallback, branded errors) from a
2638    ///   surface that cannot provide it. Rejected rather than silently
2639    ///   ignored. Same for `worker_bundle_path` — nothing would deploy it.
2640    /// - `worker` / `passway` with an empty route table is a silent 404
2641    ///   machine: the front door exists, has nothing to serve, and every
2642    ///   request falls through to the catch-all.
2643    ///
2644    /// Called from [`Self::load`], so both [`CloudConfig::load`] and
2645    /// [`CloudConfig::load_from_config_dir`] enforce it.
2646    pub fn validate_front_door(&self) -> Result<()> {
2647        match self.front_door {
2648            FrontDoor::BucketDirect => {
2649                if let Some(route) = self.routes.first() {
2650                    anyhow::bail!(
2651                        "front_door = \"bucket-direct\" but routes[0].path = \"{}\" — \
2652                         an R2 custom domain never consults a route table, so this \
2653                         route would silently do nothing (no clean URLs, no SPA \
2654                         fallback, no branded errors). Set front_door = \"worker\" \
2655                         (or \"passway\") to keep the routes, or drop the [[routes]] \
2656                         to keep the bucket-direct binding.",
2657                        route.path
2658                    );
2659                }
2660                if let Some(path) = &self.worker_bundle_path {
2661                    anyhow::bail!(
2662                        "front_door = \"bucket-direct\" but worker_bundle_path = \
2663                         \"{path}\" — nothing deploys a Worker bundle for a domain \
2664                         bound straight to R2"
2665                    );
2666                }
2667            }
2668            FrontDoor::Worker | FrontDoor::Passway => {
2669                if self.routes.is_empty() {
2670                    anyhow::bail!(
2671                        "front_door = \"{}\" but [[routes]] is empty — a front door \
2672                         with no route table is a silent 404 machine. Declare at \
2673                         least one route, or set front_door = \"bucket-direct\" if \
2674                         this domain really is served straight from R2.",
2675                        self.front_door.as_str()
2676                    );
2677                }
2678            }
2679        }
2680        Ok(())
2681    }
2682
2683    /// Persist to `.yah/domains/<name>.toml`, creating the domains
2684    /// directory if needed. Create-or-overwrite.
2685    pub fn save(&self, workspace_root: &Path) -> Result<()> {
2686        let dir = crate::paths::domains_dir(workspace_root);
2687        std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
2688        let path = crate::paths::domain_toml(workspace_root, &self.name);
2689        let s = toml::to_string_pretty(self)
2690            .with_context(|| format!("serializing domain {}", self.name))?;
2691        std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
2692    }
2693
2694    /// Remove `.yah/domains/<name>.toml`. Returns `false` when the file
2695    /// was already absent.
2696    pub fn delete(workspace_root: &Path, name: &str) -> Result<bool> {
2697        let path = crate::paths::domain_toml(workspace_root, name);
2698        if !path.exists() {
2699            return Ok(false);
2700        }
2701        std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
2702        Ok(true)
2703    }
2704}
2705
2706impl RouteMode {
2707    /// Component reference for static/backend modes; `None` for redirects.
2708    pub fn component(&self) -> Option<&str> {
2709        match self {
2710            Self::Static { component } | Self::Backend { component, .. } => Some(component),
2711            Self::Redirect { .. } => None,
2712        }
2713    }
2714}
2715
2716// ─── Service-group vault (R706 / W294) ───────────────────────────────────────
2717
2718/// A camp's declaration of one cluster secret, from
2719/// `.yah/infra/secrets/<slug>.toml`.
2720///
2721/// This is the *authoring* side of the fleet's cluster-secret store: it names
2722/// where the value lives in the camp (a `fob` vault slot), what the fleet should
2723/// call it, and — the point of R706 — which workloads are allowed to mount it.
2724///
2725/// The declaration is not itself the enforcement point. `yah cloud secret put`
2726/// reads this file, seals the vault value under the cluster KEK, and ships the
2727/// ciphertext **with its access rule** into raft; yubaba's `ClusterResolver`
2728/// evaluates the rule on the node at mount time. Deleting this file does not
2729/// revoke anything — the record in raft is the live authority. That asymmetry is
2730/// deliberate: a rule that lived only in a git-tracked camp file would be
2731/// trivially bypassed by anyone who could reach the fleet without the camp.
2732///
2733/// ```toml
2734/// #:schema ../../schema/secret.toml.schema.json
2735/// schema_version = 1
2736/// name = "cheers/cloud-admin/verify-key"
2737/// vault_slot = "cheers-cloud-admin-verify-key"
2738/// description = "Ed25519 public key yah-cloud-admin verifies operator PASETOs with"
2739///
2740/// [access]
2741/// workloads = [{ workload = "yah-cloud-admin" }]
2742///
2743/// [target]
2744/// kind = "file"
2745/// path = "/run/secrets/cheers-verify.key"
2746/// mode = 0o400
2747/// ```
2748#[derive(Debug, Clone, Serialize, Deserialize)]
2749#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2750pub struct SecretConfig {
2751    pub schema_version: u32,
2752
2753    /// Logical cluster-secret key, as `SecretRef::Cluster { name }` spells it —
2754    /// e.g. `"tls/yah.dev/cert"`, `"cheers/cloud-admin/verify-key"`. May contain
2755    /// `/`; the file stem is a filesystem-safe slug and carries no meaning.
2756    pub name: String,
2757
2758    /// The `fob` vault slot in this camp holding the plaintext value. Read by
2759    /// `yah cloud secret put` at ship time and never recorded anywhere else — in
2760    /// particular the value is not in this file, so the declaration is safe to
2761    /// commit.
2762    pub vault_slot: String,
2763
2764    /// Human note for `yah cloud secret ls`. What this secret is and who minted
2765    /// it — the thing nobody remembers 6 months later.
2766    #[serde(default, skip_serializing_if = "Option::is_none")]
2767    pub description: Option<String>,
2768
2769    /// How the vault slot's text decodes into the bytes the consumer expects.
2770    ///
2771    /// `fob` slots hold strings, but plenty of real secrets are **binary** — an
2772    /// Ed25519 key is exactly 32 raw bytes, and `yah-cloud-admin` rejects a key
2773    /// file of any other length. Without this field the only way to ship such a
2774    /// key would be to hope its bytes happened to be valid UTF-8, which for a
2775    /// random key they are not.
2776    ///
2777    /// Defaults to [`SecretEncoding::Utf8`] — the right answer for tokens,
2778    /// passwords, and PEM, which is most secrets.
2779    #[serde(default)]
2780    pub encoding: SecretEncoding,
2781
2782    /// Who may mount it. Stamped onto the raft record verbatim.
2783    ///
2784    /// Defaults to [`SecretAccess::default`] — the deny-all empty allow-list. A
2785    /// declaration that forgets this field produces a secret nobody can mount,
2786    /// which is the correct direction to fail in.
2787    #[serde(default)]
2788    pub access: SecretAccess,
2789
2790    /// Advisory: the mount shape a consuming workload should declare. Not
2791    /// enforced — yubaba honours whatever the `WorkloadSpec` asks for — but it
2792    /// lets `yah cloud secret put` print the exact `SecretMount` to paste, so
2793    /// the consumer and the declaration can't drift on path or mode.
2794    #[serde(default, skip_serializing_if = "Option::is_none")]
2795    pub target: Option<SecretTargetDecl>,
2796}
2797
2798/// How a [`SecretConfig`]'s vault text becomes the bytes delivered to the
2799/// container (R706 / W294).
2800#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2801#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2802#[serde(rename_all = "kebab-case")]
2803pub enum SecretEncoding {
2804    /// Ship the vault string's UTF-8 bytes verbatim. Tokens, passwords, PEM.
2805    #[default]
2806    Utf8,
2807    /// The vault string is hex; ship the decoded bytes. Use for binary key
2808    /// material — e.g. a raw Ed25519 key, which must land as exactly 32 bytes.
2809    Hex,
2810}
2811
2812/// Advisory mount shape on a [`SecretConfig`]. Mirrors
2813/// `workload_spec::SecretTarget` in a TOML-friendly, externally-tagged-free
2814/// shape (a `kind` discriminator reads better in a hand-written manifest than
2815/// serde's default enum encoding).
2816#[derive(Debug, Clone, Serialize, Deserialize)]
2817#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2818#[serde(tag = "kind", rename_all = "kebab-case")]
2819pub enum SecretTargetDecl {
2820    /// Mounted as a tmpfs-backed file inside the container.
2821    File {
2822        /// Absolute path inside the container.
2823        path: String,
2824        /// Unix permission bits. Defaults to `0o400` (owner-read-only).
2825        #[serde(default = "default_secret_mode")]
2826        mode: u32,
2827    },
2828    /// Injected as an environment variable. Prefer `file` — env vars leak
2829    /// through subprocess environments and log dumps.
2830    EnvVar { name: String },
2831}
2832
2833fn default_secret_mode() -> u32 {
2834    0o400
2835}
2836
2837impl SecretTargetDecl {
2838    /// The `workload_spec` target this declaration describes.
2839    pub fn to_target(&self) -> workload_spec::SecretTarget {
2840        match self {
2841            Self::File { path, mode } => workload_spec::SecretTarget::File {
2842                path: path.into(),
2843                mode: *mode,
2844            },
2845            Self::EnvVar { name } => workload_spec::SecretTarget::EnvVar { name: name.clone() },
2846        }
2847    }
2848}
2849
2850impl SecretConfig {
2851    /// Parse a single `.yah/infra/secrets/<slug>.toml`.
2852    pub fn load(path: &Path) -> Result<Self> {
2853        let src =
2854            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
2855        let cfg: Self =
2856            toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
2857        cfg.validate()
2858            .with_context(|| format!("validating {}", path.display()))?;
2859        Ok(cfg)
2860    }
2861
2862    /// Load every declaration in `dir`, keyed by logical secret name. A missing
2863    /// directory is an empty map (a camp with no cluster secrets is normal).
2864    ///
2865    /// Two files declaring the same `name` is a hard error, not a last-writer-
2866    /// wins merge: they would race to define the access rule for one record, and
2867    /// whichever lost would look correct in git while being inert on the fleet.
2868    pub fn load_dir(dir: &Path) -> Result<BTreeMap<String, Self>> {
2869        let mut out: BTreeMap<String, Self> = BTreeMap::new();
2870        if !dir.exists() {
2871            return Ok(out);
2872        }
2873        for entry in std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? {
2874            let path = entry?.path();
2875            if path.extension().is_none_or(|e| e != "toml") {
2876                continue;
2877            }
2878            let cfg = Self::load(&path)?;
2879            if let Some(prev) = out.insert(cfg.name.clone(), cfg) {
2880                anyhow::bail!(
2881                    "two secret declarations both claim name {:?} (one of them is {}); \
2882                     a cluster secret must have exactly one declaration so its access \
2883                     rule has one author",
2884                    prev.name,
2885                    path.display()
2886                );
2887            }
2888        }
2889        Ok(out)
2890    }
2891
2892    /// Reject declarations that would produce an unusable or dangerous record.
2893    pub fn validate(&self) -> Result<()> {
2894        if self.name.trim().is_empty() {
2895            anyhow::bail!("`name` must not be empty");
2896        }
2897        if self.vault_slot.trim().is_empty() {
2898            anyhow::bail!(
2899                "`vault_slot` must not be empty — it names the fob slot holding the value"
2900            );
2901        }
2902        // A deny-all rule is a *valid* record (it is the fail-closed default the
2903        // resolver relies on) but it is never a useful thing to deliberately
2904        // ship, so catching it here saves an operator the round-trip of
2905        // deploying a workload that mysteriously can't see its own secret.
2906        if let SecretAccess::Workloads(entries) = &self.access {
2907            if entries.is_empty() {
2908                anyhow::bail!(
2909                    "`[access]` admits nobody: list the workloads allowed to mount {:?} \
2910                     (e.g. `workloads = [{{ workload = \"my-service\" }}]`), or set \
2911                     `access = \"allow_any\"` to store it unrestricted",
2912                    self.name
2913                );
2914            }
2915            if let Some(bad) = entries.iter().find(|e| e.workload.trim().is_empty()) {
2916                anyhow::bail!("`[access]` entry has an empty `workload` name: {bad:?}");
2917            }
2918        }
2919        Ok(())
2920    }
2921}
2922
2923#[cfg(test)]
2924mod secret_config_tests {
2925    use super::*;
2926
2927    fn parse(body: &str) -> Result<SecretConfig> {
2928        let cfg: SecretConfig = toml::from_str(body)?;
2929        cfg.validate()?;
2930        Ok(cfg)
2931    }
2932
2933    #[test]
2934    fn minimal_declaration_parses_with_narrow_defaults() {
2935        let cfg = parse(
2936            r#"
2937schema_version = 1
2938name = "svc/token"
2939vault_slot = "svc-token"
2940[access]
2941workloads = [{ workload = "svc" }]
2942"#,
2943        )
2944        .unwrap();
2945
2946        assert_eq!(cfg.encoding, SecretEncoding::Utf8, "text is the default");
2947        assert!(cfg.target.is_none());
2948        // The omitted tenant/namespace must narrow to the singletons, not widen
2949        // to a wildcard.
2950        assert!(cfg
2951            .access
2952            .admits(&workload_spec::secrets::SecretConsumer::workload("svc")));
2953        assert!(!cfg
2954            .access
2955            .admits(&workload_spec::secrets::SecretConsumer::workload("other")));
2956    }
2957
2958    #[test]
2959    fn allow_any_is_spelled_as_a_bare_string() {
2960        // The operator-facing spelling, pinned: `access = "allow_any"`.
2961        let cfg = parse(
2962            r#"
2963schema_version = 1
2964name = "public/thing"
2965vault_slot = "slot"
2966access = "allow_any"
2967"#,
2968        )
2969        .unwrap();
2970        assert_eq!(cfg.access, SecretAccess::AllowAny);
2971    }
2972
2973    #[test]
2974    fn a_declaration_with_no_access_block_is_rejected() {
2975        // Omitting `[access]` defaults to deny-all, which is the correct
2976        // *runtime* default but never a correct authoring intent — so it must
2977        // not silently produce a secret nobody can mount.
2978        let err = parse(
2979            r#"
2980schema_version = 1
2981name = "svc/token"
2982vault_slot = "svc-token"
2983"#,
2984        )
2985        .unwrap_err()
2986        .to_string();
2987        assert!(err.contains("admits nobody"), "got {err}");
2988    }
2989
2990    #[test]
2991    fn empty_name_or_slot_is_rejected() {
2992        assert!(parse(
2993            r#"
2994schema_version = 1
2995name = ""
2996vault_slot = "slot"
2997access = "allow_any"
2998"#
2999        )
3000        .is_err());
3001        assert!(parse(
3002            r#"
3003schema_version = 1
3004name = "x"
3005vault_slot = "  "
3006access = "allow_any"
3007"#
3008        )
3009        .is_err());
3010    }
3011
3012    #[test]
3013    fn target_declaration_maps_onto_the_workload_spec_type() {
3014        let cfg = parse(
3015            r#"
3016schema_version = 1
3017name = "svc/token"
3018vault_slot = "slot"
3019access = "allow_any"
3020[target]
3021kind = "file"
3022path = "/run/secrets/t"
3023"#,
3024        )
3025        .unwrap();
3026        match cfg.target.unwrap().to_target() {
3027            workload_spec::SecretTarget::File { path, mode } => {
3028                assert_eq!(path, std::path::PathBuf::from("/run/secrets/t"));
3029                assert_eq!(mode, 0o400, "owner-read-only by default");
3030            }
3031            other => panic!("expected File, got {other:?}"),
3032        }
3033    }
3034
3035    #[test]
3036    fn load_dir_is_empty_for_a_camp_with_no_secrets() {
3037        let tmp = tempfile::TempDir::new().unwrap();
3038        assert!(SecretConfig::load_dir(&tmp.path().join("nope"))
3039            .unwrap()
3040            .is_empty());
3041    }
3042}
3043
3044/// Split a `"<service>/<component-id>"` ref. Returns `None` if the ref
3045/// isn't shaped like `service/component`.
3046fn split_component_ref(s: &str) -> Option<(&str, &str)> {
3047    let (svc, comp) = s.split_once('/')?;
3048    if svc.is_empty() || comp.is_empty() || comp.contains('/') {
3049        return None;
3050    }
3051    Some((svc, comp))
3052}
3053
3054#[cfg(test)]
3055mod tests {
3056    use super::*;
3057    use std::path::PathBuf;
3058
3059    fn make_machine(name: &str, mesh_tags: Vec<&str>) -> MachineConfig {
3060        MachineConfig {
3061            name: name.into(),
3062            provider: "hetzner".into(),
3063            location: Some("hil".into()),
3064            server_type: Some("ccx13".into()),
3065            hosts_mirrors: vec![],
3066            mesh_tags: mesh_tags.into_iter().map(String::from).collect(),
3067            region: None,
3068            zone: None,
3069            arch: None,
3070            bucket: None,
3071            vendor: None,
3072            nickname: None,
3073            legacy_hostkey_fingerprint: None,
3074            registration: Default::default(),
3075            ssh_keys: vec![],
3076            cloudflared: None,
3077            hosts_operator_bridge: false,
3078            connect: None,
3079            allocatable: None,
3080            taints: vec![],
3081        }
3082    }
3083
3084    /// Like [`make_machine`] but with explicit topology axes for F16 tests.
3085    fn make_machine_topo(
3086        name: &str,
3087        provider: &str,
3088        region: &str,
3089        mesh_tags: Vec<&str>,
3090    ) -> MachineConfig {
3091        MachineConfig {
3092            provider: provider.into(),
3093            region: Some(region.into()),
3094            zone: Some(region.into()),
3095            ..make_machine(name, mesh_tags)
3096        }
3097    }
3098
3099    fn make_empty_cfg(machines: Vec<MachineConfig>) -> CloudConfig {
3100        CloudConfig {
3101            workspace_root: PathBuf::new(),
3102            machines,
3103            providers: vec![],
3104            machine_origins: BTreeMap::new(),
3105            provider_origins: BTreeMap::new(),
3106            services: BTreeMap::new(),
3107            domains: BTreeMap::new(),
3108            legacy_mirrors: vec![],
3109            workloads: vec![],
3110            topology: TopologyConfig::default(),
3111            legacy_services: vec![],
3112        }
3113    }
3114
3115    #[test]
3116    fn required_spec_parses_from_provider_fields() {
3117        let toml_src = r#"
3118use = "hetzner-primary"
3119[required]
3120mesh_tags = ["tag:cloud-runner"]
3121"#;
3122        let slot: MirrorProviderSlot = toml::from_str(toml_src).unwrap();
3123        let req = slot.required().expect("required block present");
3124        assert_eq!(req.mesh_tags, vec!["tag:cloud-runner"]);
3125    }
3126
3127    #[test]
3128    fn required_spec_absent_when_field_missing() {
3129        let slot: MirrorProviderSlot = toml::from_str(r#"use = "hetzner-primary""#).unwrap();
3130        assert!(slot.required().is_none());
3131    }
3132
3133    #[test]
3134    fn db_catalog_parses_all_env_blocks() {
3135        // W241 / R571-F8: a service.toml [db] table with dev/pond/cloud.
3136        let toml_src = r#"
3137schema_version = 1
3138name = "scrabcake"
3139domain = "scrabcake.net.yah.dev"
3140
3141[[db.dev]]
3142name = "main"
3143path = "data/dev.sqlite"
3144
3145[[db.pond]]
3146name = "main"
3147port = 5433
3148
3149[[db.pond]]
3150name = "pg"
3151port = 5432
3152kind = "postgres"
3153
3154[[db.cloud]]
3155name = "main"
3156url = "libsql://scrabcake.turso.io"
3157auth_token_env = "SCRABCAKE_TURSO_TOKEN"
3158"#;
3159        let svc: ServiceConfig = toml::from_str(toml_src).unwrap();
3160        assert_eq!(svc.db.dev.len(), 1);
3161        assert_eq!(svc.db.dev[0].path, "data/dev.sqlite");
3162        assert_eq!(svc.db.pond.len(), 2);
3163        assert_eq!(svc.db.pond[0].port, Some(5433));
3164        assert_eq!(svc.db.pond[0].kind, PondDbKind::Turso); // default
3165        assert_eq!(svc.db.pond[1].kind, PondDbKind::Postgres);
3166        assert_eq!(
3167            svc.db.cloud[0].auth_token_env.as_deref(),
3168            Some("SCRABCAKE_TURSO_TOKEN")
3169        );
3170    }
3171
3172    #[test]
3173    fn service_without_db_table_has_empty_catalog() {
3174        let svc: ServiceConfig =
3175            toml::from_str("schema_version = 1\nname = \"s\"\ndomain = \"s.dev\"\n").unwrap();
3176        assert!(svc.db.is_empty());
3177        // And an empty [db] must not appear when re-serialized.
3178        let out = toml::to_string(&svc).unwrap();
3179        assert!(
3180            !out.contains("[db"),
3181            "empty db table should be skipped: {out}"
3182        );
3183    }
3184
3185    #[test]
3186    fn camp_shared_cloud_toml_parses() {
3187        let src = r#"
3188[[cloud]]
3189name = "analytics"
3190url = "postgres://shared/analytics"
3191"#;
3192        let shared: CampCloudDbs = toml::from_str(src).unwrap();
3193        assert_eq!(shared.cloud.len(), 1);
3194        assert_eq!(shared.cloud[0].name, "analytics");
3195    }
3196
3197    #[test]
3198    fn resolve_machine_by_mesh_tags_superset_match() {
3199        let cfg = make_empty_cfg(vec![
3200            make_machine("yah-bnt-1", vec!["tag:primary-yah", "tag:tier-scratch"]),
3201            make_machine("us-west-001", vec!["tag:primary-yah", "tag:cloud-runner"]),
3202        ]);
3203        let picked = cfg
3204            .resolve_machine_by_mesh_tags(&["tag:cloud-runner".into()])
3205            .map(|m| m.name.as_str());
3206        assert_eq!(picked, Some("us-west-001"));
3207    }
3208
3209    #[test]
3210    fn resolve_machine_by_mesh_tags_returns_none_when_no_match() {
3211        let cfg = make_empty_cfg(vec![make_machine("yah-bnt-1", vec!["tag:primary-yah"])]);
3212        assert!(cfg
3213            .resolve_machine_by_mesh_tags(&["tag:cloud-runner".into()])
3214            .is_none());
3215    }
3216
3217    // ─── R590-F1 mesh-tag node-selector admission ───────────────────────────
3218
3219    /// Build a forge WorkloadSpec carrying the R594 node-selector annotation.
3220    /// `selector` is the comma-joined mesh-tag set; `None` omits the annotation
3221    /// entirely (pre-R594 "no constraint").
3222    fn ws_with_selector(selector: Option<&str>) -> WorkloadSpec {
3223        use workload_spec::{ImageRef, TierTag};
3224        let mut ws = WorkloadSpec::for_forge(
3225            "R590-F1-test",
3226            ImageRef {
3227                registry: "docker.io".into(),
3228                repository: "library/busybox".into(),
3229                tag: "latest".into(),
3230                digest: workload_spec::testing::test_digest(),
3231            },
3232            TierTag("infra".into()),
3233            vec![],
3234        );
3235        if let Some(sel) = selector {
3236            ws.annotations.insert(
3237                velveteen_exec::remote::NODE_SELECTOR_MESH_TAGS_ANNOTATION.into(),
3238                sel.into(),
3239            );
3240        }
3241        ws
3242    }
3243
3244    /// The build-worker fleet shape: one x86 node (us-west-002) and one arm
3245    /// node (a Pi5), both carrying `tag:build-worker`.
3246    fn build_worker_fleet() -> CloudConfig {
3247        make_empty_cfg(vec![
3248            make_machine("us-west-002", vec!["tag:build-worker", "tier:x86"]),
3249            make_machine("pi5-001", vec!["tag:build-worker", "tier:arm"]),
3250        ])
3251    }
3252
3253    #[test]
3254    fn admit_workload_routes_amd64_to_x86_worker() {
3255        let cfg = build_worker_fleet();
3256        let ws = ws_with_selector(Some("tag:build-worker,tier:x86"));
3257        let picked = cfg.admit_workload(&ws).unwrap();
3258        assert_eq!(picked.name, "us-west-002");
3259    }
3260
3261    #[test]
3262    fn admit_workload_routes_arm64_to_pi5_worker() {
3263        let cfg = build_worker_fleet();
3264        let ws = ws_with_selector(Some("tag:build-worker,tier:arm"));
3265        let picked = cfg.admit_workload(&ws).unwrap();
3266        assert_eq!(picked.name, "pi5-001");
3267    }
3268
3269    #[test]
3270    fn admit_workload_rejects_node_missing_required_tag() {
3271        // Only an arm worker exists; an x86 build must NOT land on it.
3272        let cfg = make_empty_cfg(vec![make_machine(
3273            "pi5-001",
3274            vec!["tag:build-worker", "tier:arm"],
3275        )]);
3276        let ws = ws_with_selector(Some("tag:build-worker,tier:x86"));
3277        assert!(cfg.admit_workload(&ws).is_err());
3278    }
3279
3280    /// R555-S1 regression: with TWO nodes carrying the same tag set, which one
3281    /// admits must be decided by *declaration order* (file name), which is the
3282    /// contract `admit_workload` documents — not by `read_dir` order, which is
3283    /// filesystem-dependent and can change when an unrelated file appears in
3284    /// the directory. Written creation-order-reversed so a filesystem that
3285    /// yields creation order (rather than sorted order) trips it without the
3286    /// sort in `load_dir`.
3287    ///
3288    /// Live consequence this guards: `.yah/infra/machines/` carries both
3289    /// us-west-002 and us-west-003 on `[tag:build-worker, tier:x86, os:linux]`,
3290    /// so an x86 QED offload has two equal candidates. Unstable selection means
3291    /// a retried build cannot be relied on to land back on the node whose
3292    /// working state it left behind.
3293    #[test]
3294    fn equally_matching_machines_admit_in_file_name_order() {
3295        let tmp = tempfile::TempDir::new().unwrap();
3296        let machines = tmp.path().join(".yah").join("infra").join("machines");
3297        std::fs::create_dir_all(&machines).unwrap();
3298        let toml_for = |name: &str| {
3299            format!(
3300                r#"name = "{name}"
3301provider = "static"
3302mesh_tags = ["tag:build-worker", "tier:x86"]
3303"#
3304            )
3305        };
3306        // Reverse-of-sorted creation order on purpose.
3307        std::fs::write(machines.join("b-second.toml"), toml_for("b-second")).unwrap();
3308        std::fs::write(machines.join("a-first.toml"), toml_for("a-first")).unwrap();
3309
3310        let cfg = CloudConfig::load(tmp.path()).unwrap();
3311        assert_eq!(
3312            cfg.machines.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(),
3313            vec!["a-first", "b-second"],
3314            "machines must load in file-name order, not read_dir order"
3315        );
3316
3317        let ws = ws_with_selector(Some("tag:build-worker,tier:x86"));
3318        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "a-first");
3319    }
3320
3321    #[test]
3322    fn admit_workload_empty_selector_is_unconstrained() {
3323        // Absent annotation ⇒ no mesh-tag constraint ⇒ first declared machine
3324        // (pre-R594 behavior preserved).
3325        let cfg = build_worker_fleet();
3326        let ws = ws_with_selector(None);
3327        let picked = cfg.admit_workload(&ws).unwrap();
3328        assert_eq!(picked.name, "us-west-002");
3329    }
3330
3331    #[test]
3332    fn node_selector_mesh_tags_trims_and_drops_empties() {
3333        let ws = ws_with_selector(Some(" tag:build-worker , tier:x86 ,"));
3334        assert_eq!(
3335            node_selector_mesh_tags(&ws),
3336            vec!["tag:build-worker".to_string(), "tier:x86".to_string()]
3337        );
3338        assert!(node_selector_mesh_tags(&ws_with_selector(None)).is_empty());
3339    }
3340
3341    // ─── F16 topology-aware resolver ────────────────────────────────────────
3342
3343    fn two_region_fleet() -> CloudConfig {
3344        make_empty_cfg(vec![
3345            make_machine_topo(
3346                "us-west-001",
3347                "hetzner",
3348                "us-west",
3349                vec!["tag:cloud-runner"],
3350            ),
3351            make_machine_topo(
3352                "eu-west-001",
3353                "hetzner",
3354                "eu-west",
3355                vec!["tag:cloud-runner"],
3356            ),
3357        ])
3358    }
3359
3360    #[test]
3361    fn resolve_machine_matches_on_region_plus_mesh_tags() {
3362        let cfg = two_region_fleet();
3363        let req = RequiredSpec {
3364            regions: vec!["us-west".into()],
3365            mesh_tags: vec!["tag:cloud-runner".into()],
3366            ..Default::default()
3367        };
3368        let picked = cfg.resolve_machine(&req).unwrap();
3369        assert_eq!(picked.name, "us-west-001");
3370    }
3371
3372    #[test]
3373    fn resolve_machine_region_disambiguates_same_tag() {
3374        // Both boxes carry tag:cloud-runner; the region axis selects eu-west.
3375        let cfg = two_region_fleet();
3376        let req = RequiredSpec {
3377            regions: vec!["eu-west".into()],
3378            mesh_tags: vec!["tag:cloud-runner".into()],
3379            ..Default::default()
3380        };
3381        assert_eq!(cfg.resolve_machine(&req).unwrap().name, "eu-west-001");
3382    }
3383
3384    #[test]
3385    fn resolve_machine_fails_loud_with_constraint_summary() {
3386        let cfg = two_region_fleet();
3387        let req = RequiredSpec {
3388            regions: vec!["us-central".into()],
3389            mesh_tags: vec!["tag:cloud-runner".into()],
3390            ..Default::default()
3391        };
3392        let err = cfg.resolve_machine(&req).unwrap_err().to_string();
3393        assert!(err.contains("required.regions=[us-central]"), "got: {err}");
3394        assert!(
3395            err.contains("required.mesh_tags=[tag:cloud-runner]"),
3396            "got: {err}"
3397        );
3398        // Names the candidates it rejected.
3399        assert!(err.contains("us-west-001"), "got: {err}");
3400    }
3401
3402    #[test]
3403    fn resolve_machine_provider_axis_filters() {
3404        let cfg = make_empty_cfg(vec![
3405            make_machine_topo("aws-west-1", "aws", "us-west", vec!["tag:cloud-runner"]),
3406            make_machine_topo("hz-west-1", "hetzner", "us-west", vec!["tag:cloud-runner"]),
3407        ]);
3408        let req = RequiredSpec {
3409            regions: vec!["us-west".into()],
3410            providers: vec!["hetzner".into()],
3411            ..Default::default()
3412        };
3413        assert_eq!(cfg.resolve_machine(&req).unwrap().name, "hz-west-1");
3414    }
3415
3416    #[test]
3417    fn unconstrained_required_spec_matches_first_machine() {
3418        let cfg = two_region_fleet();
3419        assert!(RequiredSpec::default().is_unconstrained());
3420        assert_eq!(
3421            cfg.resolve_machine(&RequiredSpec::default()).unwrap().name,
3422            "us-west-001"
3423        );
3424    }
3425
3426    #[test]
3427    fn required_spec_parses_topology_axes_from_toml() {
3428        let toml_src = r#"
3429use = "hetzner-primary"
3430[required]
3431regions = ["us-west"]
3432mesh_tags = ["tag:cloud-runner"]
3433"#;
3434        let slot: MirrorProviderSlot = toml::from_str(toml_src).unwrap();
3435        let req = slot.required().expect("required block present");
3436        assert_eq!(req.regions, vec!["us-west"]);
3437        assert_eq!(req.mesh_tags, vec!["tag:cloud-runner"]);
3438        assert!(req.zones.is_empty());
3439    }
3440
3441    #[test]
3442    fn round_trip_machine() {
3443        let cfg = MachineConfig {
3444            name: "test-pdx-1".into(),
3445            provider: "hetzner".into(),
3446            location: Some("pdx".into()),
3447            server_type: Some("cpx22".into()),
3448            hosts_mirrors: vec!["noisetable".into()],
3449            mesh_tags: vec!["region:pdx".into()],
3450            region: Some("us-west".into()),
3451            zone: Some("pdx".into()),
3452            arch: None,
3453            bucket: Some(BucketSpec {
3454                name: "test-assets-pdx-1".into(),
3455                public_read: false,
3456            }),
3457            vendor: None,
3458            nickname: None,
3459            legacy_hostkey_fingerprint: None,
3460            registration: Default::default(),
3461            ssh_keys: vec![],
3462            cloudflared: None,
3463            hosts_operator_bridge: false,
3464            connect: None,
3465            allocatable: None,
3466            taints: vec![],
3467        };
3468        let s = toml::to_string(&cfg).unwrap();
3469        let back: MachineConfig = toml::from_str(&s).unwrap();
3470        assert_eq!(back.name, cfg.name);
3471        assert_eq!(back.location, cfg.location);
3472        assert_eq!(back.region.as_deref(), Some("us-west"));
3473        assert_eq!(back.zone.as_deref(), Some("pdx"));
3474    }
3475
3476    #[test]
3477    fn round_trip_mirror() {
3478        let cfg = LegacyMirrorConfig {
3479            camp: "noisetable".into(),
3480            regions: vec!["pdx".into(), "iad".into()],
3481            workloads: vec!["asset-registry".into()],
3482            cloud_domain: None,
3483        };
3484        let s = toml::to_string(&cfg).unwrap();
3485        let back: LegacyMirrorConfig = toml::from_str(&s).unwrap();
3486        assert_eq!(back.camp, cfg.camp);
3487        assert_eq!(back.regions, cfg.regions);
3488        assert_eq!(back.workloads, cfg.workloads);
3489    }
3490
3491    #[test]
3492    fn mirror_serialises_as_camp_key() {
3493        // Serialised form should use `camp`, not `rig`.
3494        let cfg = LegacyMirrorConfig {
3495            camp: "noisetable".into(),
3496            regions: vec!["pdx".into()],
3497            workloads: vec![],
3498            cloud_domain: None,
3499        };
3500        let s = toml::to_string(&cfg).unwrap();
3501        assert!(
3502            s.contains("camp = "),
3503            "serialised key should be 'camp': {s}"
3504        );
3505        assert!(!s.contains("rig = "), "old key should not appear: {s}");
3506    }
3507
3508    #[test]
3509    fn mirror_rig_alias_still_loads() {
3510        // Old mirrors/*.toml files use `rig = "..."` before the R137 rename;
3511        // the alias keeps them loading until the one-time `sed` migration runs.
3512        let toml_str =
3513            "rig = \"noisetable\"\nregions = [\"pdx\"]\nworkloads = [\"asset-registry\"]\n";
3514        let cfg: LegacyMirrorConfig = toml::from_str(toml_str).unwrap();
3515        assert_eq!(cfg.camp, "noisetable");
3516    }
3517
3518    #[test]
3519    fn mirror_services_alias_still_loads() {
3520        // Old mirrors/*.toml files use `services = [...]`; the alias keeps them
3521        // loading without a migration step.
3522        let toml_str =
3523            "camp = \"noisetable\"\nregions = [\"pdx\"]\nservices = [\"asset-registry\"]\n";
3524        let cfg: LegacyMirrorConfig = toml::from_str(toml_str).unwrap();
3525        assert_eq!(cfg.workloads, vec!["asset-registry"]);
3526    }
3527
3528    #[test]
3529    fn round_trip_service_legacy() {
3530        let cfg = LegacyServiceConfig {
3531            name: "asset-registry".into(),
3532            image: "ghcr.io/noisetable/asset-registry".into(),
3533            version: "v1.0.0".into(),
3534            env: HashMap::new(),
3535            ports: vec![PortMapping {
3536                host: 8080,
3537                container: 8080,
3538            }],
3539            mesh_only: false,
3540            bind_interface: None,
3541            tenant: TenantId::singleton(),
3542        };
3543        let s = toml::to_string(&cfg).unwrap();
3544        let back: LegacyServiceConfig = toml::from_str(&s).unwrap();
3545        assert_eq!(back.name, cfg.name);
3546        assert_eq!(back.image, cfg.image);
3547    }
3548
3549    #[test]
3550    fn service_bind_interface_round_trips() {
3551        let cfg = LegacyServiceConfig {
3552            name: "postgres".into(),
3553            image: "postgres".into(),
3554            version: "16".into(),
3555            env: HashMap::new(),
3556            ports: vec![PortMapping {
3557                host: 5432,
3558                container: 5432,
3559            }],
3560            mesh_only: true,
3561            bind_interface: Some("tailscale0".into()),
3562            tenant: TenantId::singleton(),
3563        };
3564        let s = toml::to_string(&cfg).unwrap();
3565        let back: LegacyServiceConfig = toml::from_str(&s).unwrap();
3566        assert_eq!(back.bind_interface.as_deref(), Some("tailscale0"));
3567    }
3568
3569    #[test]
3570    fn service_bind_interface_absent_is_none() {
3571        let toml_str = "name = \"app\"\nimage = \"app\"\nversion = \"v1\"\n";
3572        let cfg: LegacyServiceConfig = toml::from_str(toml_str).unwrap();
3573        assert!(
3574            cfg.bind_interface.is_none(),
3575            "bind_interface should default to None"
3576        );
3577    }
3578
3579    #[test]
3580    fn service_bind_interface_skipped_when_none() {
3581        let cfg = LegacyServiceConfig {
3582            name: "app".into(),
3583            image: "app".into(),
3584            version: "v1".into(),
3585            env: HashMap::new(),
3586            ports: vec![],
3587            mesh_only: false,
3588            bind_interface: None,
3589            tenant: TenantId::singleton(),
3590        };
3591        let s = toml::to_string(&cfg).unwrap();
3592        assert!(!s.contains("bind_interface"), "None should be skipped: {s}");
3593    }
3594
3595    #[test]
3596    fn load_dir_missing_is_empty() {
3597        let dir = std::path::PathBuf::from("/nonexistent/path");
3598        let result: Vec<MachineConfig> = load_dir(dir).unwrap();
3599        assert!(result.is_empty());
3600    }
3601
3602    #[test]
3603    fn topology_round_trip() {
3604        let topo = TopologyConfig {
3605            assignments: vec![
3606                MirrorAssignment {
3607                    mirror: "noisetable-pdx".into(),
3608                    machine: "noisetable-pdx-1".into(),
3609                },
3610                MirrorAssignment {
3611                    mirror: "noisetable-iad".into(),
3612                    machine: "noisetable-iad-1".into(),
3613                },
3614            ],
3615            buckets: vec![],
3616        };
3617        let s = toml::to_string(&topo).unwrap();
3618        let back: TopologyConfig = toml::from_str(&s).unwrap();
3619        assert_eq!(back.assignments.len(), 2);
3620        assert_eq!(back.assignments[0].mirror, "noisetable-pdx");
3621        assert_eq!(back.assignments[1].machine, "noisetable-iad-1");
3622    }
3623
3624    #[test]
3625    fn topology_absent_returns_default() {
3626        let tmp = tempfile::TempDir::new().unwrap();
3627        let path = tmp.path().join("topology.toml");
3628        // file doesn't exist
3629        let topo = load_topology(path).unwrap();
3630        assert!(topo.assignments.is_empty());
3631    }
3632
3633    /// Helper: lay out a `<workspace_root>/.yah/cloud/` legacy tree for the
3634    /// pre-R215 cargo tests below; returns the legacy cloud_dir for writes.
3635    fn make_legacy_cloud_dir(root: &std::path::Path) -> std::path::PathBuf {
3636        let cloud_dir = root.join(".yah").join("cloud");
3637        std::fs::create_dir_all(&cloud_dir).unwrap();
3638        cloud_dir
3639    }
3640
3641    #[test]
3642    fn cloud_config_load_and_lookup() {
3643        let tmp = tempfile::TempDir::new().unwrap();
3644        let root = tmp.path();
3645        let cloud_dir = make_legacy_cloud_dir(root);
3646
3647        let machine = MachineConfig {
3648            name: "noisetable-pdx-1".into(),
3649            provider: "hetzner".into(),
3650            location: Some("pdx".into()),
3651            server_type: Some("cpx22".into()),
3652            hosts_mirrors: vec!["noisetable".into(), "yah".into()],
3653            mesh_tags: vec!["region:pdx".into(), "tier:t2".into()],
3654            region: None,
3655            zone: None,
3656            arch: None,
3657            bucket: Some(BucketSpec {
3658                name: "noisetable-assets-pdx-1".into(),
3659                public_read: false,
3660            }),
3661            vendor: None,
3662            nickname: None,
3663            legacy_hostkey_fingerprint: None,
3664            registration: Default::default(),
3665            ssh_keys: vec![],
3666            cloudflared: None,
3667            hosts_operator_bridge: false,
3668            connect: None,
3669            allocatable: None,
3670            taints: vec![],
3671        };
3672        // Land in the legacy tree so the legacy machine loader picks it up.
3673        machine.save(&cloud_dir).unwrap();
3674
3675        let mirror_toml = "camp = \"noisetable\"\nregions = [\"pdx\", \"iad\", \"fsn\"]\nworkloads = [\"asset-registry\"]\n";
3676        std::fs::create_dir_all(cloud_dir.join("mirrors")).unwrap();
3677        std::fs::write(cloud_dir.join("mirrors/noisetable.toml"), mirror_toml).unwrap();
3678
3679        // Legacy services/ dir (backward compat)
3680        let svc_toml = "name = \"asset-registry\"\nimage = \"ghcr.io/noisetable/asset-registry\"\nversion = \"v1.0.0\"\nmesh_only = false\n";
3681        std::fs::create_dir_all(cloud_dir.join("services")).unwrap();
3682        std::fs::write(cloud_dir.join("services/asset-registry.toml"), svc_toml).unwrap();
3683
3684        let cfg = CloudConfig::load(root).unwrap();
3685
3686        assert_eq!(cfg.machines.len(), 1);
3687        assert_eq!(cfg.legacy_mirrors.len(), 1);
3688        assert_eq!(cfg.legacy_services.len(), 1);
3689        assert_eq!(cfg.workloads.len(), 0); // no workloads/ dir yet
3690        assert!(cfg.services.is_empty(), "no R215+ services/ tree");
3691        assert!(cfg.providers.is_empty(), "no R215+ providers/ tree");
3692
3693        let m = cfg.machine("noisetable-pdx-1").unwrap();
3694        assert_eq!(m.location(), "pdx");
3695        assert_eq!(m.bucket.as_ref().unwrap().name, "noisetable-assets-pdx-1");
3696
3697        let mir = cfg.legacy_mirror("noisetable").unwrap();
3698        assert_eq!(mir.regions, vec!["pdx", "iad", "fsn"]);
3699        assert_eq!(mir.workloads, vec!["asset-registry"]);
3700    }
3701
3702    #[test]
3703    fn mirror_folder_layout_loads() {
3704        // Folder layout: mirrors/<id>/mirror.toml — new preferred form.
3705        let tmp = tempfile::TempDir::new().unwrap();
3706        let root = tmp.path();
3707        let cloud_dir = make_legacy_cloud_dir(root);
3708        let mirror_dir = cloud_dir.join("mirrors").join("yah-com");
3709        std::fs::create_dir_all(&mirror_dir).unwrap();
3710        std::fs::write(
3711            mirror_dir.join("mirror.toml"),
3712            "camp = \"yah\"\nregions = [\"pdx\"]\nworkloads = [\"yah-web\"]\n",
3713        )
3714        .unwrap();
3715
3716        let cfg = CloudConfig::load(root).unwrap();
3717        assert_eq!(cfg.legacy_mirrors.len(), 1);
3718        let mir = cfg.legacy_mirror("yah").unwrap();
3719        assert_eq!(mir.camp, "yah");
3720        assert_eq!(mir.workloads, vec!["yah-web"]);
3721    }
3722
3723    #[test]
3724    fn mirror_folder_and_flat_coexist() {
3725        // Both layouts may coexist in the same mirrors/ directory.
3726        let tmp = tempfile::TempDir::new().unwrap();
3727        let root = tmp.path();
3728        let cloud_dir = make_legacy_cloud_dir(root);
3729        let mirrors_root = cloud_dir.join("mirrors");
3730        std::fs::create_dir_all(&mirrors_root).unwrap();
3731
3732        // Flat legacy mirror
3733        std::fs::write(
3734            mirrors_root.join("noisetable.toml"),
3735            "camp = \"noisetable\"\nregions = [\"pdx\"]\nworkloads = []\n",
3736        )
3737        .unwrap();
3738
3739        // Folder-form mirror
3740        let yah_com_dir = mirrors_root.join("yah-com");
3741        std::fs::create_dir_all(&yah_com_dir).unwrap();
3742        std::fs::write(
3743            yah_com_dir.join("mirror.toml"),
3744            "camp = \"yah\"\nregions = [\"pdx\"]\nworkloads = []\n",
3745        )
3746        .unwrap();
3747
3748        let cfg = CloudConfig::load(root).unwrap();
3749        assert_eq!(cfg.legacy_mirrors.len(), 2);
3750        assert!(cfg.legacy_mirror("noisetable").is_some());
3751        assert!(cfg.legacy_mirror("yah").is_some());
3752    }
3753
3754    #[test]
3755    fn mirror_malformed_fails_with_field_path() {
3756        // A malformed mirror.toml should fail at load with a clear error
3757        // that includes the file path.
3758        let tmp = tempfile::TempDir::new().unwrap();
3759        let root = tmp.path();
3760        let cloud_dir = make_legacy_cloud_dir(root);
3761        let mirror_dir = cloud_dir.join("mirrors").join("bad");
3762        std::fs::create_dir_all(&mirror_dir).unwrap();
3763        // Missing required `camp` field
3764        std::fs::write(
3765            mirror_dir.join("mirror.toml"),
3766            "regions = [\"pdx\"]\nworkloads = []\n",
3767        )
3768        .unwrap();
3769
3770        let err = CloudConfig::load(root).unwrap_err();
3771        let msg = err.to_string();
3772        assert!(
3773            msg.contains("mirror.toml"),
3774            "error should reference the file path, got: {msg}"
3775        );
3776    }
3777
3778    #[test]
3779    fn workload_config_load_and_validate() {
3780        use workload_spec::{
3781            ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
3782            RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
3783        };
3784
3785        let tmp = tempfile::TempDir::new().unwrap();
3786        let root = tmp.path();
3787        let cloud_dir = make_legacy_cloud_dir(root);
3788        std::fs::create_dir_all(cloud_dir.join("workloads")).unwrap();
3789
3790        let spec = WorkloadSpec {
3791            schema_version: SchemaVersion::V1,
3792            name: "asset-registry".into(),
3793            image: ImageRef {
3794                registry: "ghcr.io".into(),
3795                repository: "noisetable/asset-registry".into(),
3796                tag: "v1.0.0".into(),
3797                digest: workload_spec::testing::test_digest(),
3798            },
3799            tier: TierTag("tenant".into()),
3800            replicas: 1,
3801            command: None,
3802            entrypoint: None,
3803            workdir: None,
3804            user: None,
3805            env: vec![],
3806            secrets: vec![],
3807            volumes: vec![],
3808            resources: ResourceLimits {
3809                memory_mb: 256,
3810                cpu_millis: 512,
3811                ephemeral_storage_mb: 512,
3812            },
3813            depends_on: vec![],
3814            healthcheck: None,
3815            restart_policy: RestartPolicy::Always,
3816            archetype: None,
3817            stop_policy: StopPolicy {
3818                signal: 15,
3819                grace_period: workload_spec::Millis::from_secs(10),
3820            },
3821            expose: ExposeSpec {
3822                mesh: MeshExpose {
3823                    identity: MeshIdent("asset-registry.pdx".into()),
3824                    ports: vec![8080],
3825                    allow_from: vec![],
3826                },
3827                public: None,
3828                operator: None,
3829            },
3830            tenant: TenantId::singleton(),
3831            namespace: NamespaceId::singleton(),
3832            labels: Default::default(),
3833            annotations: Default::default(),
3834        };
3835
3836        let toml_str = toml::to_string_pretty(&spec).unwrap();
3837        std::fs::write(cloud_dir.join("workloads/asset-registry.toml"), &toml_str).unwrap();
3838
3839        let cfg = CloudConfig::load(root).unwrap();
3840        assert_eq!(cfg.workloads.len(), 1);
3841        assert_eq!(cfg.workloads[0].spec.name, "asset-registry");
3842        assert_eq!(cfg.workload("asset-registry").unwrap().spec.replicas, 1);
3843    }
3844
3845    /// Minimal valid spec for the R215+ loader tests below. Kept as a helper so
3846    /// the two tests differ only in *where* the file lands, which is the whole
3847    /// thing under test.
3848    #[cfg(test)]
3849    fn minimal_spec(name: &str, replicas: u32) -> workload_spec::WorkloadSpec {
3850        use workload_spec::{
3851            ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
3852            RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
3853        };
3854        WorkloadSpec {
3855            schema_version: SchemaVersion::V1,
3856            name: name.into(),
3857            image: ImageRef {
3858                registry: "cr.yah.dev".into(),
3859                repository: name.into(),
3860                tag: "v1".into(),
3861                digest: workload_spec::testing::test_digest(),
3862            },
3863            tier: TierTag("infra".into()),
3864            replicas,
3865            command: None,
3866            entrypoint: None,
3867            workdir: None,
3868            user: None,
3869            env: vec![],
3870            secrets: vec![],
3871            volumes: vec![],
3872            resources: ResourceLimits {
3873                memory_mb: 256,
3874                cpu_millis: 250,
3875                ephemeral_storage_mb: 128,
3876            },
3877            depends_on: vec![],
3878            healthcheck: None,
3879            restart_policy: RestartPolicy::Always,
3880            archetype: None,
3881            stop_policy: StopPolicy {
3882                signal: 15,
3883                grace_period: workload_spec::Millis::from_secs(10),
3884            },
3885            expose: ExposeSpec {
3886                mesh: MeshExpose {
3887                    identity: MeshIdent(name.into()),
3888                    ports: vec![4325],
3889                    allow_from: vec![],
3890                },
3891                public: None,
3892                operator: None,
3893            },
3894            tenant: TenantId::singleton(),
3895            namespace: NamespaceId::singleton(),
3896            labels: Default::default(),
3897            annotations: Default::default(),
3898        }
3899    }
3900
3901    /// R568-T7. Workloads must load from the R215+ tree.
3902    ///
3903    /// Before the fix this function tested, `CloudConfig::load` read workloads
3904    /// ONLY from the pre-R215 `.yah/cloud/workloads/` — which R222-B1 emptied —
3905    /// so in any modern camp `cfg.workload(name)` returned `None` for every
3906    /// name and the entire `yah cloud workload …` surface was unreachable. The
3907    /// CLI's own error text has said `.yah/infra/workloads/` throughout, so the
3908    /// bug read as "you must have typoed the filename".
3909    ///
3910    /// Note the fixture writes NO legacy `.yah/cloud/` dir at all: that is the
3911    /// shape of a real post-R215 camp, and it is exactly the shape the old code
3912    /// could not serve.
3913    #[test]
3914    fn workloads_load_from_the_infra_tree() {
3915        let tmp = tempfile::TempDir::new().unwrap();
3916        let root = tmp.path();
3917        let dir = crate::paths::workloads_dir(root);
3918        std::fs::create_dir_all(&dir).unwrap();
3919        std::fs::write(
3920            dir.join("yah-cloud-admin.toml"),
3921            toml::to_string_pretty(&minimal_spec("yah-cloud-admin", 1)).unwrap(),
3922        )
3923        .unwrap();
3924
3925        let cfg = CloudConfig::load(root).unwrap();
3926        assert_eq!(cfg.workloads.len(), 1);
3927        assert_eq!(
3928            cfg.workload("yah-cloud-admin").unwrap().spec.replicas,
3929            1,
3930            "a workload declared under .yah/infra/workloads/ must be resolvable by name"
3931        );
3932    }
3933
3934    /// A camp mid-migration can have both trees. R215+ wins on a name
3935    /// collision — same precedence the machine loader applies — so moving a
3936    /// declaration into `.yah/infra/workloads/` takes effect immediately
3937    /// instead of being silently shadowed by the copy left behind.
3938    #[test]
3939    fn infra_workload_shadows_the_legacy_copy_of_the_same_name() {
3940        let tmp = tempfile::TempDir::new().unwrap();
3941        let root = tmp.path();
3942
3943        let legacy = make_legacy_cloud_dir(root);
3944        std::fs::create_dir_all(legacy.join("workloads")).unwrap();
3945        std::fs::write(
3946            legacy.join("workloads/shared.toml"),
3947            toml::to_string_pretty(&minimal_spec("shared", 9)).unwrap(),
3948        )
3949        .unwrap();
3950        // Legacy-only name, to prove the old tree is still read rather than
3951        // replaced wholesale.
3952        std::fs::write(
3953            legacy.join("workloads/legacy-only.toml"),
3954            toml::to_string_pretty(&minimal_spec("legacy-only", 3)).unwrap(),
3955        )
3956        .unwrap();
3957
3958        let infra = crate::paths::workloads_dir(root);
3959        std::fs::create_dir_all(&infra).unwrap();
3960        std::fs::write(
3961            infra.join("shared.toml"),
3962            toml::to_string_pretty(&minimal_spec("shared", 1)).unwrap(),
3963        )
3964        .unwrap();
3965
3966        let cfg = CloudConfig::load(root).unwrap();
3967        assert_eq!(cfg.workloads.len(), 2, "one `shared`, plus `legacy-only`");
3968        assert_eq!(
3969            cfg.workload("shared").unwrap().spec.replicas,
3970            1,
3971            "the .yah/infra/ copy must win over the legacy one"
3972        );
3973        assert_eq!(cfg.workload("legacy-only").unwrap().spec.replicas, 3);
3974    }
3975
3976    #[test]
3977    fn workload_loader_rejects_bad_spec() {
3978        use workload_spec::{
3979            ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
3980            RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
3981        };
3982
3983        let tmp = tempfile::TempDir::new().unwrap();
3984        let root = tmp.path();
3985        let cloud_dir = make_legacy_cloud_dir(root);
3986        std::fs::create_dir_all(cloud_dir.join("workloads")).unwrap();
3987
3988        // Construct a spec that round-trips through TOML but fails shape
3989        // validation: replicas = 200 is above the max of 100.
3990        let mut spec = WorkloadSpec {
3991            schema_version: SchemaVersion::V1,
3992            name: "asset-registry".into(),
3993            image: ImageRef {
3994                registry: "ghcr.io".into(),
3995                repository: "test/app".into(),
3996                tag: "v1".into(),
3997                digest: workload_spec::testing::test_digest(),
3998            },
3999            tier: TierTag("tenant".into()),
4000            replicas: 200, // ← invalid: exceeds max 100
4001            command: None,
4002            entrypoint: None,
4003            workdir: None,
4004            user: None,
4005            env: vec![],
4006            secrets: vec![],
4007            volumes: vec![],
4008            resources: ResourceLimits {
4009                memory_mb: 256,
4010                cpu_millis: 512,
4011                ephemeral_storage_mb: 512,
4012            },
4013            depends_on: vec![],
4014            healthcheck: None,
4015            restart_policy: RestartPolicy::Always,
4016            archetype: None,
4017            stop_policy: StopPolicy {
4018                signal: 15,
4019                grace_period: workload_spec::Millis::from_secs(10),
4020            },
4021            expose: ExposeSpec {
4022                mesh: MeshExpose {
4023                    identity: MeshIdent("asset-registry.pdx".into()),
4024                    ports: vec![8080],
4025                    allow_from: vec![],
4026                },
4027                public: None,
4028                operator: None,
4029            },
4030            tenant: TenantId::singleton(),
4031            namespace: NamespaceId::singleton(),
4032            labels: Default::default(),
4033            annotations: Default::default(),
4034        };
4035
4036        let toml_str = toml::to_string_pretty(&spec).unwrap();
4037        std::fs::write(cloud_dir.join("workloads/bad.toml"), &toml_str).unwrap();
4038
4039        let result = CloudConfig::load(root);
4040        assert!(
4041            result.is_err(),
4042            "loading a WorkloadSpec with replicas=200 should return Err"
4043        );
4044        let msg = result.unwrap_err().to_string();
4045        assert!(
4046            msg.contains("shape validation")
4047                || msg.contains("Replicas")
4048                || msg.contains("replicas"),
4049            "error should mention shape validation or replicas field, got: {msg}"
4050        );
4051
4052        // The `spec` binding is only used for the write — suppress warning.
4053        let _ = &mut spec;
4054    }
4055
4056    #[test]
4057    fn workload_config_save_round_trip() {
4058        use workload_spec::{
4059            ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
4060            RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
4061        };
4062
4063        let tmp = tempfile::TempDir::new().unwrap();
4064        let root = tmp.path();
4065
4066        let spec = WorkloadSpec {
4067            schema_version: SchemaVersion::V1,
4068            name: "signing-service".into(),
4069            image: ImageRef {
4070                registry: "ghcr.io".into(),
4071                repository: "noisetable/signing".into(),
4072                tag: "v2.0.0".into(),
4073                digest: workload_spec::testing::test_digest(),
4074            },
4075            tier: TierTag("private".into()),
4076            replicas: 2,
4077            command: None,
4078            entrypoint: None,
4079            workdir: None,
4080            user: None,
4081            env: vec![],
4082            secrets: vec![],
4083            volumes: vec![],
4084            resources: ResourceLimits {
4085                memory_mb: 128,
4086                cpu_millis: 256,
4087                ephemeral_storage_mb: 256,
4088            },
4089            depends_on: vec![],
4090            healthcheck: None,
4091            restart_policy: RestartPolicy::Always,
4092            archetype: None,
4093            stop_policy: StopPolicy {
4094                signal: 15,
4095                grace_period: workload_spec::Millis::from_secs(5),
4096            },
4097            expose: ExposeSpec {
4098                mesh: MeshExpose {
4099                    identity: MeshIdent("signing.pdx".into()),
4100                    ports: vec![9090],
4101                    allow_from: vec![],
4102                },
4103                public: None,
4104                operator: None,
4105            },
4106            tenant: TenantId::singleton(),
4107            namespace: NamespaceId::singleton(),
4108            labels: Default::default(),
4109            annotations: Default::default(),
4110        };
4111
4112        let wc = WorkloadConfig { spec };
4113        let cloud_dir = make_legacy_cloud_dir(root);
4114        wc.save(&cloud_dir).unwrap();
4115
4116        let loaded = CloudConfig::load(root).unwrap();
4117        assert_eq!(loaded.workloads.len(), 1);
4118        assert_eq!(loaded.workloads[0].spec.name, "signing-service");
4119        assert_eq!(loaded.workloads[0].spec.replicas, 2);
4120    }
4121
4122    #[test]
4123    fn machine_save_write_back_fingerprint() {
4124        let tmp = tempfile::TempDir::new().unwrap();
4125        let root = tmp.path();
4126
4127        let mut machine = MachineConfig {
4128            name: "test-pdx-1".into(),
4129            provider: "hetzner".into(),
4130            location: Some("pdx".into()),
4131            server_type: Some("cpx22".into()),
4132            hosts_mirrors: vec![],
4133            mesh_tags: vec![],
4134            region: None,
4135            zone: None,
4136            arch: None,
4137            bucket: None,
4138            vendor: None,
4139            nickname: None,
4140            legacy_hostkey_fingerprint: None,
4141            registration: Default::default(),
4142            ssh_keys: vec![],
4143            cloudflared: None,
4144            hosts_operator_bridge: false,
4145            connect: None,
4146            allocatable: None,
4147            taints: vec![],
4148        };
4149        machine.save(root).unwrap();
4150
4151        // Simulate A4: write back the hostkey fingerprint after provision.
4152        // R707-T1: registration is the write target; the accessor is the read.
4153        machine.registration.hostkey_fingerprint = Some("SHA256:abc123".into());
4154        machine.save(root).unwrap();
4155
4156        let reloaded: Vec<MachineConfig> = load_dir(root.join("machines")).unwrap();
4157        assert_eq!(reloaded.len(), 1);
4158        assert_eq!(reloaded[0].hostkey_fingerprint(), Some("SHA256:abc123"));
4159    }
4160
4161    // ─── New-shape (R222 B2) parse tests ────────────────────────────────────
4162    //
4163    // These mirror the Phase-A manifests committed under `.yah/services/` and
4164    // `.yah/infra/providers/`. Keeping the test strings inline (rather than
4165    // reading the on-disk files) so the loader stays runnable in any workdir
4166    // and so accidental edits to the on-disk files don't silently change
4167    // schema expectations.
4168
4169    #[test]
4170    fn provider_cloudflare_round_trips() {
4171        let src = r#"
4172schema_version = 1
4173id = "cloudflare"
4174kind = "cloudflare"
4175credentials = "keystore://cloudflare/yah"
4176default_zone = "yah.dev"
4177"#;
4178        let cfg: ProviderConfig = toml::from_str(src).unwrap();
4179        assert_eq!(cfg.id, "cloudflare");
4180        assert_eq!(cfg.kind, Provider::Cloudflare);
4181        assert_eq!(
4182            cfg.credentials.as_deref(),
4183            Some("keystore://cloudflare/yah")
4184        );
4185        assert_eq!(
4186            cfg.fields.get("default_zone").and_then(|v| v.as_str()),
4187            Some("yah.dev"),
4188        );
4189        let back = toml::to_string(&cfg).unwrap();
4190        let again: ProviderConfig = toml::from_str(&back).unwrap();
4191        assert_eq!(again.id, cfg.id);
4192        assert_eq!(again.kind, cfg.kind);
4193    }
4194
4195    #[test]
4196    fn provider_hetzner_round_trips() {
4197        let src = r#"
4198schema_version = 1
4199id = "hetzner"
4200kind = "hetzner"
4201credentials = "keystore://hetzner/yah"
4202default_location = "pdx"
4203default_server_type = "cpx11"
4204ssh_keys = []
4205"#;
4206        let cfg: ProviderConfig = toml::from_str(src).unwrap();
4207        assert_eq!(cfg.kind, Provider::Hetzner);
4208        assert_eq!(
4209            cfg.fields.get("default_location").and_then(|v| v.as_str()),
4210            Some("pdx"),
4211        );
4212        assert!(
4213            cfg.fields
4214                .get("ssh_keys")
4215                .map(|v| v.as_array().unwrap().is_empty())
4216                .unwrap_or(false),
4217            "ssh_keys must round-trip as empty array, got {:?}",
4218            cfg.fields.get("ssh_keys"),
4219        );
4220    }
4221
4222    #[test]
4223    fn provider_orbstack_local_container_round_trips() {
4224        let src = r#"
4225schema_version = 1
4226id = "orbstack"
4227kind = "local-container"
4228runtime = "auto"
4229
4230[discovery]
4231orbstack = "~/.orbstack/run/docker.sock"
4232colima   = "~/.colima/default/docker.sock"
4233docker   = "/var/run/docker.sock"
4234"#;
4235        let cfg: ProviderConfig = toml::from_str(src).unwrap();
4236        assert_eq!(cfg.kind, Provider::LocalContainer);
4237        assert_eq!(
4238            cfg.fields.get("runtime").and_then(|v| v.as_str()),
4239            Some("auto"),
4240        );
4241        let discovery = cfg
4242            .fields
4243            .get("discovery")
4244            .and_then(|v| v.as_table())
4245            .expect("discovery table");
4246        assert!(discovery.contains_key("orbstack"));
4247        assert!(discovery.contains_key("colima"));
4248        assert!(discovery.contains_key("docker"));
4249    }
4250
4251    #[test]
4252    fn provider_unknown_kind_fails() {
4253        let src = r#"
4254schema_version = 1
4255id = "made-up"
4256kind = "fly-io"
4257"#;
4258        let err = toml::from_str::<ProviderConfig>(src).unwrap_err();
4259        let msg = err.to_string();
4260        assert!(
4261            msg.contains("kind") || msg.contains("variant"),
4262            "unknown provider kind should surface as a serde error, got: {msg}"
4263        );
4264    }
4265
4266    #[test]
4267    fn service_dev_yah_round_trips() {
4268        let src = r#"
4269schema_version = 1
4270name = "dev-yah"
4271domain = "yah.dev"
4272
4273[[components]]
4274id = "site"
4275kind = "mesofact-static"
4276path = "app/yah/web"
4277role = "static"
4278"#;
4279        let cfg: ServiceConfig = toml::from_str(src).unwrap();
4280        assert_eq!(cfg.name, "dev-yah");
4281        assert_eq!(cfg.domain, "yah.dev");
4282        assert_eq!(cfg.components.len(), 1);
4283        let c = &cfg.components[0];
4284        assert_eq!(c.id, "site");
4285        assert_eq!(c.kind, "mesofact-static");
4286        assert_eq!(c.path, "app/yah/web");
4287        assert_eq!(c.role, "static");
4288        assert!(c.publishes.is_none());
4289
4290        let back = toml::to_string(&cfg).unwrap();
4291        let again: ServiceConfig = toml::from_str(&back).unwrap();
4292        assert_eq!(again.name, cfg.name);
4293        assert_eq!(again.components[0].kind, c.kind);
4294    }
4295
4296    #[test]
4297    fn mirror_prod_cloudflare_reference_parses() {
4298        let src = r#"
4299schema_version = 1
4300shape = "single-machine"
4301
4302[providers.static]
4303use = "cloudflare"
4304bucket = "yah-dev"
4305zone = "yah.dev"
4306dns = { record = "@", type = "CNAME" }
4307"#;
4308        let cfg: MirrorConfig = toml::from_str(src).unwrap();
4309        assert_eq!(cfg.shape, MirrorShape::SingleMachine);
4310        let slot = cfg.providers.get("static").expect("static slot");
4311        assert_eq!(slot.provider_id(), Some("cloudflare"));
4312        assert!(slot.inline_kind().is_none());
4313        if let MirrorProviderSlot::Reference { fields, .. } = slot {
4314            assert_eq!(
4315                fields.get("bucket").and_then(|v| v.as_str()),
4316                Some("yah-dev")
4317            );
4318            assert_eq!(fields.get("zone").and_then(|v| v.as_str()), Some("yah.dev"));
4319            let dns = fields
4320                .get("dns")
4321                .and_then(|v| v.as_table())
4322                .expect("dns table");
4323            assert_eq!(dns.get("record").and_then(|v| v.as_str()), Some("@"));
4324            assert_eq!(dns.get("type").and_then(|v| v.as_str()), Some("CNAME"));
4325        } else {
4326            panic!("expected Reference slot");
4327        }
4328    }
4329
4330    #[test]
4331    fn mirror_local_inline_static_and_orbstack_compute_parse() {
4332        let src = r#"
4333schema_version = 1
4334shape = "local"
4335
4336[providers.static]
4337kind = "local-static"
4338port = 4321
4339artifact_dir = ".yah/infra/state/local/static"
4340
4341[providers.compute]
4342use = "orbstack"
4343"#;
4344        let cfg: MirrorConfig = toml::from_str(src).unwrap();
4345        assert_eq!(cfg.shape, MirrorShape::Local);
4346
4347        let static_slot = cfg.providers.get("static").expect("static slot");
4348        assert_eq!(static_slot.inline_kind(), Some(Provider::LocalStatic));
4349        assert!(static_slot.provider_id().is_none());
4350        if let MirrorProviderSlot::Inline { fields, .. } = static_slot {
4351            assert_eq!(fields.get("port").and_then(|v| v.as_integer()), Some(4321));
4352            assert_eq!(
4353                fields.get("artifact_dir").and_then(|v| v.as_str()),
4354                Some(".yah/infra/state/local/static"),
4355            );
4356        } else {
4357            panic!("expected Inline slot for static");
4358        }
4359
4360        let compute_slot = cfg.providers.get("compute").expect("compute slot");
4361        assert_eq!(compute_slot.provider_id(), Some("orbstack"));
4362    }
4363
4364    #[test]
4365    fn mirror_pond_miniflare_minio_parse() {
4366        // pond-tier mirror: miniflare-container + minio, both inline.
4367        // T1 just needs these inline kinds to parse — the reconciler dispatch
4368        // arrives in R256-T3.
4369        let src = r#"
4370schema_version = 1
4371shape = "local"
4372
4373[providers.static]
4374kind = "miniflare-container"
4375port = 4322
4376bucket = "yah-dev"
4377
4378[providers.object_store]
4379kind = "minio-container"
4380api_port = 9000
4381console_port = 9001
4382bucket = "yah-dev"
4383"#;
4384        let cfg: MirrorConfig = toml::from_str(src).unwrap();
4385        assert_eq!(cfg.shape, MirrorShape::Local);
4386
4387        let static_slot = cfg.providers.get("static").expect("static slot");
4388        assert_eq!(
4389            static_slot.inline_kind(),
4390            Some(Provider::MiniflareContainer)
4391        );
4392        if let MirrorProviderSlot::Inline { fields, .. } = static_slot {
4393            assert_eq!(fields.get("port").and_then(|v| v.as_integer()), Some(4322));
4394            assert_eq!(
4395                fields.get("bucket").and_then(|v| v.as_str()),
4396                Some("yah-dev")
4397            );
4398        } else {
4399            panic!("expected Inline slot for miniflare-container static");
4400        }
4401
4402        let object_store_slot = cfg
4403            .providers
4404            .get("object_store")
4405            .expect("object_store slot");
4406        assert_eq!(
4407            object_store_slot.inline_kind(),
4408            Some(Provider::MinioContainer)
4409        );
4410        if let MirrorProviderSlot::Inline { fields, .. } = object_store_slot {
4411            assert_eq!(
4412                fields.get("api_port").and_then(|v| v.as_integer()),
4413                Some(9000)
4414            );
4415            assert_eq!(
4416                fields.get("console_port").and_then(|v| v.as_integer()),
4417                Some(9001)
4418            );
4419            assert_eq!(
4420                fields.get("bucket").and_then(|v| v.as_str()),
4421                Some("yah-dev")
4422            );
4423        } else {
4424            panic!("expected Inline slot for minio-container object_store");
4425        }
4426    }
4427
4428    #[test]
4429    fn provider_miniflare_container_kind_round_trips() {
4430        // Inline-only kind; never declared as a standalone provider file but
4431        // the enum round-trip is still exercised through ProviderConfig because
4432        // schemars/serde share the variant table.
4433        let cfg = MirrorProviderSlot::Inline {
4434            kind: Provider::MiniflareContainer,
4435            fields: BTreeMap::new(),
4436        };
4437        let s = toml::to_string(&cfg).unwrap();
4438        assert!(
4439            s.contains("kind = \"miniflare-container\""),
4440            "kebab-case wire form expected, got: {s}"
4441        );
4442        let back: MirrorProviderSlot = toml::from_str(&s).unwrap();
4443        assert_eq!(back.inline_kind(), Some(Provider::MiniflareContainer));
4444    }
4445
4446    #[test]
4447    fn provider_minio_container_kind_round_trips() {
4448        let cfg = MirrorProviderSlot::Inline {
4449            kind: Provider::MinioContainer,
4450            fields: BTreeMap::new(),
4451        };
4452        let s = toml::to_string(&cfg).unwrap();
4453        assert!(
4454            s.contains("kind = \"minio-container\""),
4455            "kebab-case wire form expected, got: {s}"
4456        );
4457        let back: MirrorProviderSlot = toml::from_str(&s).unwrap();
4458        assert_eq!(back.inline_kind(), Some(Provider::MinioContainer));
4459    }
4460
4461    #[test]
4462    fn mirror_compute_slot_with_machine_reference_parses() {
4463        // The on-disk prod.toml has a commented-out compute slot; this test
4464        // covers the form Phase B will need once yubaba is provisioned.
4465        let src = r#"
4466schema_version = 1
4467shape = "single-machine"
4468
4469[providers.compute]
4470use = "hetzner"
4471machine = "yah-cloud-1"
4472"#;
4473        let cfg: MirrorConfig = toml::from_str(src).unwrap();
4474        let slot = cfg.providers.get("compute").expect("compute slot");
4475        assert_eq!(slot.provider_id(), Some("hetzner"));
4476        if let MirrorProviderSlot::Reference { fields, .. } = slot {
4477            assert_eq!(
4478                fields.get("machine").and_then(|v| v.as_str()),
4479                Some("yah-cloud-1"),
4480            );
4481        }
4482    }
4483
4484    #[test]
4485    fn machine_yah_cloud_1_round_trips_with_existing_shape() {
4486        // The current machine TOML predates B2 — MachineConfig hasn't been
4487        // reshaped yet. This locks the expected shape so we notice if B3
4488        // accidentally regresses it.
4489        let src = r#"
4490name = "yah-cloud-1"
4491provider = "hetzner"
4492location = "pdx"
4493server_type = "cpx11"
4494hosts_mirrors = []
4495mesh_tags = ["tag:tier-scratch", "tag:primary-yah"]
4496ssh_keys = [111513970, 111525493]
4497"#;
4498        let cfg: MachineConfig = toml::from_str(src).unwrap();
4499        assert_eq!(cfg.name, "yah-cloud-1");
4500        assert_eq!(cfg.provider, "hetzner");
4501        assert_eq!(cfg.ssh_keys.len(), 2);
4502    }
4503
4504    #[test]
4505    fn static_node_omits_location_server_type_and_carries_connect() {
4506        // BYO Phase-0: a `static` node we brought up over SSH has no provider
4507        // DC code or SKU; it declares reach in `[connect]` instead. Must load.
4508        let src = r#"
4509name = "us-south-001"
4510provider = "static"
4511region = "us-south"
4512mesh_tags = ["tag:cloud-runner", "tag:voter-candidate"]
4513
4514[connect]
4515address = "45.32.194.254"
4516ssh = "root@45.32.194.254"
4517yubaba = "http://127.0.0.1:7443"
4518arch = "x86_64"
4519"#;
4520        let cfg: MachineConfig = toml::from_str(src).unwrap();
4521        assert_eq!(cfg.provider, "static");
4522        assert!(cfg.location.is_none());
4523        assert!(cfg.server_type.is_none());
4524        assert_eq!(cfg.location(), ""); // accessor defaults empty
4525        let c = cfg.connect.as_ref().expect("connect block");
4526        assert_eq!(c.ssh, "root@45.32.194.254");
4527        // Loopback is a *declared* reach placeholder, so it stays in [connect]
4528        // verbatim and composes straight through (R707-T1).
4529        assert_eq!(c.yubaba.as_deref(), Some("http://127.0.0.1:7443"));
4530        assert_eq!(cfg.yubaba_url().as_deref(), Some("http://127.0.0.1:7443"));
4531        assert_eq!(cfg.mesh_ipv4(), None);
4532        // Static providers have no driver, so validate() is a no-op pass.
4533        assert!(!provider_has_machine_driver(&cfg.provider));
4534        cfg.validate().unwrap();
4535    }
4536
4537    // ─── R707-T1: declaration / registration split ──────────────────────────
4538
4539    /// The pre-split shape — top-level `hostkey_fingerprint`, mesh IP baked
4540    /// into `[connect].yubaba` — must keep parsing, and must read back through
4541    /// the accessors identically. Every machine TOML in the fleet was written
4542    /// this way, and other camps' inventories still are.
4543    #[test]
4544    fn legacy_shape_still_parses_and_reads_through_accessors() {
4545        let src = r#"
4546name = "us-west-001"
4547provider = "static"
4548region = "us-west"
4549arch = "x86_64"
4550mesh_tags = ["tag:cloud-runner"]
4551hostkey_fingerprint = "SHA256:dmpq"
4552
4553[connect]
4554address = "15.204.89.240"
4555ssh = "debian@15.204.89.240"
4556yubaba = "http://100.64.0.1:7443"
4557"#;
4558        let cfg: MachineConfig = toml::from_str(src).unwrap();
4559        assert_eq!(cfg.hostkey_fingerprint(), Some("SHA256:dmpq"));
4560        assert_eq!(cfg.mesh_ipv4(), Some("100.64.0.1"));
4561        assert_eq!(cfg.yubaba_url().as_deref(), Some("http://100.64.0.1:7443"));
4562    }
4563
4564    /// The post-split shape reads identically to the legacy one above — same
4565    /// three accessor answers from a file that separates the two halves. This
4566    /// is the "unchanged in meaning" guarantee the fleet migration rests on.
4567    #[test]
4568    fn split_shape_is_equivalent_to_legacy_shape() {
4569        let legacy = r#"
4570name = "m"
4571provider = "static"
4572mesh_tags = []
4573hostkey_fingerprint = "SHA256:dmpq"
4574
4575[connect]
4576address = "15.204.89.240"
4577ssh = "debian@15.204.89.240"
4578yubaba = "http://100.64.0.1:7443"
4579"#;
4580        let split = r#"
4581name = "m"
4582provider = "static"
4583mesh_tags = []
4584
4585[connect]
4586address = "15.204.89.240"
4587ssh = "debian@15.204.89.240"
4588
4589[registration]
4590hostkey_fingerprint = "SHA256:dmpq"
4591mesh_ipv4 = "100.64.0.1"
4592"#;
4593        let old: MachineConfig = toml::from_str(legacy).unwrap();
4594        let new: MachineConfig = toml::from_str(split).unwrap();
4595        assert_eq!(old.hostkey_fingerprint(), new.hostkey_fingerprint());
4596        assert_eq!(old.mesh_ipv4(), new.mesh_ipv4());
4597        assert_eq!(old.yubaba_url(), new.yubaba_url());
4598    }
4599
4600    /// A non-default `[connect].yubaba_port` is declared reach and composes
4601    /// with the observed mesh address rather than being pinned into a URL.
4602    #[test]
4603    fn declared_port_composes_with_observed_mesh_address() {
4604        let src = r#"
4605name = "m"
4606provider = "static"
4607mesh_tags = []
4608
4609[connect]
4610address = "10.0.0.1"
4611ssh = "yah@10.0.0.1"
4612yubaba_port = 9443
4613
4614[registration]
4615mesh_ipv4 = "100.64.0.9"
4616"#;
4617        let cfg: MachineConfig = toml::from_str(src).unwrap();
4618        assert_eq!(cfg.connect.as_ref().unwrap().yubaba_port(), 9443);
4619        assert_eq!(cfg.yubaba_url().as_deref(), Some("http://100.64.0.9:9443"));
4620    }
4621
4622    /// R707-T6: the case no other test here exercises — a node that declares
4623    /// BOTH a non-loopback `[connect].yubaba` literal AND a registered
4624    /// `mesh_ipv4` (us-west-014's shape: mesh-joined, but its raft peers are
4625    /// LAN-only so the literal is what `rollout::yubaba::membership_to_nodes`
4626    /// needs). The declared literal must win — that's the whole point of the
4627    /// flip; before it, `mesh_ipv4` unconditionally won and this node's LAN
4628    /// URL was unreachable through `yubaba_url()`.
4629    #[test]
4630    fn a_declared_literal_wins_over_a_registered_mesh_address() {
4631        let src = r#"
4632name = "us-west-014"
4633provider = "static"
4634mesh_tags = []
4635
4636[connect]
4637address = "192.168.10.14"
4638ssh = "yah@192.168.10.14"
4639yubaba = "http://192.168.10.14:7443"
4640
4641[registration]
4642mesh_ipv4 = "100.64.0.6"
4643"#;
4644        let cfg: MachineConfig = toml::from_str(src).unwrap();
4645        assert_eq!(cfg.mesh_ipv4(), Some("100.64.0.6"), "still mesh-joined");
4646        assert_eq!(
4647            cfg.yubaba_url().as_deref(),
4648            Some("http://192.168.10.14:7443"),
4649            "the declared LAN literal must win over the registered mesh address"
4650        );
4651    }
4652
4653    /// `normalize` migrates in place: the legacy fingerprint moves into
4654    /// `[registration]`, the mesh IP is lifted out of the URL, and the derived
4655    /// `[connect].yubaba` is cleared so the two halves cannot drift.
4656    #[test]
4657    fn normalize_migrates_legacy_fields_and_is_idempotent() {
4658        let src = r#"
4659name = "m"
4660provider = "static"
4661mesh_tags = []
4662hostkey_fingerprint = "SHA256:dmpq"
4663
4664[connect]
4665address = "15.204.89.240"
4666ssh = "debian@15.204.89.240"
4667yubaba = "http://100.64.0.1:7443"
4668"#;
4669        let mut cfg: MachineConfig = toml::from_str(src).unwrap();
4670        cfg.normalize();
4671        assert!(cfg.legacy_hostkey_fingerprint.is_none());
4672        assert_eq!(
4673            cfg.registration.hostkey_fingerprint.as_deref(),
4674            Some("SHA256:dmpq")
4675        );
4676        assert_eq!(cfg.registration.mesh_ipv4.as_deref(), Some("100.64.0.1"));
4677        assert!(cfg.connect.as_ref().unwrap().yubaba.is_none());
4678        // Accessors still answer the same, and re-running changes nothing.
4679        assert_eq!(cfg.yubaba_url().as_deref(), Some("http://100.64.0.1:7443"));
4680        let once = format!("{cfg:?}");
4681        cfg.normalize();
4682        assert_eq!(once, format!("{cfg:?}"));
4683    }
4684
4685    /// A loopback `[connect].yubaba` is a declaration ("no mesh address yet —
4686    /// reach me through the SSH tunnel"), not a stale observation, so
4687    /// `normalize` must leave it alone. us-west-003/011/013 depend on this.
4688    #[test]
4689    fn normalize_leaves_pre_mesh_loopback_declaration_intact() {
4690        let src = r#"
4691name = "m"
4692provider = "static"
4693mesh_tags = []
4694
4695[connect]
4696address = "192.168.10.11"
4697ssh = "yah@192.168.10.11"
4698yubaba = "http://127.0.0.1:7443"
4699"#;
4700        let mut cfg: MachineConfig = toml::from_str(src).unwrap();
4701        cfg.normalize();
4702        assert_eq!(
4703            cfg.connect.as_ref().unwrap().yubaba.as_deref(),
4704            Some("http://127.0.0.1:7443")
4705        );
4706        assert!(cfg.registration.is_empty());
4707        assert_eq!(cfg.mesh_ipv4(), None);
4708    }
4709
4710    /// `save` normalizes, so a legacy file that round-trips through the writer
4711    /// comes back on the split shape with nothing lost — the property that
4712    /// keeps `yah cloud machine attach` from re-emitting the old layout.
4713    #[test]
4714    fn save_writes_the_split_shape_from_a_legacy_config() {
4715        let tmp = tempfile::TempDir::new().unwrap();
4716        let root = tmp.path();
4717        let src = r#"
4718name = "m"
4719provider = "static"
4720mesh_tags = []
4721hostkey_fingerprint = "SHA256:dmpq"
4722
4723[connect]
4724address = "15.204.89.240"
4725ssh = "debian@15.204.89.240"
4726yubaba = "http://100.64.0.1:7443"
4727"#;
4728        let cfg: MachineConfig = toml::from_str(src).unwrap();
4729        cfg.save(root).unwrap();
4730
4731        let written = std::fs::read_to_string(root.join("machines/m.toml")).unwrap();
4732        let reg_at = written
4733            .find("[registration]")
4734            .unwrap_or_else(|| panic!("no [registration] table: {written}"));
4735        let fp_at = written
4736            .find("hostkey_fingerprint")
4737            .unwrap_or_else(|| panic!("fingerprint dropped: {written}"));
4738        assert!(
4739            fp_at > reg_at,
4740            "legacy top-level field must not be re-emitted: {written}"
4741        );
4742        assert!(
4743            !written.contains("yubaba ="),
4744            "derived URL must not be re-emitted alongside mesh_ipv4: {written}"
4745        );
4746
4747        let reloaded: MachineConfig = toml::from_str(&written).unwrap();
4748        assert_eq!(reloaded.hostkey_fingerprint(), Some("SHA256:dmpq"));
4749        assert_eq!(
4750            reloaded.yubaba_url().as_deref(),
4751            Some("http://100.64.0.1:7443")
4752        );
4753    }
4754
4755    /// `[registration]` is omitted entirely for a machine nothing has been
4756    /// observed about — a scaffolded declaration stays clean.
4757    #[test]
4758    fn empty_registration_is_omitted_on_serialize() {
4759        let src = r#"
4760name = "m"
4761provider = "static"
4762mesh_tags = []
4763"#;
4764        let cfg: MachineConfig = toml::from_str(src).unwrap();
4765        assert!(cfg.registration.is_empty());
4766        let out = toml::to_string_pretty(&cfg).unwrap();
4767        assert!(!out.contains("[registration]"), "{out}");
4768    }
4769
4770    #[test]
4771    fn driver_provider_without_location_fails_validate() {
4772        // A driver-backed provider (hetzner/vultr) still MUST carry location +
4773        // server_type — the driver can't create a server without them. The
4774        // contract moved from load-time (required field) to provision-time
4775        // (validate), so the TOML loads but validate() rejects it.
4776        let src = r#"
4777name = "us-west-001"
4778provider = "hetzner"
4779mesh_tags = []
4780"#;
4781        let cfg: MachineConfig = toml::from_str(src).unwrap();
4782        assert!(provider_has_machine_driver(&cfg.provider));
4783        let err = cfg.validate().unwrap_err().to_string();
4784        assert!(
4785            err.contains("location"),
4786            "expected location complaint: {err}"
4787        );
4788    }
4789
4790    /// Helper for the new-tree integration tests below: lay out
4791    /// `<workspace>/.yah/{infra,services}/` with `dev-yah` + its mirrors and
4792    /// the three Phase-A providers (cloudflare, hetzner, orbstack).
4793    fn make_new_tree_with_dev_yah(root: &std::path::Path) {
4794        let infra = root.join(".yah").join("infra");
4795        let providers = infra.join("providers");
4796        std::fs::create_dir_all(&providers).unwrap();
4797        std::fs::write(
4798            providers.join("cloudflare.toml"),
4799            r#"schema_version = 1
4800id = "cloudflare"
4801kind = "cloudflare"
4802credentials = "keystore://cloudflare/yah"
4803default_zone = "yah.dev"
4804"#,
4805        )
4806        .unwrap();
4807        std::fs::write(
4808            providers.join("hetzner.toml"),
4809            r#"schema_version = 1
4810id = "hetzner"
4811kind = "hetzner"
4812credentials = "keystore://hetzner/yah"
4813default_location = "pdx"
4814default_server_type = "cpx11"
4815ssh_keys = []
4816"#,
4817        )
4818        .unwrap();
4819        std::fs::write(
4820            providers.join("orbstack.toml"),
4821            r#"schema_version = 1
4822id = "orbstack"
4823kind = "local-container"
4824runtime = "auto"
4825
4826[discovery]
4827orbstack = "~/.orbstack/run/docker.sock"
4828"#,
4829        )
4830        .unwrap();
4831
4832        let svc = root.join(".yah").join("services").join("dev-yah");
4833        std::fs::create_dir_all(svc.join("mirrors")).unwrap();
4834        std::fs::write(
4835            svc.join("service.toml"),
4836            r#"schema_version = 1
4837name = "dev-yah"
4838domain = "yah.dev"
4839
4840[[components]]
4841id = "site"
4842kind = "mesofact-static"
4843path = "app/yah/web"
4844role = "static"
4845"#,
4846        )
4847        .unwrap();
4848        std::fs::write(
4849            svc.join("mirrors/prod.toml"),
4850            r#"schema_version = 1
4851shape = "single-machine"
4852
4853[providers.static]
4854use = "cloudflare"
4855bucket = "yah-dev"
4856zone = "yah.dev"
4857"#,
4858        )
4859        .unwrap();
4860        std::fs::write(
4861            svc.join("mirrors/local.toml"),
4862            r#"schema_version = 1
4863shape = "local"
4864
4865[providers.static]
4866kind = "local-static"
4867port = 4321
4868
4869[providers.compute]
4870use = "orbstack"
4871"#,
4872        )
4873        .unwrap();
4874    }
4875
4876    #[test]
4877    fn cloud_config_load_new_tree_populates_providers_and_services() {
4878        let tmp = tempfile::TempDir::new().unwrap();
4879        let root = tmp.path();
4880        make_new_tree_with_dev_yah(root);
4881
4882        let cfg = CloudConfig::load(root).unwrap();
4883        assert_eq!(cfg.providers.len(), 3, "three providers loaded");
4884        assert!(cfg.provider("cloudflare").is_some());
4885        assert!(cfg.provider("hetzner").is_some());
4886        assert!(cfg.provider("orbstack").is_some());
4887
4888        let dev = cfg.service("dev-yah").expect("dev-yah service");
4889        assert_eq!(dev.service.domain, "yah.dev");
4890        assert_eq!(dev.service.components.len(), 1);
4891        assert_eq!(dev.mirrors.len(), 2);
4892        // Legacy file stems "prod" and "local" are normalised to canonical tier names.
4893        assert!(dev.mirrors.contains_key("cloud"), "prod.toml → cloud tier");
4894        assert!(dev.mirrors.contains_key("dev"), "local.toml → dev tier");
4895        assert_eq!(dev.mirrors["cloud"].shape, MirrorShape::SingleMachine);
4896        assert_eq!(dev.mirrors["dev"].shape, MirrorShape::Local);
4897
4898        // Legacy fields stay empty when no .yah/cloud/ exists.
4899        assert!(cfg.legacy_mirrors.is_empty());
4900        assert!(cfg.legacy_services.is_empty());
4901        assert!(cfg.workloads.is_empty());
4902    }
4903
4904    #[test]
4905    fn cloud_config_cross_ref_fails_on_missing_provider() {
4906        // Mirror references a provider id that doesn't exist.
4907        let tmp = tempfile::TempDir::new().unwrap();
4908        let root = tmp.path();
4909        let svc = root.join(".yah").join("services").join("dev-yah");
4910        std::fs::create_dir_all(svc.join("mirrors")).unwrap();
4911        std::fs::write(
4912            svc.join("service.toml"),
4913            "schema_version = 1\nname = \"dev-yah\"\ndomain = \"yah.dev\"\n",
4914        )
4915        .unwrap();
4916        std::fs::write(
4917            svc.join("mirrors/prod.toml"),
4918            "schema_version = 1\nshape = \"single-machine\"\n\n[providers.static]\nuse = \"fly-io\"\n",
4919        ).unwrap();
4920
4921        let err = CloudConfig::load(root).unwrap_err();
4922        let msg = err.to_string();
4923        assert!(
4924            msg.contains("fly-io"),
4925            "error should name the missing provider id, got: {msg}"
4926        );
4927        assert!(
4928            msg.contains("providers/fly-io.toml") || msg.contains("no such provider"),
4929            "error should hint at remedy, got: {msg}"
4930        );
4931    }
4932
4933    #[test]
4934    fn cloud_config_cross_ref_passes_on_inline_only_mirror() {
4935        // Inline `kind = "local-static"` doesn't require an infra provider.
4936        let tmp = tempfile::TempDir::new().unwrap();
4937        let root = tmp.path();
4938        let svc = root.join(".yah").join("services").join("local-only");
4939        std::fs::create_dir_all(svc.join("mirrors")).unwrap();
4940        std::fs::write(
4941            svc.join("service.toml"),
4942            "schema_version = 1\nname = \"local-only\"\ndomain = \"local.test\"\n",
4943        )
4944        .unwrap();
4945        std::fs::write(
4946            svc.join("mirrors/local.toml"),
4947            "schema_version = 1\nshape = \"local\"\n\n[providers.static]\nkind = \"local-static\"\nport = 8080\n",
4948        ).unwrap();
4949
4950        // Should load fine: no `use=` references, no providers required.
4951        let cfg = CloudConfig::load(root).unwrap();
4952        assert!(cfg.service("local-only").is_some());
4953    }
4954
4955    #[test]
4956    fn cloud_config_load_coexists_legacy_and_new_trees() {
4957        // Both trees present — both fields populated independently.
4958        let tmp = tempfile::TempDir::new().unwrap();
4959        let root = tmp.path();
4960        make_new_tree_with_dev_yah(root);
4961
4962        let cloud_dir = make_legacy_cloud_dir(root);
4963        std::fs::create_dir_all(cloud_dir.join("mirrors")).unwrap();
4964        std::fs::write(
4965            cloud_dir.join("mirrors/noisetable.toml"),
4966            "camp = \"noisetable\"\nregions = [\"pdx\"]\nworkloads = []\n",
4967        )
4968        .unwrap();
4969
4970        let cfg = CloudConfig::load(root).unwrap();
4971        assert_eq!(cfg.providers.len(), 3);
4972        assert!(cfg.service("dev-yah").is_some());
4973        assert_eq!(cfg.legacy_mirrors.len(), 1);
4974        assert!(cfg.legacy_mirror("noisetable").is_some());
4975    }
4976
4977    #[test]
4978    fn web_workload_round_trips() {
4979        // app/yah/web/workload.toml is parsed as a WorkloadSpec via the
4980        // workload-spec crate. The minimum-viable manifest here exercises
4981        // schema_version + kind + build fields.
4982        //
4983        // The on-disk file uses the abbreviated v1 form (kind + build); the
4984        // full WorkloadSpec is verbose, so this test asserts the new
4985        // mesofact-static abbreviated form parses as raw TOML (B3 will plumb
4986        // it through WorkloadSpec proper).
4987        let src = r#"
4988schema_version = 1
4989kind = "mesofact-static"
4990
4991[build]
4992command = "bun run build"
4993out_dir = "dist"
4994
4995routes = "./routes.ts"
4996"#;
4997        let v: toml::Value = toml::from_str(src).unwrap();
4998        assert_eq!(
4999            v.get("schema_version").and_then(|x| x.as_integer()),
5000            Some(1)
5001        );
5002        assert_eq!(
5003            v.get("kind").and_then(|x| x.as_str()),
5004            Some("mesofact-static")
5005        );
5006        let build = v
5007            .get("build")
5008            .and_then(|x| x.as_table())
5009            .expect("build table");
5010        assert_eq!(
5011            build.get("command").and_then(|x| x.as_str()),
5012            Some("bun run build")
5013        );
5014        assert_eq!(build.get("out_dir").and_then(|x| x.as_str()), Some("dist"));
5015    }
5016
5017    // ─── Canonical CRUD: ServiceConfig/MirrorConfig save + delete (R323-F1) ──
5018
5019    #[test]
5020    fn service_config_save_creates_canonical_toml_and_round_trips() {
5021        let tmp = tempfile::TempDir::new().unwrap();
5022        let root = tmp.path();
5023
5024        let svc = ServiceConfig {
5025            schema_version: 1,
5026            name: "dev-yah".into(),
5027            domain: "yah.dev".into(),
5028            db: DbCatalog::default(),
5029            components: vec![ServiceComponent {
5030                id: "site".into(),
5031                kind: "mesofact-static".into(),
5032                path: "app/yah/web".into(),
5033                role: "static".into(),
5034                publishes: Some("static".into()),
5035                wave: 0,
5036                git: None,
5037            }],
5038        };
5039        svc.save(root).unwrap();
5040
5041        // Landed at the canonical path.
5042        let path = crate::paths::service_toml(root, "dev-yah");
5043        assert!(
5044            path.exists(),
5045            "service.toml should exist at {}",
5046            path.display()
5047        );
5048
5049        // Reloads through the full CloudConfig loader (no mirrors yet).
5050        let cfg = CloudConfig::load(root).unwrap();
5051        let loaded = cfg.service("dev-yah").expect("dev-yah service");
5052        assert_eq!(loaded.service.domain, "yah.dev");
5053        assert_eq!(loaded.service.components.len(), 1);
5054        assert_eq!(
5055            loaded.service.components[0].publishes.as_deref(),
5056            Some("static")
5057        );
5058        assert!(loaded.mirrors.is_empty());
5059    }
5060
5061    #[test]
5062    fn service_config_save_overwrites_in_place() {
5063        let tmp = tempfile::TempDir::new().unwrap();
5064        let root = tmp.path();
5065
5066        let mut svc = ServiceConfig {
5067            schema_version: 1,
5068            name: "dev-yah".into(),
5069            domain: "yah.dev".into(),
5070            components: vec![],
5071            db: DbCatalog::default(),
5072        };
5073        svc.save(root).unwrap();
5074        svc.domain = "yah.example".into();
5075        svc.save(root).unwrap();
5076
5077        let cfg = CloudConfig::load(root).unwrap();
5078        assert_eq!(
5079            cfg.service("dev-yah").unwrap().service.domain,
5080            "yah.example"
5081        );
5082    }
5083
5084    #[test]
5085    fn mirror_config_save_round_trips_reference_and_inline_slots() {
5086        let tmp = tempfile::TempDir::new().unwrap();
5087        let root = tmp.path();
5088
5089        // A service must exist so the loader walks the mirrors/ dir.
5090        ServiceConfig {
5091            schema_version: 1,
5092            name: "dev-yah".into(),
5093            domain: "yah.dev".into(),
5094            components: vec![],
5095            db: DbCatalog::default(),
5096        }
5097        .save(root)
5098        .unwrap();
5099
5100        // The cloudflare provider the reference slot points at must resolve,
5101        // or CloudConfig::load's cross-ref check rejects the tree.
5102        let providers = crate::paths::providers_dir(root);
5103        std::fs::create_dir_all(&providers).unwrap();
5104        std::fs::write(
5105            providers.join("cloudflare.toml"),
5106            "schema_version = 1\nid = \"cloudflare\"\nkind = \"cloudflare\"\n",
5107        )
5108        .unwrap();
5109
5110        let mut providers_map = BTreeMap::new();
5111        providers_map.insert(
5112            "static".to_string(),
5113            MirrorProviderSlot::Reference {
5114                provider_id: "cloudflare".into(),
5115                fields: {
5116                    let mut f = BTreeMap::new();
5117                    f.insert("bucket".to_string(), toml::Value::String("yah-dev".into()));
5118                    f
5119                },
5120            },
5121        );
5122        providers_map.insert(
5123            "compute".to_string(),
5124            MirrorProviderSlot::Inline {
5125                kind: Provider::LocalStatic,
5126                fields: {
5127                    let mut f = BTreeMap::new();
5128                    f.insert("port".to_string(), toml::Value::Integer(4321));
5129                    f
5130                },
5131            },
5132        );
5133        let mirror = MirrorConfig {
5134            schema_version: 1,
5135            shape: MirrorShape::SingleMachine,
5136            providers: providers_map,
5137            ingress: Default::default(),
5138            drivers: Default::default(),
5139            asset_aliases: Default::default(),
5140        };
5141        // Save with canonical name; legacy "prod" is normalised to "cloud" on load.
5142        mirror.save(root, "dev-yah", "cloud").unwrap();
5143
5144        let path = crate::paths::service_mirror_toml(root, "dev-yah", "cloud");
5145        assert!(
5146            path.exists(),
5147            "mirror toml should exist at {}",
5148            path.display()
5149        );
5150
5151        let cfg = CloudConfig::load(root).unwrap();
5152        let loaded = &cfg.service("dev-yah").unwrap().mirrors["cloud"];
5153        assert_eq!(loaded.shape, MirrorShape::SingleMachine);
5154        assert_eq!(loaded.providers["static"].provider_id(), Some("cloudflare"));
5155        assert_eq!(
5156            loaded.providers["compute"].inline_kind(),
5157            Some(Provider::LocalStatic)
5158        );
5159    }
5160
5161    #[test]
5162    fn service_delete_removes_dir_and_mirrors() {
5163        let tmp = tempfile::TempDir::new().unwrap();
5164        let root = tmp.path();
5165
5166        let svc = ServiceConfig {
5167            schema_version: 1,
5168            name: "dev-yah".into(),
5169            domain: "yah.dev".into(),
5170            components: vec![],
5171            db: DbCatalog::default(),
5172        };
5173        svc.save(root).unwrap();
5174        MirrorConfig {
5175            schema_version: 1,
5176            shape: MirrorShape::Local,
5177            providers: BTreeMap::new(),
5178            ingress: Default::default(),
5179            drivers: Default::default(),
5180            asset_aliases: Default::default(),
5181        }
5182        .save(root, "dev-yah", "local")
5183        .unwrap();
5184
5185        assert!(
5186            ServiceConfig::delete(root, "dev-yah").unwrap(),
5187            "first delete reports true"
5188        );
5189        assert!(!crate::paths::service_dir(root, "dev-yah").exists());
5190        // Idempotent: deleting again is a no-op that reports false.
5191        assert!(!ServiceConfig::delete(root, "dev-yah").unwrap());
5192
5193        let cfg = CloudConfig::load(root).unwrap();
5194        assert!(cfg.service("dev-yah").is_none());
5195    }
5196
5197    #[test]
5198    fn mirror_delete_leaves_other_mirrors_and_service_intact() {
5199        let tmp = tempfile::TempDir::new().unwrap();
5200        let root = tmp.path();
5201
5202        ServiceConfig {
5203            schema_version: 1,
5204            name: "dev-yah".into(),
5205            domain: "yah.dev".into(),
5206            components: vec![],
5207            db: DbCatalog::default(),
5208        }
5209        .save(root)
5210        .unwrap();
5211        for env in ["prod", "local"] {
5212            MirrorConfig {
5213                schema_version: 1,
5214                shape: MirrorShape::Local,
5215                providers: BTreeMap::new(),
5216                ingress: Default::default(),
5217                drivers: Default::default(),
5218                asset_aliases: Default::default(),
5219            }
5220            .save(root, "dev-yah", env)
5221            .unwrap();
5222        }
5223
5224        assert!(MirrorConfig::delete(root, "dev-yah", "prod").unwrap());
5225        assert!(!MirrorConfig::delete(root, "dev-yah", "prod").unwrap());
5226
5227        let cfg = CloudConfig::load(root).unwrap();
5228        let svc = cfg
5229            .service("dev-yah")
5230            .expect("service survives mirror delete");
5231        // Legacy file stems are normalised on load: "prod" → "cloud", "local" → "dev".
5232        assert!(!svc.mirrors.contains_key("cloud"));
5233        assert!(svc.mirrors.contains_key("dev"));
5234    }
5235
5236    // ─── DomainConfig (R347-F2) ────────────────────────────────────────────
5237
5238    fn write_marketing_service(root: &Path) {
5239        let svc = ServiceConfig {
5240            schema_version: 1,
5241            name: "yah-marketing".into(),
5242            domain: "yah.dev".into(),
5243            db: DbCatalog::default(),
5244            components: vec![ServiceComponent {
5245                id: "site".into(),
5246                kind: "mesofact-static".into(),
5247                path: "app/yah/web".into(),
5248                role: "static".into(),
5249                publishes: None,
5250                wave: 0,
5251                git: None,
5252            }],
5253        };
5254        svc.save(root).unwrap();
5255    }
5256
5257    #[test]
5258    fn round_trip_domain_with_each_route_mode() {
5259        let dom = DomainConfig {
5260            schema_version: 1,
5261            name: "yah-dev".into(),
5262            domain: "yah.dev".into(),
5263            front_door: FrontDoor::Worker,
5264            cdn_bucket: "yah-dev".into(),
5265            worker_bundle_path: Some(".yah/workers/yah-dev/".into()),
5266            routes: vec![
5267                DomainRoute {
5268                    path: "/".into(),
5269                    mode: RouteMode::Static {
5270                        component: "yah-marketing/site".into(),
5271                    },
5272                },
5273                DomainRoute {
5274                    path: "/dashboard/api/*".into(),
5275                    mode: RouteMode::Backend {
5276                        component: "yah-dashboard/api".into(),
5277                        origin: "https://api.dashboard.yah.dev".into(),
5278                    },
5279                },
5280                DomainRoute {
5281                    path: "/old".into(),
5282                    mode: RouteMode::Redirect {
5283                        target: "https://yah.dev/blog".into(),
5284                        status: 308,
5285                    },
5286                },
5287            ],
5288        };
5289        let s = toml::to_string(&dom).unwrap();
5290        let back: DomainConfig = toml::from_str(&s).unwrap();
5291        assert_eq!(back.name, "yah-dev");
5292        assert_eq!(back.routes.len(), 3);
5293        assert!(matches!(back.routes[0].mode, RouteMode::Static { .. }));
5294        assert!(matches!(back.routes[1].mode, RouteMode::Backend { .. }));
5295        assert!(matches!(back.routes[2].mode, RouteMode::Redirect { .. }));
5296    }
5297
5298    #[test]
5299    fn redirect_status_defaults_to_308() {
5300        let src = r#"
5301schema_version = 1
5302name = "yah-dev"
5303domain = "yah.dev"
5304front_door = "worker"
5305cdn_bucket = "yah-dev"
5306
5307[[routes]]
5308path = "/old"
5309mode = "redirect"
5310target = "https://yah.dev/blog"
5311"#;
5312        let dom: DomainConfig = toml::from_str(src).unwrap();
5313        let RouteMode::Redirect { status, .. } = &dom.routes[0].mode else {
5314            panic!("expected redirect");
5315        };
5316        assert_eq!(*status, 308);
5317    }
5318
5319    #[test]
5320    fn missing_domains_dir_is_empty() {
5321        let tmp = tempfile::TempDir::new().unwrap();
5322        let cfg = CloudConfig::load(tmp.path()).unwrap();
5323        assert!(cfg.domains.is_empty());
5324    }
5325
5326    #[test]
5327    fn save_reload_roundtrip() {
5328        let tmp = tempfile::TempDir::new().unwrap();
5329        let root = tmp.path();
5330        write_marketing_service(root);
5331
5332        let dom = DomainConfig {
5333            schema_version: 1,
5334            name: "yah-dev".into(),
5335            domain: "yah.dev".into(),
5336            front_door: FrontDoor::Worker,
5337            cdn_bucket: "yah-dev".into(),
5338            worker_bundle_path: None,
5339            routes: vec![DomainRoute {
5340                path: "/".into(),
5341                mode: RouteMode::Static {
5342                    component: "yah-marketing/site".into(),
5343                },
5344            }],
5345        };
5346        dom.save(root).unwrap();
5347
5348        let cfg = CloudConfig::load(root).unwrap();
5349        let loaded = cfg.domain("yah-dev").expect("yah-dev domain");
5350        assert_eq!(loaded.domain, "yah.dev");
5351        assert_eq!(loaded.routes.len(), 1);
5352    }
5353
5354    #[test]
5355    fn delete_returns_false_when_absent() {
5356        let tmp = tempfile::TempDir::new().unwrap();
5357        assert!(!DomainConfig::delete(tmp.path(), "no-such-domain").unwrap());
5358    }
5359
5360    #[test]
5361    fn delete_returns_true_first_time() {
5362        let tmp = tempfile::TempDir::new().unwrap();
5363        let root = tmp.path();
5364        let dom = DomainConfig {
5365            schema_version: 1,
5366            name: "yah-dev".into(),
5367            domain: "yah.dev".into(),
5368            front_door: FrontDoor::BucketDirect,
5369            cdn_bucket: "yah-dev".into(),
5370            worker_bundle_path: None,
5371            routes: vec![],
5372        };
5373        dom.save(root).unwrap();
5374        assert!(DomainConfig::delete(root, "yah-dev").unwrap());
5375        assert!(!DomainConfig::delete(root, "yah-dev").unwrap());
5376    }
5377
5378    // ---- R594-F12: front-door discriminator ------------------------------
5379
5380    /// Write a raw domain manifest so the tests exercise the deserialize +
5381    /// validate path, not a hand-built struct that skipped serde.
5382    fn write_domain_toml(root: &Path, stem: &str, body: &str) {
5383        let dir = root.join(".yah").join("domains");
5384        std::fs::create_dir_all(&dir).unwrap();
5385        std::fs::write(dir.join(format!("{stem}.toml")), body).unwrap();
5386    }
5387
5388    #[test]
5389    fn front_door_is_required() {
5390        let tmp = tempfile::TempDir::new().unwrap();
5391        let root = tmp.path();
5392        write_marketing_service(root);
5393        write_domain_toml(
5394            root,
5395            "yah-dev",
5396            r#"
5397schema_version = 1
5398name = "yah-dev"
5399domain = "yah.dev"
5400cdn_bucket = "yah-dev"
5401[[routes]]
5402path = "/*"
5403mode = "static"
5404component = "yah-marketing/site"
5405"#,
5406        );
5407        let err = CloudConfig::load(root).unwrap_err().to_string();
5408        // serde's own missing-field message; the point is that omitting the
5409        // discriminator is not a silently-defaulted state.
5410        assert!(err.contains("yah-dev.toml"), "{err}");
5411    }
5412
5413    #[test]
5414    fn bucket_direct_with_routes_is_rejected() {
5415        let tmp = tempfile::TempDir::new().unwrap();
5416        let root = tmp.path();
5417        write_marketing_service(root);
5418        write_domain_toml(
5419            root,
5420            "cdn-yah-dev",
5421            r#"
5422schema_version = 1
5423name = "cdn-yah-dev"
5424domain = "cdn.yah.dev"
5425front_door = "bucket-direct"
5426cdn_bucket = "yah-dev"
5427[[routes]]
5428path = "/docs/*"
5429mode = "static"
5430component = "yah-marketing/site"
5431"#,
5432        );
5433        let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
5434        assert!(err.contains("front_door"), "{err}");
5435        assert!(err.contains("/docs/*"), "{err}");
5436    }
5437
5438    #[test]
5439    fn bucket_direct_with_worker_bundle_path_is_rejected() {
5440        let tmp = tempfile::TempDir::new().unwrap();
5441        let root = tmp.path();
5442        write_domain_toml(
5443            root,
5444            "cdn-yah-dev",
5445            r#"
5446schema_version = 1
5447name = "cdn-yah-dev"
5448domain = "cdn.yah.dev"
5449front_door = "bucket-direct"
5450cdn_bucket = "yah-dev"
5451worker_bundle_path = ".yah/workers/cdn-yah-dev/"
5452"#,
5453        );
5454        let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
5455        assert!(err.contains("worker_bundle_path"), "{err}");
5456    }
5457
5458    #[test]
5459    fn worker_with_no_routes_is_rejected() {
5460        let tmp = tempfile::TempDir::new().unwrap();
5461        let root = tmp.path();
5462        write_domain_toml(
5463            root,
5464            "yah-dev",
5465            r#"
5466schema_version = 1
5467name = "yah-dev"
5468domain = "yah.dev"
5469front_door = "worker"
5470cdn_bucket = "yah-dev"
5471"#,
5472        );
5473        let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
5474        assert!(err.contains("front_door = \"worker\""), "{err}");
5475        assert!(err.contains("404"), "{err}");
5476    }
5477
5478    #[test]
5479    fn passway_with_no_routes_is_rejected_too() {
5480        let tmp = tempfile::TempDir::new().unwrap();
5481        let root = tmp.path();
5482        write_domain_toml(
5483            root,
5484            "yah-dev",
5485            r#"
5486schema_version = 1
5487name = "yah-dev"
5488domain = "yah.dev"
5489front_door = "passway"
5490cdn_bucket = "yah-dev"
5491"#,
5492        );
5493        let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
5494        assert!(err.contains("front_door = \"passway\""), "{err}");
5495    }
5496
5497    #[test]
5498    fn bucket_direct_without_routes_loads() {
5499        let tmp = tempfile::TempDir::new().unwrap();
5500        let root = tmp.path();
5501        // Exactly the shape .yah/domains/cdn-yah-dev.toml ships (W175: a pure
5502        // asset tier deliberately has no Worker behaviours).
5503        write_domain_toml(
5504            root,
5505            "cdn-yah-dev",
5506            r#"
5507schema_version = 1
5508name = "cdn-yah-dev"
5509domain = "cdn.yah.dev"
5510front_door = "bucket-direct"
5511cdn_bucket = "yah-dev"
5512"#,
5513        );
5514        let cfg = CloudConfig::load(root).unwrap();
5515        let dom = cfg.domain("cdn-yah-dev").expect("cdn-yah-dev domain");
5516        assert_eq!(dom.front_door, FrontDoor::BucketDirect);
5517        assert!(!dom.front_door.is_route_driven());
5518    }
5519
5520    #[test]
5521    fn front_door_round_trips_through_save() {
5522        let tmp = tempfile::TempDir::new().unwrap();
5523        let root = tmp.path();
5524        write_marketing_service(root);
5525        let dom = DomainConfig {
5526            schema_version: 1,
5527            name: "yah-dev".into(),
5528            domain: "yah.dev".into(),
5529            front_door: FrontDoor::Passway,
5530            cdn_bucket: "yah-dev".into(),
5531            worker_bundle_path: None,
5532            routes: vec![DomainRoute {
5533                path: "/*".into(),
5534                mode: RouteMode::Static {
5535                    component: "yah-marketing/site".into(),
5536                },
5537            }],
5538        };
5539        dom.save(root).unwrap();
5540        let cfg = CloudConfig::load(root).unwrap();
5541        assert_eq!(
5542            cfg.domain("yah-dev").unwrap().front_door,
5543            FrontDoor::Passway
5544        );
5545    }
5546
5547    // The four manifests this repo actually ships are asserted in
5548    // `tests/live_workspace_smoke.rs` — that's the only place with a
5549    // depth-agnostic path to the live `.yah/` tree and a skip path for the
5550    // standalone mirror checkout.
5551
5552    #[test]
5553    fn cross_ref_bails_on_missing_service() {
5554        let tmp = tempfile::TempDir::new().unwrap();
5555        let root = tmp.path();
5556        // No services declared at all — component ref must fail to resolve.
5557        let dom = DomainConfig {
5558            schema_version: 1,
5559            name: "yah-dev".into(),
5560            domain: "yah.dev".into(),
5561            front_door: FrontDoor::Worker,
5562            cdn_bucket: "yah-dev".into(),
5563            worker_bundle_path: None,
5564            routes: vec![DomainRoute {
5565                path: "/".into(),
5566                mode: RouteMode::Static {
5567                    component: "yah-marketing/site".into(),
5568                },
5569            }],
5570        };
5571        dom.save(root).unwrap();
5572
5573        let err = CloudConfig::load(root).unwrap_err();
5574        let msg = format!("{err:#}");
5575        assert!(msg.contains("no such service"), "got: {msg}");
5576        assert!(msg.contains("yah-marketing"), "got: {msg}");
5577    }
5578
5579    #[test]
5580    fn cross_ref_bails_on_missing_component() {
5581        let tmp = tempfile::TempDir::new().unwrap();
5582        let root = tmp.path();
5583        write_marketing_service(root); // has component id "site", not "elsewhere"
5584
5585        let dom = DomainConfig {
5586            schema_version: 1,
5587            name: "yah-dev".into(),
5588            domain: "yah.dev".into(),
5589            front_door: FrontDoor::Worker,
5590            cdn_bucket: "yah-dev".into(),
5591            worker_bundle_path: None,
5592            routes: vec![DomainRoute {
5593                path: "/".into(),
5594                mode: RouteMode::Static {
5595                    component: "yah-marketing/elsewhere".into(),
5596                },
5597            }],
5598        };
5599        dom.save(root).unwrap();
5600
5601        let err = CloudConfig::load(root).unwrap_err();
5602        let msg = format!("{err:#}");
5603        assert!(msg.contains("no component with id"), "got: {msg}");
5604        assert!(msg.contains("elsewhere"), "got: {msg}");
5605    }
5606
5607    #[test]
5608    fn cross_ref_bails_on_malformed_ref() {
5609        let tmp = tempfile::TempDir::new().unwrap();
5610        let root = tmp.path();
5611        write_marketing_service(root);
5612
5613        let dom = DomainConfig {
5614            schema_version: 1,
5615            name: "yah-dev".into(),
5616            domain: "yah.dev".into(),
5617            front_door: FrontDoor::Worker,
5618            cdn_bucket: "yah-dev".into(),
5619            worker_bundle_path: None,
5620            routes: vec![DomainRoute {
5621                path: "/".into(),
5622                mode: RouteMode::Static {
5623                    component: "no-slash-here".into(),
5624                },
5625            }],
5626        };
5627        dom.save(root).unwrap();
5628
5629        let err = CloudConfig::load(root).unwrap_err();
5630        let msg = format!("{err:#}");
5631        assert!(msg.contains("expected"), "got: {msg}");
5632    }
5633
5634    #[test]
5635    fn redirect_routes_skip_component_validation() {
5636        let tmp = tempfile::TempDir::new().unwrap();
5637        let root = tmp.path();
5638        // No services at all — redirect must still load cleanly because it
5639        // references nothing.
5640        let dom = DomainConfig {
5641            schema_version: 1,
5642            name: "yah-dev".into(),
5643            domain: "yah.dev".into(),
5644            front_door: FrontDoor::Worker,
5645            cdn_bucket: "yah-dev".into(),
5646            worker_bundle_path: None,
5647            routes: vec![DomainRoute {
5648                path: "/old".into(),
5649                mode: RouteMode::Redirect {
5650                    target: "https://yah.dev/blog".into(),
5651                    status: 308,
5652                },
5653            }],
5654        };
5655        dom.save(root).unwrap();
5656
5657        let cfg = CloudConfig::load(root).unwrap();
5658        assert!(cfg.domain("yah-dev").is_some());
5659    }
5660
5661    #[test]
5662    fn name_must_match_file_stem() {
5663        let tmp = tempfile::TempDir::new().unwrap();
5664        let root = tmp.path();
5665        // Hand-write a file whose stem disagrees with its `name`.
5666        let dir = root.join(".yah").join("domains");
5667        std::fs::create_dir_all(&dir).unwrap();
5668        std::fs::write(
5669            dir.join("yah-dev.toml"),
5670            r#"schema_version = 1
5671name = "different-name"
5672domain = "yah.dev"
5673front_door = "bucket-direct"
5674cdn_bucket = "yah-dev"
5675"#,
5676        )
5677        .unwrap();
5678
5679        let err = CloudConfig::load(root).unwrap_err();
5680        let msg = format!("{err:#}");
5681        assert!(msg.contains("must match the file stem"), "got: {msg}");
5682    }
5683
5684    #[test]
5685    fn net_alias_tier_subdomain_manifest_loads_and_cross_refs() {
5686        // R561-F2: a per-tenant subdomain manifest on the net.yah.dev wildcard
5687        // alias tier is just a DomainConfig whose `domain` is `<name>.net.yah.dev`
5688        // and whose static route cross-refs the tenant's service component.
5689        // This is exactly the shape .yah/domains/scrabcake-net-yah-dev.toml ships.
5690        let tmp = tempfile::TempDir::new().unwrap();
5691        let root = tmp.path();
5692        write_marketing_service(root); // service "yah-marketing", component "site"
5693
5694        let dom = DomainConfig {
5695            schema_version: 1,
5696            name: "tenant-net-yah-dev".into(),
5697            domain: "tenant.net.yah.dev".into(),
5698            front_door: FrontDoor::Worker,
5699            cdn_bucket: "net-yah-dev".into(), // shared per-tier bucket
5700            worker_bundle_path: None,
5701            routes: vec![DomainRoute {
5702                path: "/*".into(),
5703                mode: RouteMode::Static {
5704                    component: "yah-marketing/site".into(),
5705                },
5706            }],
5707        };
5708        dom.save(root).unwrap();
5709
5710        let cfg = CloudConfig::load(root).unwrap();
5711        let dom = cfg
5712            .domain("tenant-net-yah-dev")
5713            .expect("net-tier subdomain manifest should load");
5714        assert_eq!(dom.domain, "tenant.net.yah.dev");
5715        assert_eq!(dom.cdn_bucket, "net-yah-dev");
5716    }
5717
5718    // ─── R572-F3: NodeAllocatable + taints ──────────────────────────────────
5719
5720    #[test]
5721    fn machine_allocatable_round_trips() {
5722        let toml_src = r#"
5723name = "us-west-001"
5724provider = "static"
5725mesh_tags = ["tag:cloud-runner"]
5726[allocatable]
5727memory_mb = 3800
5728cpu_millis = 2000
5729"#;
5730        let m: MachineConfig = toml::from_str(toml_src).unwrap();
5731        let a = m.allocatable.as_ref().expect("allocatable should parse");
5732        assert_eq!(a.memory_mb, 3800);
5733        assert_eq!(a.cpu_millis, 2000);
5734
5735        let s = toml::to_string(&m).unwrap();
5736        let back: MachineConfig = toml::from_str(&s).unwrap();
5737        let a2 = back.allocatable.as_ref().unwrap();
5738        assert_eq!(a2.memory_mb, 3800);
5739        assert_eq!(a2.cpu_millis, 2000);
5740    }
5741
5742    #[test]
5743    fn machine_taints_round_trips() {
5744        let toml_src = r#"
5745name = "us-south-001"
5746provider = "static"
5747mesh_tags = ["tag:cloud-runner"]
5748taints = ["no-appliance"]
5749"#;
5750        let m: MachineConfig = toml::from_str(toml_src).unwrap();
5751        assert_eq!(m.taints, vec!["no-appliance"]);
5752
5753        let s = toml::to_string(&m).unwrap();
5754        let back: MachineConfig = toml::from_str(&s).unwrap();
5755        assert_eq!(back.taints, vec!["no-appliance"]);
5756    }
5757
5758    #[test]
5759    fn machine_allocatable_absent_is_none() {
5760        let toml_src = "name = \"node\"\nprovider = \"static\"\nmesh_tags = []\n";
5761        let m: MachineConfig = toml::from_str(toml_src).unwrap();
5762        assert!(m.allocatable.is_none());
5763        assert!(m.taints.is_empty());
5764    }
5765
5766    #[test]
5767    fn machine_allocatable_skipped_when_none() {
5768        let m = make_machine("node", vec![]);
5769        let s = toml::to_string(&m).unwrap();
5770        assert!(
5771            !s.contains("allocatable"),
5772            "None allocatable must be omitted: {s}"
5773        );
5774        assert!(!s.contains("taints"), "empty taints must be omitted: {s}");
5775    }
5776
5777    #[test]
5778    fn machine_multiple_taints_round_trip() {
5779        let toml_src = r#"
5780name = "us-west-002"
5781provider = "static"
5782mesh_tags = ["tag:build-worker"]
5783taints = ["no-server", "no-appliance", "no-voter"]
5784"#;
5785        let m: MachineConfig = toml::from_str(toml_src).unwrap();
5786        assert_eq!(m.taints.len(), 3);
5787        assert!(m.taints.contains(&"no-server".to_string()));
5788        assert!(m.taints.contains(&"no-appliance".to_string()));
5789        assert!(m.taints.contains(&"no-voter".to_string()));
5790    }
5791
5792    // ─── R572-F5: capacity floor + repel-unless-tolerate taints ─────────────
5793
5794    fn make_machine_with_capacity(
5795        name: &str,
5796        memory_mb: u32,
5797        cpu_millis: u32,
5798        taints: Vec<&str>,
5799    ) -> MachineConfig {
5800        MachineConfig {
5801            allocatable: Some(NodeAllocatable {
5802                memory_mb,
5803                cpu_millis,
5804            }),
5805            taints: taints.into_iter().map(String::from).collect(),
5806            ..make_machine(name, vec![])
5807        }
5808    }
5809
5810    fn server_spec(memory_mb: u32, cpu_millis: u32) -> WorkloadSpec {
5811        use workload_spec::{ImageRef, LifecycleArchetype, ResourceLimits, TierTag};
5812        let mut ws = WorkloadSpec::for_forge(
5813            "f5-test",
5814            ImageRef {
5815                registry: "localhost".into(),
5816                repository: "test".into(),
5817                tag: "latest".into(),
5818                digest: workload_spec::testing::test_digest(),
5819            },
5820            TierTag("infra".into()),
5821            vec![],
5822        );
5823        ws.archetype = Some(LifecycleArchetype::Server);
5824        ws.resources = ResourceLimits {
5825            memory_mb,
5826            cpu_millis,
5827            ephemeral_storage_mb: 0,
5828        };
5829        ws
5830    }
5831
5832    fn appliance_spec_ws(memory_mb: u32, cpu_millis: u32) -> WorkloadSpec {
5833        use workload_spec::LifecycleArchetype;
5834        let mut ws = server_spec(memory_mb, cpu_millis);
5835        ws.archetype = Some(LifecycleArchetype::Appliance);
5836        ws
5837    }
5838
5839    #[test]
5840    fn capacity_floor_rejects_undersized_node() {
5841        let cfg = make_empty_cfg(vec![make_machine_with_capacity("small", 256, 500, vec![])]);
5842        let ws = server_spec(512, 1000); // demands more than available
5843        assert!(cfg.admit_workload(&ws).is_err());
5844    }
5845
5846    #[test]
5847    fn capacity_floor_accepts_exact_fit() {
5848        let cfg = make_empty_cfg(vec![make_machine_with_capacity("exact", 512, 1000, vec![])]);
5849        let ws = server_spec(512, 1000);
5850        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "exact");
5851    }
5852
5853    #[test]
5854    fn capacity_floor_passes_when_allocatable_absent() {
5855        // A machine with no allocatable block skips the capacity check (no data).
5856        let cfg = make_empty_cfg(vec![make_machine("no-alloc", vec![])]);
5857        let ws = server_spec(99999, 99999); // would exceed any real node
5858        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "no-alloc");
5859    }
5860
5861    #[test]
5862    fn taint_repulsion_blocks_appliance_on_no_appliance_node() {
5863        let cfg = make_empty_cfg(vec![make_machine_with_capacity(
5864            "south",
5865            1024,
5866            2000,
5867            vec!["no-appliance"],
5868        )]);
5869        let ws = appliance_spec_ws(256, 500);
5870        assert!(
5871            cfg.admit_workload(&ws).is_err(),
5872            "appliance must be repelled by no-appliance taint"
5873        );
5874    }
5875
5876    #[test]
5877    fn taint_repulsion_allows_server_on_no_appliance_node() {
5878        // "no-appliance" only repels Appliance workloads; servers are unaffected.
5879        let cfg = make_empty_cfg(vec![make_machine_with_capacity(
5880            "south",
5881            1024,
5882            2000,
5883            vec!["no-appliance"],
5884        )]);
5885        let ws = server_spec(256, 500);
5886        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "south");
5887    }
5888
5889    #[test]
5890    fn taint_repulsion_job_not_blocked_by_no_server() {
5891        use workload_spec::LifecycleArchetype;
5892        let cfg = make_empty_cfg(vec![make_machine_with_capacity(
5893            "build-box",
5894            8192,
5895            4000,
5896            vec!["no-server", "no-appliance"],
5897        )]);
5898        let mut ws = server_spec(256, 500);
5899        ws.archetype = Some(LifecycleArchetype::Job);
5900        // Job only repelled by "no-job"; "no-server" and "no-appliance" don't affect it.
5901        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "build-box");
5902    }
5903
5904    #[test]
5905    fn requires_taint_affinity_blocks_placement_without_it() {
5906        use workload_spec::{LifecycleArchetype, PUBLIC_IP_TAINT, REQUIRES_TAINT_ANNOTATION};
5907        // Simulate the passway ingress appliance: requires "public-ip" taint.
5908        let mut ws = appliance_spec_ws(256, 512);
5909        ws.archetype = Some(LifecycleArchetype::Appliance);
5910        ws.annotations
5911            .insert(REQUIRES_TAINT_ANNOTATION.into(), PUBLIC_IP_TAINT.into());
5912
5913        // Node without the taint: rejected.
5914        let cfg = make_empty_cfg(vec![make_machine_with_capacity(
5915            "no-pip",
5916            2048,
5917            2000,
5918            vec![],
5919        )]);
5920        assert!(cfg.admit_workload(&ws).is_err());
5921
5922        // Node with the taint: accepted.
5923        let cfg = make_empty_cfg(vec![make_machine_with_capacity(
5924            "pub-node",
5925            2048,
5926            2000,
5927            vec!["public-ip"],
5928        )]);
5929        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "pub-node");
5930    }
5931
5932    #[test]
5933    fn w244_fleet_scenario_appliance_rejected_from_south_and_west002() {
5934        // Full W244 fleet table scenario:
5935        // us-west-001/east-001: no taints, large capacity → appliance lands here
5936        // us-south-001: no-appliance taint → appliance rejected
5937        // us-west-002: no-server, no-appliance, no-voter → appliance rejected
5938        let cfg = make_empty_cfg(vec![
5939            make_machine_with_capacity("us-south-001", 512, 1000, vec!["no-appliance"]),
5940            make_machine_with_capacity(
5941                "us-west-002",
5942                16384,
5943                8000,
5944                vec!["no-server", "no-appliance", "no-voter"],
5945            ),
5946            make_machine_with_capacity("us-west-001", 4096, 4000, vec![]),
5947        ]);
5948        let ws = appliance_spec_ws(256, 500);
5949        // Skips south (no-appliance) and west-002 (no-appliance), lands on west-001.
5950        assert_eq!(cfg.admit_workload(&ws).unwrap().name, "us-west-001");
5951    }
5952
5953    #[test]
5954    fn w244_fleet_scenario_job_lands_on_west002_first() {
5955        use workload_spec::LifecycleArchetype;
5956        // Jobs should prefer (or at least land on) the job-only box.
5957        let cfg = make_empty_cfg(vec![
5958            make_machine_with_capacity("us-west-001", 4096, 4000, vec![]),
5959            make_machine_with_capacity(
5960                "us-west-002",
5961                16384,
5962                8000,
5963                vec!["no-server", "no-appliance", "no-voter"],
5964            ),
5965        ]);
5966        let mut ws = server_spec(256, 500);
5967        ws.archetype = Some(LifecycleArchetype::Job);
5968        // Jobs tolerate all fleet taints; west-001 comes first in declaration
5969        // order (greedy, no preference), which is the expected tie-break.
5970        let picked = cfg.admit_workload(&ws).unwrap();
5971        // Both are eligible (Job tolerates no-server/no-appliance/no-voter).
5972        assert!(
5973            picked.name == "us-west-001" || picked.name == "us-west-002",
5974            "job must land on an eligible node, got {}",
5975            picked.name
5976        );
5977    }
5978
5979    #[test]
5980    fn r569_f4_macos_node_taints_keep_cloud_critical_off_but_admit_build_jobs() {
5981        use workload_spec::LifecycleArchetype;
5982        // R569-F4: the headless M2 (us-west-015) joins the fleet as a
5983        // build-worker but must never take cloud-critical load. It carries the
5984        // same repel set as the rpi/x86 build-worker pool
5985        // (`no-server, no-appliance, no-voter` — see
5986        // .yah/infra/machines/us-west-015.toml). This pins that intent: with a
5987        // plain cloud node available beside the Mac, every cloud-critical
5988        // archetype lands on the cloud node and never the Mac; build Jobs
5989        // (the Mac's actual purpose) remain eligible on it. `no-voter` is
5990        // honored separately by R569-F3's learner-only join, not by workload
5991        // admission — there is no "voter" workload archetype.
5992        let mac_taints = vec!["no-server", "no-appliance", "no-voter"];
5993        let fleet = || {
5994            make_empty_cfg(vec![
5995                make_machine_with_capacity("us-west-015", 24576, 8000, mac_taints.clone()),
5996                make_machine_with_capacity("us-west-001", 4096, 4000, vec![]),
5997            ])
5998        };
5999
6000        // A cloud-critical Server workload is repelled from the Mac and lands
6001        // on the untainted cloud node.
6002        let cfg = fleet();
6003        assert_eq!(
6004            cfg.admit_workload(&server_spec(256, 500)).unwrap().name,
6005            "us-west-001",
6006            "a Server workload must never land on the no-server Mac node"
6007        );
6008
6009        // Same for an Appliance (pinned/stateful cloud-critical) workload.
6010        let cfg = fleet();
6011        assert_eq!(
6012            cfg.admit_workload(&appliance_spec_ws(256, 500))
6013                .unwrap()
6014                .name,
6015            "us-west-001",
6016            "an Appliance workload must never land on the no-appliance Mac node"
6017        );
6018
6019        // Sharpest repulsion proof: with ONLY the Mac in the fleet, a
6020        // cloud-critical Server workload is rejected outright — the taint keeps
6021        // it off even when that means nowhere to run.
6022        let mac_only = make_empty_cfg(vec![make_machine_with_capacity(
6023            "us-west-015",
6024            24576,
6025            8000,
6026            mac_taints.clone(),
6027        )]);
6028        assert!(
6029            mac_only.admit_workload(&server_spec(256, 500)).is_err(),
6030            "a Server workload must be repelled from a Mac-only fleet, not admitted"
6031        );
6032
6033        // But the Mac's real job — build/forge workloads — IS admitted on it:
6034        // it tolerates every fleet taint (there is no `no-job`).
6035        let mut job = server_spec(256, 500);
6036        job.archetype = Some(LifecycleArchetype::Job);
6037        assert_eq!(
6038            mac_only.admit_workload(&job).unwrap().name,
6039            "us-west-015",
6040            "a build Job must still be admitted on the Mac build-worker"
6041        );
6042    }
6043
6044    // ─── R615-F1: linked infra sources (`.yah/infra/sources.toml`) ─────────
6045
6046    #[test]
6047    fn sources_load_is_empty_when_the_file_is_absent() {
6048        // "Every camp without linked infra has none" — which today is every
6049        // camp — must not be an error.
6050        let tmp = tempfile::TempDir::new().unwrap();
6051        let cfg = SourcesConfig::load(tmp.path()).unwrap();
6052        assert_eq!(cfg, SourcesConfig::default());
6053        assert!(cfg.source.is_empty());
6054        assert_eq!(cfg.schema_version, 1);
6055    }
6056
6057    #[test]
6058    fn sources_parses_a_path_kind_exactly_like_w274s_example() {
6059        let tmp = tempfile::TempDir::new().unwrap();
6060        std::fs::write(
6061            tmp.path().join("sources.toml"),
6062            r#"
6063schema_version = 1
6064
6065[[source]]
6066owner = "yah"
6067kind  = "path"
6068path  = "../yah"
6069mode  = "read-only"
6070"#,
6071        )
6072        .unwrap();
6073        let cfg = SourcesConfig::load(tmp.path()).unwrap();
6074        assert_eq!(cfg.source.len(), 1);
6075        let s = &cfg.source[0];
6076        assert_eq!(s.owner, "yah");
6077        assert_eq!(s.mode, SourceMode::ReadOnly);
6078        assert!(s.select.is_empty());
6079        match &s.kind {
6080            InfraSourceKind::Path { path } => assert_eq!(path, "../yah"),
6081            other => panic!("expected Path, got {other:?}"),
6082        }
6083    }
6084
6085    #[test]
6086    fn sources_parses_a_git_kind_reusing_gitsource_verbatim() {
6087        let tmp = tempfile::TempDir::new().unwrap();
6088        std::fs::write(
6089            tmp.path().join("sources.toml"),
6090            r#"
6091schema_version = 1
6092
6093[[source]]
6094owner  = "yah"
6095kind   = "git"
6096repo   = "git@github.com:yah-ai/infra.git"
6097ref    = "main"
6098subdir = "infra"
6099select = ["tag:cloud-runner"]
6100mode   = "read-only"
6101"#,
6102        )
6103        .unwrap();
6104        let cfg = SourcesConfig::load(tmp.path()).unwrap();
6105        assert_eq!(cfg.source.len(), 1);
6106        let s = &cfg.source[0];
6107        assert_eq!(s.select, vec!["tag:cloud-runner".to_string()]);
6108        match &s.kind {
6109            InfraSourceKind::Git(git) => {
6110                assert_eq!(git.repo, "git@github.com:yah-ai/infra.git");
6111                assert_eq!(git.r#ref, "main");
6112                assert_eq!(git.subdir.as_deref(), Some("infra"));
6113            }
6114            other => panic!("expected Git, got {other:?}"),
6115        }
6116    }
6117
6118    #[test]
6119    fn sources_mode_defaults_to_read_only_and_manage_is_explicit() {
6120        let tmp = tempfile::TempDir::new().unwrap();
6121        std::fs::write(
6122            tmp.path().join("sources.toml"),
6123            r#"
6124schema_version = 1
6125
6126[[source]]
6127owner = "a"
6128kind  = "path"
6129path  = "../a"
6130
6131[[source]]
6132owner = "b"
6133kind  = "path"
6134path  = "../b"
6135mode  = "manage"
6136"#,
6137        )
6138        .unwrap();
6139        let cfg = SourcesConfig::load(tmp.path()).unwrap();
6140        assert_eq!(cfg.source[0].mode, SourceMode::ReadOnly, "omitted mode = read-only");
6141        assert_eq!(cfg.source[1].mode, SourceMode::Manage);
6142    }
6143
6144    #[test]
6145    fn sources_preserves_declaration_order() {
6146        // Overlay order matters (R615-F2) when two sources name the same
6147        // machine — the list must round-trip in file order, not be reordered
6148        // by owner or kind.
6149        let tmp = tempfile::TempDir::new().unwrap();
6150        std::fs::write(
6151            tmp.path().join("sources.toml"),
6152            r#"
6153schema_version = 1
6154
6155[[source]]
6156owner = "second"
6157kind  = "path"
6158path  = "../second"
6159
6160[[source]]
6161owner = "first"
6162kind  = "path"
6163path  = "../first"
6164"#,
6165        )
6166        .unwrap();
6167        let cfg = SourcesConfig::load(tmp.path()).unwrap();
6168        let owners: Vec<&str> = cfg.source.iter().map(|s| s.owner.as_str()).collect();
6169        assert_eq!(owners, vec!["second", "first"]);
6170    }
6171
6172    #[test]
6173    fn sources_round_trips_through_serialize() {
6174        let cfg = SourcesConfig {
6175            schema_version: 1,
6176            source: vec![
6177                InfraSource {
6178                    owner: "yah".into(),
6179                    kind: InfraSourceKind::Path {
6180                        path: "../yah".into(),
6181                    },
6182                    mode: SourceMode::ReadOnly,
6183                    select: vec![],
6184                },
6185                InfraSource {
6186                    owner: "yah".into(),
6187                    kind: InfraSourceKind::Git(GitSource {
6188                        repo: "git@github.com:yah-ai/infra.git".into(),
6189                        r#ref: "main".into(),
6190                        subdir: Some("infra".into()),
6191                    }),
6192                    mode: SourceMode::Manage,
6193                    select: vec!["tag:cloud-runner".into()],
6194                },
6195            ],
6196        };
6197        let toml_str = toml::to_string_pretty(&cfg).unwrap();
6198        let reloaded: SourcesConfig = toml::from_str(&toml_str).unwrap();
6199        assert_eq!(reloaded, cfg, "round-trip through TOML must be lossless:\n{toml_str}");
6200    }
6201
6202    // ─── R615-F2: overlay loader in CloudConfig::load ───────────────────────
6203
6204    fn write_min_machine(dir: &Path, name: &str, extra_toml: &str) {
6205        std::fs::create_dir_all(dir).unwrap();
6206        // `extra_toml` supplies `mesh_tags` when the caller cares about it;
6207        // otherwise default to the empty list. Never hardcode `mesh_tags`
6208        // here as well as in `extra_toml` -- TOML rejects a duplicate key.
6209        let mesh_tags = if extra_toml.contains("mesh_tags") {
6210            String::new()
6211        } else {
6212            "mesh_tags = []\n".to_string()
6213        };
6214        std::fs::write(
6215            dir.join(format!("{name}.toml")),
6216            format!("name = \"{name}\"\nprovider = \"static\"\n{mesh_tags}{extra_toml}"),
6217        )
6218        .unwrap();
6219    }
6220
6221    fn write_min_provider(dir: &Path, id: &str) {
6222        std::fs::create_dir_all(dir).unwrap();
6223        std::fs::write(
6224            dir.join(format!("{id}.toml")),
6225            format!("schema_version = 1\nid = \"{id}\"\nkind = \"static\"\n"),
6226        )
6227        .unwrap();
6228    }
6229
6230    fn write_sources_toml(camp_root: &Path, body: &str) {
6231        let dir = camp_root.join(".yah/infra");
6232        std::fs::create_dir_all(&dir).unwrap();
6233        std::fs::write(dir.join("sources.toml"), body).unwrap();
6234    }
6235
6236    #[test]
6237    fn load_with_no_sources_toml_is_unchanged() {
6238        let tmp = tempfile::TempDir::new().unwrap();
6239        write_min_machine(&tmp.path().join(".yah/infra/machines"), "local-1", "");
6240        let cfg = CloudConfig::load(tmp.path()).unwrap();
6241        assert_eq!(cfg.machines.len(), 1);
6242        assert!(cfg.machine_origins.is_empty());
6243        assert!(cfg.provider_origins.is_empty());
6244    }
6245
6246    #[test]
6247    fn path_source_overlays_machines_and_providers_tagged_with_origin() {
6248        let camp = tempfile::TempDir::new().unwrap();
6249        let other = tempfile::TempDir::new().unwrap();
6250        write_min_machine(&other.path().join(".yah/infra/machines"), "borrowed-1", "");
6251        write_min_provider(&other.path().join(".yah/infra/providers"), "borrowed-provider");
6252        write_sources_toml(
6253            camp.path(),
6254            &format!(
6255                "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
6256                other.path().display()
6257            ),
6258        );
6259
6260        let cfg = CloudConfig::load(camp.path()).unwrap();
6261        assert_eq!(cfg.machines.len(), 1);
6262        assert_eq!(cfg.machines[0].name, "borrowed-1");
6263        assert_eq!(cfg.providers.len(), 1);
6264        assert_eq!(cfg.providers[0].id, "borrowed-provider");
6265
6266        let origin = cfg.machine_origins.get("borrowed-1").expect("origin recorded");
6267        assert_eq!(origin.owner, "other");
6268        assert_eq!(origin.mode, SourceMode::ReadOnly);
6269        assert!(origin.source.starts_with("path:"));
6270        assert_eq!(
6271            cfg.provider_origins.get("borrowed-provider").unwrap().owner,
6272            "other"
6273        );
6274    }
6275
6276    #[test]
6277    fn camp_local_wins_on_name_collision_and_carries_no_origin() {
6278        let camp = tempfile::TempDir::new().unwrap();
6279        let other = tempfile::TempDir::new().unwrap();
6280        // Both declare a machine named "shared" -- camp-local's copy must win,
6281        // and it must never gain an origin tag.
6282        write_min_machine(&camp.path().join(".yah/infra/machines"), "shared", "");
6283        write_min_machine(
6284            &other.path().join(".yah/infra/machines"),
6285            "shared",
6286            "nickname = \"the borrowed one\"\n",
6287        );
6288        write_sources_toml(
6289            camp.path(),
6290            &format!(
6291                "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
6292                other.path().display()
6293            ),
6294        );
6295
6296        let cfg = CloudConfig::load(camp.path()).unwrap();
6297        assert_eq!(cfg.machines.len(), 1, "the name collides, so exactly one entry");
6298        assert_eq!(cfg.machines[0].nickname, None, "camp-local's copy, not the borrowed one");
6299        assert!(
6300            !cfg.machine_origins.contains_key("shared"),
6301            "camp-local entries never carry an origin tag"
6302        );
6303    }
6304
6305    #[test]
6306    fn an_earlier_source_wins_over_a_later_one_on_collision() {
6307        let camp = tempfile::TempDir::new().unwrap();
6308        let first = tempfile::TempDir::new().unwrap();
6309        let second = tempfile::TempDir::new().unwrap();
6310        write_min_machine(&first.path().join(".yah/infra/machines"), "dup", "");
6311        write_min_machine(&second.path().join(".yah/infra/machines"), "dup", "");
6312        write_sources_toml(
6313            camp.path(),
6314            &format!(
6315                "schema_version = 1\n\n[[source]]\nowner = \"first\"\nkind = \"path\"\npath = \"{}\"\n\n[[source]]\nowner = \"second\"\nkind = \"path\"\npath = \"{}\"\n",
6316                first.path().display(),
6317                second.path().display()
6318            ),
6319        );
6320
6321        let cfg = CloudConfig::load(camp.path()).unwrap();
6322        assert_eq!(cfg.machines.len(), 1);
6323        assert_eq!(cfg.machine_origins.get("dup").unwrap().owner, "first");
6324    }
6325
6326    #[test]
6327    fn select_filters_borrowed_machines_by_name_or_mesh_tag() {
6328        let camp = tempfile::TempDir::new().unwrap();
6329        let other = tempfile::TempDir::new().unwrap();
6330        write_min_machine(&other.path().join(".yah/infra/machines"), "runner-1", "mesh_tags = [\"tag:cloud-runner\"]\n");
6331        write_min_machine(&other.path().join(".yah/infra/machines"), "excluded-1", "");
6332        write_sources_toml(
6333            camp.path(),
6334            &format!(
6335                "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\nselect = [\"tag:cloud-runner\"]\n",
6336                other.path().display()
6337            ),
6338        );
6339
6340        let cfg = CloudConfig::load(camp.path()).unwrap();
6341        assert_eq!(cfg.machines.len(), 1);
6342        assert_eq!(cfg.machines[0].name, "runner-1");
6343    }
6344
6345    #[test]
6346    fn one_unparseable_foreign_machine_does_not_sink_the_rest_of_the_directory_or_the_load() {
6347        let camp = tempfile::TempDir::new().unwrap();
6348        let other = tempfile::TempDir::new().unwrap();
6349        let dir = other.path().join(".yah/infra/machines");
6350        write_min_machine(&dir, "good", "");
6351        // Schema-skew gotcha: a foreign machine this binary's MachineConfig
6352        // can't parse at all (not just an unknown field -- MachineConfig has
6353        // no deny_unknown_fields, so this has to fail on a TYPE, not a name).
6354        std::fs::write(dir.join("bad.toml"), "name = 1\nprovider = 2\n").unwrap();
6355        write_sources_toml(
6356            camp.path(),
6357            &format!(
6358                "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
6359                other.path().display()
6360            ),
6361        );
6362
6363        // Must not error at all -- camp-local load must never fail because a
6364        // source it doesn't own has one bad file.
6365        let cfg = CloudConfig::load(camp.path()).unwrap();
6366        assert_eq!(cfg.machines.len(), 1, "the good entry still loads");
6367        assert_eq!(cfg.machines[0].name, "good");
6368    }
6369
6370    #[test]
6371    fn an_unsynced_git_source_overlays_nothing_and_is_not_an_error() {
6372        // No `yah infra sync` (R615-T3) has ever run, so the cache dir this
6373        // resolves to doesn't exist. Must be silent, not fatal.
6374        let camp = tempfile::TempDir::new().unwrap();
6375        write_sources_toml(
6376            camp.path(),
6377            "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"git\"\nrepo = \"git@github.com:yah-ai/infra.git\"\nref = \"main\"\n",
6378        );
6379        let cfg = CloudConfig::load(camp.path()).unwrap();
6380        assert!(cfg.machines.is_empty());
6381        assert!(cfg.machine_origins.is_empty());
6382    }
6383
6384    #[test]
6385    fn a_synced_git_source_reads_from_the_cache_dir_not_the_repo_path() {
6386        // No `subdir` declared -- the checkout ROOT is the infra root.
6387        let camp = tempfile::TempDir::new().unwrap();
6388        let cache = crate::paths::infra_source_cache_dir(camp.path(), "yah");
6389        write_min_machine(&cache.join("machines"), "synced-1", "");
6390        write_sources_toml(
6391            camp.path(),
6392            "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"git\"\nrepo = \"git@github.com:yah-ai/infra.git\"\nref = \"main\"\n",
6393        );
6394        let cfg = CloudConfig::load(camp.path()).unwrap();
6395        assert_eq!(cfg.machines.len(), 1);
6396        assert_eq!(cfg.machines[0].name, "synced-1");
6397        assert!(cfg.machine_origins.get("synced-1").unwrap().source.starts_with("git:"));
6398    }
6399
6400    #[test]
6401    fn a_git_sources_subdir_is_honoured_like_the_component_case() {
6402        // W274's own example declares `subdir = "infra"` for a monorepo whose
6403        // registry lives under a subdirectory of the clone rather than at its
6404        // root -- prove `infra_root` actually reads it, not just `.subdir` on
6405        // GitSource parsing (R615-F1 already covers that half).
6406        let camp = tempfile::TempDir::new().unwrap();
6407        let cache = crate::paths::infra_source_cache_dir(camp.path(), "yah");
6408        write_min_machine(&cache.join("infra").join("machines"), "subdir-1", "");
6409        // Also plant a decoy at the checkout root to prove the root itself is
6410        // NOT read when a subdir is declared.
6411        write_min_machine(&cache.join("machines"), "root-decoy", "");
6412        write_sources_toml(
6413            camp.path(),
6414            "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"git\"\nrepo = \"git@github.com:yah-ai/infra.git\"\nref = \"main\"\nsubdir = \"infra\"\n",
6415        );
6416        let cfg = CloudConfig::load(camp.path()).unwrap();
6417        assert_eq!(cfg.machines.len(), 1);
6418        assert_eq!(cfg.machines[0].name, "subdir-1");
6419    }
6420
6421    #[test]
6422    fn load_from_config_dir_never_applies_sources_overlay() {
6423        // R615-F2's explicit decision: multi-root sibling trees don't inherit
6424        // the classic .yah/infra/sources.toml. Prove it rather than assert it
6425        // silently -- a sources.toml sitting at workspace_root/.yah/infra/
6426        // must NOT leak into a load_from_config_dir call even though both
6427        // share the same workspace_root.
6428        let camp = tempfile::TempDir::new().unwrap();
6429        let other = tempfile::TempDir::new().unwrap();
6430        write_min_machine(&other.path().join(".yah/infra/machines"), "borrowed-1", "");
6431        write_sources_toml(
6432            camp.path(),
6433            &format!(
6434                "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
6435                other.path().display()
6436            ),
6437        );
6438        let sibling_config_dir = camp.path().join(".noisetable");
6439        std::fs::create_dir_all(&sibling_config_dir).unwrap();
6440
6441        let cfg = CloudConfig::load_from_config_dir(&sibling_config_dir, camp.path()).unwrap();
6442        assert!(cfg.machines.is_empty(), "sources.toml must not apply here");
6443        assert!(cfg.machine_origins.is_empty());
6444    }
6445
6446    // ─── R615-T5: `inherit_machines` retirement — cutover proof ────────────
6447
6448    /// The successor to R615-T5's parity proof. That earlier pair of tests
6449    /// asserted the legacy `[infra].inherit_machines` redirect and an
6450    /// equivalent `kind = "path"` source resolved the same machine set, and
6451    /// that the two coexisted without duplicating rows. Both claims were about
6452    /// a mechanism that no longer exists, so they retired with it — what has
6453    /// to hold *now* is the other half of the same guarantee: a camp that
6454    /// declares only `sources.toml` resolves the shared root exactly as the
6455    /// redirect used to, and a stale `inherit_machines` key left behind in
6456    /// `camp.toml` changes nothing.
6457    ///
6458    /// That stale-key case is not hypothetical: it is precisely the state a
6459    /// camp is in between the code cutover and someone tidying its
6460    /// `camp.toml`, and a silent re-resolution there would double-count the
6461    /// borrowed nodes or hide their origin badge.
6462    #[test]
6463    fn a_stale_inherit_machines_key_does_not_change_what_sources_toml_resolves() {
6464        let shared = tempfile::TempDir::new().unwrap();
6465        write_min_machine(&shared.path().join(".yah/infra/machines"), "shared-node-1", "");
6466        write_min_machine(&shared.path().join(".yah/infra/machines"), "shared-node-2", "");
6467
6468        let sources_toml = format!(
6469            "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"path\"\npath = \"{}\"\nmode = \"read-only\"\n",
6470            shared.path().display()
6471        );
6472
6473        // Camp A: migrated cleanly — sources.toml only.
6474        let clean = tempfile::TempDir::new().unwrap();
6475        write_sources_toml(clean.path(), &sources_toml);
6476
6477        // Camp B: mid-migration — same source, plus the retired key still
6478        // sitting in camp.toml pointing at the same root.
6479        let stale = tempfile::TempDir::new().unwrap();
6480        std::fs::create_dir_all(stale.path().join(".yah")).unwrap();
6481        std::fs::write(
6482            stale.path().join(".yah/camp.toml"),
6483            format!(
6484                "[infra]\ninherit_machines = \"{}\"\n",
6485                shared.path().display()
6486            ),
6487        )
6488        .unwrap();
6489        write_sources_toml(stale.path(), &sources_toml);
6490
6491        let via_clean = CloudConfig::load(clean.path()).unwrap();
6492        let via_stale = CloudConfig::load(stale.path()).unwrap();
6493
6494        let names = |cfg: &CloudConfig| {
6495            let mut v: Vec<String> = cfg.machines.iter().map(|m| m.name.clone()).collect();
6496            v.sort();
6497            v
6498        };
6499        assert_eq!(
6500            names(&via_clean),
6501            names(&via_stale),
6502            "a leftover inherit_machines key must be inert — the retired redirect is gone"
6503        );
6504        assert_eq!(names(&via_clean), vec!["shared-node-1", "shared-node-2"]);
6505
6506        // And both are *borrowed*, not camp-local. This is the operator-facing
6507        // win the stopgap could never deliver: under the old redirect these
6508        // resolved with no origin at all, indistinguishable from locally-owned
6509        // nodes.
6510        assert_eq!(via_clean.machine_origins.len(), 2);
6511        assert_eq!(via_stale.machine_origins.len(), 2);
6512        for origin in via_stale.machine_origins.values() {
6513            assert_eq!(origin.owner, "yah");
6514            assert_eq!(origin.mode, SourceMode::ReadOnly);
6515        }
6516    }
6517}