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//!
83//! @yah:ticket(R605-F12, "Sovereign groups have no voting axis, so non-voting membership is inexpressible and the raft guard is enforced by an absent field")
84//! @yah:status(review)
85//! @yah:at(2026-08-20T05:15:30Z)
86//! @yah:assignee(agent:bundle-anthropic-ashguard)
87//! @yah:parent(R605)
88//! @arch:see(.yah/docs/working/W325-isolated-x86-build-capacity.md)
89//! @yah:next("OPERATOR INTENT (2026-08-19) that the model cannot currently record: us-west-003 is a NON-VOTING member of the us-west-001-based (prod) sovereign group, and us-west-011 is a DIFFERENT sovereign (dev) from 001/003. The dev/prod split is already declared correctly. The non-voting membership is not — us-west-003.toml declares no sovereign_group at all.")
90//! @yah:next("THE GAP: MachineConfig::sovereign_group is a single Option<String>, so membership is binary, and judge_join (oss/yubaba/crates/cloud/src/config.rs:459) permits a join IFF both sides declare the same non-None group. There is no way to say 'in this blast radius, but not quorum-eligible'.")
91//! @yah:next("WHY THAT IS ACTIVELY BAD, not just missing: today the ONLY thing refusing us-west-003 into the prod raft at the join gate is its ABSENT stamp. Its own file is emphatic it must never hold a raft node id ('a home-internet partition should never be able to stall the raft'), and that guarantee currently rests on a field nobody wrote. Stamping it prod to record the operator's real intent would REMOVE the guard. This is precisely the W305 failure mode that produced R742-T4: `no-voter` sat inert on three nodes asserting something nothing enforced.")
92//! @yah:next("PROPOSED SHAPE (recommended): a second axis, e.g. sovereign_role = voter | non-voter (default voter for back-compat, or make it required), with judge_join permitting a same-group join only for voters. Then us-west-003 stamps prod + non-voter, the intent is machine-readable, and the raft guard stops depending on omission. us-west-004 (R605-T7) would take the same shape.")
93//! @yah:next("TOUCHES TWO COPIES OF THE PREDICATE, do not fix only one: cloud::judge_join renders the camp-side refusal, but the predicate itself lives in workload_spec::sovereign::join_permitted because yubaba's POST /raft/add-learner gate asks the same question and there is deliberately no yubaba -> cloud edge. Also re-read `yubaba serve --sovereign-group`, whose node-side gate is narrower on purpose (an unset flag means 'declared nothing', not 'declared standalone').")
94//! @yah:gotcha("THE CODE AND THE OPERATOR CURRENTLY DISAGREE ABOUT 003, and a reader should know which is which before editing. judge_join's own doc comment asserts 'prod and dev are both stamped, and us-west-002/003/015 are deliberately not raft members' — i.e. R742-F1 modelled 003 as STANDALONE. The operator's model is that it is a NON-VOTING MEMBER of prod. Those are different claims, not a wording difference: standalone means no blast-radius relationship to 001 at all. Do not silently 'correct' either side; this ticket is the reconciliation.")
95//! @yah:gotcha("FLEET STATE AS DECLARED (2026-08-19): prod = us-west-001, us-south-001, us-east-001. dev = us-west-011, us-west-013, us-west-014. NO sovereign_group declared = us-west-002, us-west-003, us-west-015. Verify against the files rather than trusting this list — xtask/tests/fleet_sovereign_groups.rs pins the roster and will need updating in the same change (it also asserts the stamp parses as a TOP-LEVEL key, which matters because 003 has a long comment block before [allocatable] where a stamp would silently become a member of that table).")
96//! @yah:gotcha("SEPARATE AXIS, DO NOT ENTANGLE: mesh membership is not sovereign membership. The standing rule is ONE mesh for the entire fleet regardless of group (operator, 2026-08-19), so us-west-003 and us-west-011 enrolling in headscale is unrelated work with no design question in it — see R605-T10. A voting axis on sovereign_group must not become a reason to keep any node off the mesh.")
97//! @yah:gotcha("SHARED-TREE COLLISION, live 2026-08-20: R772 (Miravel:spade, session:ce6d74a9) is refactoring oss/yubaba/crates/cloud/src/validate.rs at the same time and the file is currently RED - error[E0425] cannot find function load_machines at validate.rs:753, a half-landed extraction of the machine-loading walk that check_inert_taints / check_retired_arch_tags / the new check_unroled_sovereign_members all duplicate. That error is NOT from this ticket. Told them by party.chat and asked them to absorb check_unroled_sovereign_members into load_machines rather than leave one holdout. Do not hand-fight the file.")
98//! @yah:gotcha("R772 ALSO BROKE THREE PRE-EXISTING INGRESS TESTS, again not this ticket: two_services_fronting_one_node_collate_into_one_front_door, a_cross_service_hostname_clash_is_reported_with_both_declarations, one_mirrors_broken_declaration_does_not_hide_the_rest - all failing with 'providers.compute.use = hetzner - no such provider'. Cause is their new CloudConfig::load(workspace_root) at validate.rs:750 inside collate_workspace_ingress; the fronted_mirror fixture declares the slot but never writes infra/providers/hetzner.toml, and CloudConfig::load runs cross_ref_validate. Left alone deliberately - peer-owned.")
99//! @yah:gotcha("TRAP THAT MADE THREE OF MY OWN TESTS PASS FOR THE WRONG REASON: the machine-lint sweeps SKIP unparseable TOMLs by design (a peer's half-written scaffold must not sink the sweep). So a test fixture missing a REQUIRED MachineConfig field - mesh_tags is the one that bites - is silently skipped, the lint finds nothing, and every assert-empty test passes vacuously. Only the one test asserting found.len() == 1 noticed. write_sovereign_machine now always writes mesh_tags = [] and carries a comment saying why. Check this before trusting any new test in cloud::validate.")
100//! @yah:verify("cargo test -p yah-workload-spec --lib sovereign (from oss/yah-base) -- 9 passed, 0 failed. Covers both new refusals (a_non_voting_member_does_not_join_its_own_group, a_non_voting_target_has_no_quorum_to_join), the back-compat pin (the_default_role_is_the_pre_r605_f12_meaning), and the one-spelling round-trip across TOML/CLI/JSON.")
101//! @yah:verify("cargo test -p yubaba --lib sovereign (from oss/yubaba) -- 13 passed, 0 failed. Includes a_non_voting_joiner_is_refused_by_role_not_by_group, a_non_voting_target_refuses_every_joiner, a_group_without_a_role_key_is_a_voter_not_a_refusal (the deployed-fleet back-compat seam), a_peer_reports_its_role_in_the_toml_spelling.")
102//! @yah:verify("cargo test -p yubaba --test raft_sovereign_group (from oss/yubaba) -- 11 passed, 0 failed, up from 8. Three new end-to-end against real single-node rafts: a_non_voting_member_of_the_same_group_is_refused, a_non_voting_leader_refuses_to_grow_its_quorum, a_node_publishes_its_role_and_the_leader_reads_it_there (which also proves the request body cannot vote a non-voter in - the leader dials the joiner).")
103//! @yah:verify("cargo test -p xtask --test fleet_sovereign_groups (from repo root) -- 2 passed, 0 failed. THE DECISIVE ONE: parses the real .yah/infra/machines/*.toml through the actual MachineConfig deserializer. Confirms us-west-003 = prod + non-voter on disk, all six pre-existing voters now stamped sovereign_role = voter explicitly, and neither key swallowed by a table header.")
104//! @yah:verify("cargo test -p yah-cloud --lib (from oss/yubaba) -- 891 passed, 3 failed, where all 3 failures were R772's ingress-collate tests and none were mine. A clean re-run is BLOCKED, not failing: R555's in-flight AdmissionGrant.secrets field breaks velveteen-exec, and yah-cloud is not a root workspace member so its dev-deps can only resolve from the oss/yubaba workspace. Re-run once R555 lands.")
105//! @yah:handoff("LANDED, operator chose the second-axis shape (Call 1 = A, 2026-08-20). sovereign_role = voter | non-voter now sits beside sovereign_group, and ONE predicate judges both: workload_spec::sovereign::join_permitted(Membership, Membership) where Membership { group: Option<&str>, role: SovereignRole }. Permitted iff same non-None group AND both sides Voter. Both copies of the predicate call it - cloud::judge_join (camp-side) and yubaba::sovereign_group::judge (node-side) - so the rule itself cannot drift; only the prose differs, which was already the R742-F1 split.")
106//! @yah:handoff("WHY THE ROLE IS CHECKED ON BOTH SIDES, since only the joiner half was asked for: a join grows a quorum and it takes two nodes. Refusing a non-voting JOINER is the us-west-003 case. Refusing a non-voting TARGET is the same assertion read from the other end - a box declared non-voting that is serving add-learner is already holding a raft seat its own declaration forbids, and permitting there would paper over the contradiction. Both refusals name the role rather than the group when the groups match, because a message reading 'cross-group join refused: prod and prod' reads as a bug in the check.")
107//! @yah:handoff("THE DEFAULT IS THE LOAD-BEARING DECISION AND IT IS DELIBERATELY PERMISSIVE. An absent sovereign_role resolves to Voter (MachineConfig::sovereign_membership, the ONE place the Option is resolved). Reason: before this field, declaring a group WAS declaring quorum eligibility, so absence has to keep meaning that or the change silently retires six live voters. The permissiveness is bounded at the other end by cloud::validate::check_unroled_sovereign_members, which makes `yah cloud validate` FAIL on a group stamp with no role beside it - so the default can be reached by choice but not by silence. MachineConfig::sovereign_role stays Option<SovereignRole> (not a defaulted plain field) precisely so that lint can tell 'chose voter' from 'never considered it'.")
108//! @yah:handoff("NODE-SIDE BACK-COMPAT SEAM, pinned by a test because it is a decision and not an oversight: a peer answering GET /raft/status with a sovereign_group but NO sovereign_role key - every yubaba built between R742-F1 and R605-F12, which today is the entire prod raft - is read as Voter, not refused. Refusing would freeze a stamped cluster's growth until every member was rolled, strictly worse than what the role guards against, and it is the same degrade-toward-prior-behaviour stance the module already took for the group. Residue, named rather than hidden in read_group's doc: a box whose machine.toml says non-voter but whose daemon predates the flag answers 'voter' and the node gate admits it. judge_join refuses it camp-side, which is where operator-driven joins go. Window closes per-group as its nodes carry the flag.")
109//! @yah:handoff("FILES: workload-spec/src/sovereign.rs (SovereignRole + Membership + role-aware join_permitted, +227). cloud/src/config.rs (sovereign_role field, sovereign_membership(), judge_join same-group role branch, SovereignRole re-exported from cloud::config). cloud/src/validate.rs (check_unroled_sovereign_members + UnroledSovereignMember). app/yah/cli/src/cloud.rs (lint wired: ERROR in `yah cloud validate`, WARNING in the apply preflight - same split as inert-taint/retired-arch-tag, because an unwritten role changes no placement decision and the machine may be declared in a tree this camp does not own). yubaba/src/{sovereign_group,lib,main}.rs (--sovereign-role flag, ServerState.sovereign_role, /raft/status publishes it always-never-null, gate both directions). yubaba-test-harness/src/solo_node.rs (solo_node_with_sovereign_role). .yah/infra/machines/*.toml (7 files). xtask/tests/fleet_sovereign_groups.rs + fleet_build_placement.rs. W325 section 3d.")
110//! @yah:handoff("ONE BEHAVIOUR CHANGE WORTH A SECOND OPINION: a node started with --sovereign-role non-voter AND a --raft-node-id now refuses EVERY add-learner. I judged that correct - it is a contradiction the operator should see loudly - but the symptom is 'joins mysteriously stop working' rather than a startup refusal. main.rs warns loudly at boot when that pair is present; I did NOT make it fatal, because refusing to start could brick a node mid-roll. Reconsider if it bites.")
111//! @yah:handoff("NOT DONE, and it is a HARD GATE: .yah/schema/machine.toml.schema.json has NOT been regenerated, so sovereign_role is absent from it and schema-drift-guard (scripts/check-schema-drift.sh, a step in .yah/qed/check.toml, run by CI on every push) WILL FAIL. Fix is `cargo run -p xtask -- emit-schemas` from the repo root - it was queued behind ~7 concurrent peer cargo builds for the whole session. Nothing else is required to make this pushable.")
112//! @yah:handoff("ALSO NOT RE-CONFIRMED: `cargo test -p yah-cloud --lib` needs a clean run. Its last real run was 891 passed / 3 failed with all three failures belonging to R772's ingress-collate work and none to this ticket. The re-run is BLOCKED not failing - R555's in-flight AdmissionGrant.secrets field breaks velveteen-exec, and yah-cloud is not a root workspace member so its dev-deps only resolve from the oss/yubaba workspace where that break lives. Re-run from oss/yubaba once R555 lands.")
113//! @yah:verify("cargo run -p xtask -- emit-schemas (from repo root) -- wrote 8 files, exit 0 after an 18m24s build queued behind ~7 concurrent peer cargo jobs. .yah/schema/machine.toml.schema.json now carries the sovereign_role property (anyOf SovereignRole | null, with the full doc comment) and the SovereignRole definition as a oneOf over the two string enums voter / non-voter. The schema-drift-guard gate for THIS ticket is closed.")
114//! @yah:gotcha("emit-schemas IS ALL-OR-NOTHING AND WILL PICK UP A PEER'S UNCOMMITTED WORK. Running it to close this ticket's machine-schema drift also regenerated .yah/schema/secret.toml.schema.json (+34) from R555-F5's in-flight SecretAccess::Recipes / RecipeMatch source. That output is CORRECT for the tree as it stands and was not hand-edited, but it means the schema diff in the working tree is not purely R605-F12's: machine.toml.schema.json (+32) is this ticket, secret.toml.schema.json (+34) is R555. Told Ashguard:spade by party.chat so they carry it with their commit rather than regenerating on top. Anyone splitting these commits needs to split the schema diff too.")
115//! @yah:handoff("ALL GATES CLOSED as of 2026-08-20. Both items listed as outstanding in the earlier handoff notes are done: emit-schemas ran (machine.toml.schema.json carries sovereign_role + the SovereignRole voter/non-voter enum, drift guard satisfied), and cargo test -p yah-cloud --lib is 896 passed / 0 failed once R555 and R772 settled. 45 tests green across workload-spec (9), yubaba lib (13), yubaba raft integration (11), yah-cloud lib (10 of this ticket's, within 896), xtask fleet (2). Ready for review. NOTE for whoever commits: the working tree's schema diff is not purely this ticket - .yah/schema/machine.toml.schema.json (+32) is R605-F12, .yah/schema/secret.toml.schema.json (+34) is R555-F5, both correct generated output from one emit-schemas run. Ashguard:spade has agreed to carry theirs.")
116//! @yah:verify("cargo test -p yah-cloud --lib (from oss/yubaba) -- 896 passed, 0 FAILED, 4 ignored. The blocked check from earlier is now clean: R555 landed the velveteen-exec and TransformRecipe.secrets fixes, R772's ingress-collate work settled (they replaced the CloudConfig::load in collate_workspace_ingress with a narrower machines-only loader, so cross_ref_validate can no longer fail the collate over an unrelated provider typo). All 45 R605-F12 tests across the four crates are green simultaneously on one tree.")
117//! @yah:verify("Confirmed by NAME rather than by total, since a passing count proves nothing about which tests ran: cargo test -p yah-cloud --lib -- role voter voting lists all ten of this ticket's cloud tests green - a_non_voting_member_is_refused_into_its_own_group, a_non_voting_target_has_no_quorum_to_grow, a_refusal_names_the_group_when_fixing_the_role_would_not_help, an_unwritten_role_still_joins_its_group, a_non_voter_is_still_in_the_group_it_names, sovereign_role_round_trips_and_is_omitted_when_unwritten, a_group_with_no_role_is_reported_with_the_declaring_file, either_stated_role_is_clean, a_machine_in_no_group_is_not_asked_for_a_role, unroled_findings_are_ordered_by_file_so_output_is_stable.")
118
119use anyhow::{bail, Context, Result};
120use serde::{Deserialize, Serialize};
121use std::collections::{BTreeMap, HashMap};
122use std::path::Path;
123use thiserror::Error;
124use workload_spec::secrets::SecretAccess;
125use workload_spec::sovereign::Membership;
126pub use workload_spec::sovereign::SovereignRole;
127use workload_spec::{validate, LifecycleArchetype, TenantId, WorkloadSpec};
128
129/// Static node capacity declaration on `machine.toml` (R572-F3).
130///
131/// `memory_mb` and `cpu_millis` express the node's *total* hardware budget.
132/// F5's bin-packer subtracts the sum of committed workload requests from
133/// this floor to determine available headroom; an absent `allocatable`
134/// block means no capacity constraint is enforced (any workload fits).
135#[derive(Debug, Clone, Serialize, Deserialize)]
136#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
137pub struct NodeAllocatable {
138 /// Total physical RAM in mebibytes (e.g. 512 for a 512 MB node).
139 pub memory_mb: u32,
140 /// Total CPU in k8s millicores (1000 = 1 core, 250 = 0.25 CPU).
141 pub cpu_millis: u32,
142}
143
144/// `[registration]` — facts **observed** about a running box, written by the
145/// fleet rather than declared by an operator (R707-T1).
146///
147/// The rest of `machine.toml` is *declaration*: intent, operator-authored,
148/// reviewed and diffed like any other source. This block is the other half —
149/// what the box turned out to be once it booted and joined. Keeping the two
150/// apart is what lets the published fleet index (R707-F3) say which half it is
151/// carrying; publishing them under one schema would bake the confusion into a
152/// permanent record.
153///
154/// The split is a **provenance** boundary, not a trust or reach one:
155/// - *Declaration* answers "what did we ask for" — `name`, `region`, `arch`,
156/// `mesh_tags`, `[allocatable]`, and the declared reach in [`ConnectSpec`].
157/// - *Registration* answers "what did we observe" — the hostkey TOFU'd at
158/// attach, the mesh address headscale assigned at join.
159///
160/// It stays in the git-tracked TOML on purpose. Registration is not local
161/// scratch state: every consumer needs the mesh address to dial a node, so it
162/// has to travel with the declaration. (`.yah/infra/state/machines/<name>.json`
163/// — [`crate::state::MachineState`] — remains the *gitignored* sidecar for
164/// provider-side derivatives that nobody but this camp needs.)
165#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
166#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
167pub struct MachineRegistration {
168 /// Yubaba's ed25519 `/identity` fingerprint, TOFU-recorded by
169 /// `yah cloud machine attach` on first contact (`SHA256:…`). An observed
170 /// property of a running process — not the operator's intent — which is
171 /// why it moved out of the top level here.
172 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub hostkey_fingerprint: Option<String>,
174 /// Mesh (headscale/tailnet) IPv4 assigned at join, e.g. `"100.64.0.1"`.
175 /// Bare address, not a URL: the *port* is declared reach and lives on
176 /// [`ConnectSpec::yubaba_port`]. [`MachineConfig::yubaba_url`] composes the
177 /// two. Absent until the node has joined the mesh.
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub mesh_ipv4: Option<String>,
180 /// RFC3339 timestamp of the mesh join that produced `mesh_ipv4`. Free-form
181 /// audit; nothing keys off it.
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub joined_at: Option<String>,
184}
185
186impl MachineRegistration {
187 /// True when nothing has been observed yet — used to omit the whole
188 /// `[registration]` table from a serialized machine TOML.
189 pub fn is_empty(&self) -> bool {
190 self.hostkey_fingerprint.is_none() && self.mesh_ipv4.is_none() && self.joined_at.is_none()
191 }
192}
193
194/// Per-machine TOML from `.yah/infra/machines/<name>.toml`.
195///
196/// Two halves, split by provenance (R707-T1): everything here is *declaration*
197/// — operator intent under review and blame — except [`registration`], which
198/// carries what the fleet observed. See [`MachineRegistration`] for why the
199/// boundary is drawn there and what depends on it.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
202pub struct MachineConfig {
203 pub name: String,
204 pub provider: String,
205 /// Who the hardware actually comes from (`"ovh"`, `"vultr"`, `"on-prem"`).
206 ///
207 /// Deliberately *not* [`provider`](Self::provider), which selects the
208 /// auto-provision driver: a box we rented by hand and brought up over SSH
209 /// is `provider = "static"` for its whole life, and writing the vendor
210 /// there instead would flip it driver-backed and make
211 /// [`validate`](Self::validate) demand `location` + `server_type` it has no
212 /// answer for. The two axes genuinely differ — vendor is who bills you,
213 /// `provider` is who yah can call an API against.
214 ///
215 /// Worth recording because vendor-scoped policy is invisible in every other
216 /// field and decides real work: outbound port 25, rDNS/PTR control, IP
217 /// reputation, egress billing. It survived only in TOML prose until now,
218 /// which made it ungreppable at exactly the moment you need it.
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub vendor: Option<String>,
221 /// Human label for the box (`"gamer"`, `"the GEEKOM"`). Free-form and never
222 /// matched on — [`name`](Self::name) stays the identity everywhere. This is
223 /// only so operators and agents can say which box they mean out loud.
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub nickname: Option<String>,
226 /// Provider DC code (e.g. Hetzner `"hil"`). **Provisioning-only**: required
227 /// iff the provider has an auto-provision driver ([`provider_has_machine_driver`]);
228 /// a BYO `static` node we brought up over SSH has no such code. Optional at
229 /// load time so static machine.tomls omit it; [`MachineConfig::validate`]
230 /// enforces presence at the right moment for driver-backed providers.
231 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub location: Option<String>,
233 /// Provider SKU/size (e.g. Hetzner `"ccx13"`). Provisioning-only, same
234 /// optionality contract as [`location`](Self::location).
235 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub server_type: Option<String>,
237 /// **Deprecated (R330-F16).** A machine should describe *itself* (region,
238 /// zone, provider, mesh_tags); *which* mirrors run on it is derived by the
239 /// reconciler from each mirror's `required` placement spec, not declared
240 /// here. Now optional + omitted-when-empty so new machine.tomls leave it
241 /// out. The legacy `resolve_mirror_machine` topology fallback still reads
242 /// it until yubaba's reverse-index supersedes the topology.toml path; once
243 /// that lands, this field and its readers are removed wholesale.
244 #[serde(default, skip_serializing_if = "Vec::is_empty")]
245 pub hosts_mirrors: Vec<String>,
246 pub mesh_tags: Vec<String>,
247 /// Canonical geo region label (latency axis), e.g. `"us-west"`. F16's three
248 /// topology axes are orthogonal: `region` = geo (latency), `zone` = failure
249 /// domain within a region (HA), `provider` = network/cost. `region` is
250 /// distinct from `location` (the provider's DC code, e.g. Hetzner `"hil"`):
251 /// `location` is provider-scoped, `region` is our provider-neutral label.
252 /// Optional for backward-compat; a machine without it never satisfies a
253 /// `required.regions` constraint.
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub region: Option<String>,
256 /// Failure-domain label within a region (HA axis), e.g. `"hil"`. For
257 /// single-DC Hetzner this typically mirrors `location`. F16 placement
258 /// matches `required.zones` against this. Optional for backward-compat.
259 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub zone: Option<String>,
261 /// Declared CPU architecture (`"x86_64"` / `"aarch64"`). A machine has
262 /// exactly one — it's a first-class property of the box, not a reach
263 /// detail and not a mesh tag. Drives the yubaba release triple. Optional
264 /// only because there's no provider API to probe it (static nodes declare
265 /// it; a driver-backed provider may leave it unset until known).
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub arch: Option<String>,
268 pub bucket: Option<BucketSpec>,
269 /// **Legacy location, superseded by `[registration].hostkey_fingerprint`**
270 /// (R707-T1). Still deserialized so machine TOMLs written before the split
271 /// keep parsing; never *read* directly — go through
272 /// [`MachineConfig::hostkey_fingerprint`], which prefers the registration
273 /// block. [`MachineConfig::normalize`] folds this into `registration`, and
274 /// [`MachineConfig::save`] normalizes before writing, so a load→save cycle
275 /// migrates the file rather than dropping the value.
276 #[serde(
277 rename = "hostkey_fingerprint",
278 default,
279 skip_serializing_if = "Option::is_none"
280 )]
281 pub legacy_hostkey_fingerprint: Option<String>,
282 /// Provider-side SSH-key IDs (Hetzner: from `GET /v1/ssh_keys`)
283 /// authorized for `root` at create time. Defaults to empty for
284 /// backwards-compat with existing machine declarations; an empty
285 /// list yields a Hetzner-emailed random root password (which the
286 /// driver currently discards). Populate this when you want pre-mesh
287 /// SSH access for bootstrap deploys or recovery.
288 #[serde(default, skip_serializing_if = "Vec::is_empty")]
289 pub ssh_keys: Vec<u64>,
290 /// Cloudflare Tunnel ID this machine joins (e.g. `abc123.cfargotunnel.com`).
291 /// `None` → no tunnel (mesh-only node, no public ingress).
292 /// When set, `yah cloud machine provision` reads `cloudflare-tunnel-token`
293 /// from the keys vault and injects the cloudflared install block into
294 /// cloud-init so the new machine connects to CF edge on first boot.
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub cloudflared: Option<String>,
297 /// When `true`, this machine hosts operator-bridge workloads (Tailscale
298 /// operator access to mesh-internal services). `yah cloud machine provision`
299 /// will install tailscaled and run `tailscale up` during cloud-init via the
300 /// `{{OPERATOR_BRIDGE_BLOCK}}` placeholder. Defaults to `false` for
301 /// backward-compat with existing machine declarations.
302 #[serde(default)]
303 pub hosts_operator_bridge: bool,
304 /// BYO `static`-node reach descriptor. Static nodes have no provider API to
305 /// probe, so how the camp reaches them (SSH user@host + the yubaba URL,
306 /// which is loopback until the WireGuard mesh lands) is *declared* here.
307 /// `None` for driver-backed providers (Hetzner/Vultr), whose address is
308 /// resolved from the provider API / mesh at provision time.
309 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub connect: Option<ConnectSpec>,
311 /// Static node capacity (R572-F3). Declares the node's total hardware
312 /// budget; F5's scheduler subtracts committed workload requests from this
313 /// to check whether a new workload fits. Absent means unconstrained.
314 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub allocatable: Option<NodeAllocatable>,
316 /// Placement taint keys (R572-F3). **There is no toleration** — a
317 /// `no-<archetype>` taint is an absolute block, not a preference
318 /// (W305/R742-T4; the pre-2026-08-11 "repel-unless-tolerate" wording here
319 /// described an `unless` that was never built).
320 ///
321 /// A key in this list influences placement in exactly one of two ways, and
322 /// [`taint_effect`] is the authority on which:
323 ///
324 /// - **repulsion** — `"no-server"` / `"no-appliance"` / `"no-job"` reject
325 /// workloads of that [`LifecycleArchetype`] outright;
326 /// - **affinity** — a key in [`AFFINITY_TAINT_KEYS`] (today just
327 /// `"public-ip"`) that a workload names in
328 /// `yah.placement.requires-taint`, which then *requires* this node.
329 ///
330 /// Anything else is **inert**: it parses, it round-trips, and no scheduler
331 /// decision can ever read it. `yah cloud validate` rejects such keys
332 /// (`validate::check_inert_taints`) rather than letting them sit looking
333 /// load-bearing — which is how `no-voter` spent months asserting a
334 /// falsehood on three nodes. Facts about a node that are not placement
335 /// inputs belong in [`mesh_tags`](Self::mesh_tags) or a comment.
336 #[serde(default, skip_serializing_if = "Vec::is_empty")]
337 pub taints: Vec<String>,
338 /// Which consensus group this node belongs to — W305/R742-F1. `None` means
339 /// standalone: in no group at all, which is us-west-002 and us-west-015.
340 ///
341 /// Membership is not by itself quorum eligibility; that is
342 /// [`sovereign_role`](Self::sovereign_role), added by R605-F12 because
343 /// us-west-003 is in prod's blast radius *and* must never vote in it.
344 ///
345 /// **Not a placement input.** It is deliberately absent from
346 /// [`RequiredSpec::matches`], and adding it there would be a category
347 /// error: a sovereign group is a *blast radius*, not a filter. Nothing
348 /// about "which quorum does this box vote in" should decide where a
349 /// workload runs — that is what made the fleet express three unrelated
350 /// properties through one taint list and get all three wrong (W305).
351 ///
352 /// What it *is* for is refusal. [`judge_join`] answers "may this node join
353 /// that node's cluster", and the answer is no unless both declare the same
354 /// group. Before this field the only guard was a comment in three machine
355 /// TOMLs saying "never run a raft join against this box from a shell
356 /// pointed at prod" — habit, with no mechanism behind it, which is the
357 /// same class of guard W257 §8 admitted to.
358 ///
359 /// # Why `sovereign_group` and not `raft_group`
360 ///
361 /// Raft is today's mechanism (operator, 2026-08-10). A field named for the
362 /// mechanism goes stale the day the mechanism is swapped, and every
363 /// consumer that reads it inherits the lie. `sovereign` names what the
364 /// group *has* — its own authority, its own upgrade cadence, its own
365 /// destruction — which stays true under any consensus protocol.
366 ///
367 /// Note the word already appears in this tree as prose (W267's title, the
368 /// `IngressProvider::Passway` doc comment's "sovereign edge"). That is an
369 /// adjective meaning "self-hosted, not SaaS"; this is the first time it
370 /// carries structure.
371 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub sovereign_group: Option<String>,
373 /// Whether this node may hold a seat in its group's quorum — R605-F12.
374 /// Meaningless without [`sovereign_group`](Self::sovereign_group): a
375 /// standalone box has no quorum to be eligible for.
376 ///
377 /// **`None` is "not written", not a third role.** Read it through
378 /// [`sovereign_membership`](Self::sovereign_membership), which resolves the
379 /// absence to [`SovereignRole::Voter`] — what declaring a group has always
380 /// meant, so the six nodes stamped before this field keep their seats
381 /// without an edit. The distinction is kept only so
382 /// [`crate::validate::check_unroled_sovereign_members`] can tell an
383 /// operator who *chose* voter from one who never considered the question;
384 /// no join decision reads the `Option` directly.
385 ///
386 /// # Why this is not a taint
387 ///
388 /// It was, once: `no-voter` sat in [`taints`](Self::taints) on three nodes
389 /// for months, read by nothing, and R742-T4 removed it because the taint
390 /// list is a *placement* vocabulary and this is not a placement input (see
391 /// [`taint_effect`]). Nor is it a second group label. It is a modifier on
392 /// the membership this node already declares, which is why it lives beside
393 /// the group and is judged with it in one predicate,
394 /// [`workload_spec::sovereign::join_permitted`].
395 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub sovereign_role: Option<SovereignRole>,
397 /// `[registration]` — the observed half (R707-T1). Empty until the box has
398 /// been attached / mesh-joined. See [`MachineRegistration`].
399 #[serde(default, skip_serializing_if = "MachineRegistration::is_empty")]
400 pub registration: MachineRegistration,
401}
402
403/// True iff `provider` has an auto-provision driver (create/destroy via API).
404/// Driver-backed providers require `location` + `server_type`; BYO `static`
405/// nodes (brought up over SSH) do not. The cloud-vs-vps distinction the fleet
406/// cares about lives here — at the provider-capability layer — not as a
407/// separate machine type (W242 BYO Phase-0 decision).
408pub fn provider_has_machine_driver(provider: &str) -> bool {
409 matches!(provider, "hetzner" | "vultr" | "digitalocean")
410}
411
412/// Taint keys a workload may name in `yah.placement.requires-taint` to
413/// *require* a node (W305/R742-T4 affinity vocabulary).
414///
415/// This is a closed list on purpose. `WorkloadSpec::requires_taint` returns
416/// free text, but every producer in the tree is code — `passway_ingress.rs`
417/// and `cloudflared_ingress.rs`, both emitting
418/// [`workload_spec::PUBLIC_IP_TAINT`] — and no on-disk `workload.toml` sets the
419/// annotation at all. So the set of keys a node can usefully carry for
420/// affinity is knowable at compile time, which is what lets
421/// [`taint_effect`] call anything outside it inert instead of guessing.
422///
423/// **Adding an affinity key means adding it here**, in the same change that
424/// teaches a workload to require it. That coupling is the point: it makes the
425/// node side and the workload side impossible to land apart.
426pub const AFFINITY_TAINT_KEYS: &[&str] = &[workload_spec::PUBLIC_IP_TAINT];
427
428/// How a key in [`MachineConfig::taints`] can affect placement.
429///
430/// W305 finding 1: before R742-T4 nothing asked this question, so a key that
431/// no scheduler path could read — `"qa"`, `"no-voter"` — parsed, validated,
432/// and quietly did nothing. Both of the findings that cost real fleet state
433/// were invisible for exactly that reason.
434#[derive(Debug, Clone, Copy, PartialEq, Eq)]
435pub enum TaintEffect {
436 /// `"no-<archetype>"`: rejects workloads of that archetype outright. Read
437 /// by [`RequiredSpec::matches`] via `repel_archetype`.
438 Repels(LifecycleArchetype),
439 /// A key in [`AFFINITY_TAINT_KEYS`]: a workload naming it in
440 /// `yah.placement.requires-taint` is restricted to nodes carrying it.
441 Attracts,
442 /// Neither. No placement decision can read this key.
443 Inert,
444}
445
446/// Classify one node taint key. See [`TaintEffect`].
447///
448/// The repulsion half is derived from [`LifecycleArchetype::ALL`] rather than
449/// a literal list, so a fourth archetype makes `no-<its key>` live without an
450/// edit here.
451pub fn taint_effect(key: &str) -> TaintEffect {
452 if let Some(arch) = LifecycleArchetype::ALL
453 .into_iter()
454 .find(|a| key == format!("no-{}", a.taint_key()))
455 {
456 return TaintEffect::Repels(arch);
457 }
458 if AFFINITY_TAINT_KEYS.contains(&key) {
459 return TaintEffect::Attracts;
460 }
461 TaintEffect::Inert
462}
463
464/// Every key the scheduler *can* act on, sorted — for error messages that
465/// tell the operator what the legal vocabulary actually is instead of only
466/// what was wrong.
467pub fn live_taint_keys() -> Vec<String> {
468 let mut keys: Vec<String> = LifecycleArchetype::ALL
469 .into_iter()
470 .map(|a| format!("no-{}", a.taint_key()))
471 .chain(AFFINITY_TAINT_KEYS.iter().map(|k| (*k).to_string()))
472 .collect();
473 keys.sort();
474 keys
475}
476
477/// What [`judge_join`] decided about one proposed cluster join.
478///
479/// Shaped like yubaba's `PromotionVerdict` / `GeographyVerdict` and for the
480/// same reason: the rule stays unit-testable without a live cluster, and a
481/// refusal carries its reason from the place that knows it.
482#[derive(Debug, Clone, PartialEq, Eq)]
483pub enum JoinVerdict {
484 /// Both nodes declare the same sovereign group and both are voters. The
485 /// join is within one blast radius and grows a quorum both sides are
486 /// eligible for.
487 Permit,
488 /// The join is refused. Carries an operator-readable reason naming both
489 /// declared values and the file to edit — a refusal that only says
490 /// "invalid" gets worked around rather than fixed.
491 Refuse(String),
492}
493
494/// May `joiner` join the cluster `target` belongs to? — W305/R742-F1.
495///
496/// **A join is permitted iff both nodes declare the same non-`None`
497/// [`sovereign_group`](MachineConfig::sovereign_group) and both are
498/// [`SovereignRole::Voter`].** One rule, no special cases, and it makes the
499/// declaration mandatory before any quorum grows.
500///
501/// The case this exists for is two *different* declared groups: joining a dev
502/// Pi into prod is refused rather than trusted, where today the only guard is
503/// a comment saying not to do it. But an undeclared node is refused too, and
504/// that is the deliberate half — `None` means "in no group", not "unknown", so
505/// growing prod with an unstamped box is exactly as much a cross-group join as
506/// the dev case is. Failing open there would leave the operator believing a
507/// guarantee that was never evaluated, which is the reasoning
508/// `QuorumGeography::judge` already applies to untagged voters.
509///
510/// No legitimate flow pays for that strictness: prod and dev are both stamped,
511/// and us-west-002/015 are deliberately in no group at all. Adding a real
512/// member means declaring it first, which is the point.
513///
514/// # The non-voting refusal (R605-F12)
515///
516/// Same group and still refused, when either side declares
517/// [`SovereignRole::NonVoter`]. This is the case a group label alone could not
518/// express. us-west-003 is a residential-uplink build box the operator counts
519/// as part of prod — same secrets, same upgrade cadence, same destruction — and
520/// which must never hold a prod raft seat, because a home-internet partition
521/// should not be able to stall the quorum. Until R605-F12 the only thing
522/// refusing it was its *absent* stamp, so recording the operator's real intent
523/// (`sovereign_group = "prod"`) would have removed the guard. Now the intent
524/// and the guard are the same two lines.
525///
526/// Note what this is not: the refusal here is about *voting*, and it says
527/// nothing about the mesh. One mesh spans the whole fleet regardless of group
528/// or role (operator, 2026-08-19); a non-voter is reachable, schedulable and
529/// rollable like any other node.
530///
531/// This is the **camp-side** rendering of the rule. The predicate itself lives
532/// in [`workload_spec::sovereign::join_permitted`] because yubaba's
533/// `POST /raft/add-learner` gate asks the same question and cannot see this
534/// crate (there is deliberately no yubaba → cloud edge). Only the prose is
535/// duplicated, and it has to be: a refusal here names
536/// `.yah/infra/machines/<name>.toml`, while the node-side one has no machine
537/// name in hand and must also name `yubaba serve --sovereign-group`.
538///
539/// The node-side gate is *narrower* on purpose, and the difference is worth
540/// knowing when reading either: a daemon started without `--sovereign-group`
541/// has declared nothing rather than declared standalone, so yubaba resolves
542/// that unknown before it judges, and its gate is in force only once the
543/// cluster being joined declares a group. See `yubaba::sovereign_group`.
544pub fn judge_join(joiner: &MachineConfig, target: &MachineConfig) -> JoinVerdict {
545 let stamp_hint = |m: &MachineConfig| {
546 format!(
547 "declare `sovereign_group = \"<group>\"` in .yah/infra/machines/{}.toml",
548 m.name
549 )
550 };
551 let role_hint = |m: &MachineConfig| {
552 format!(
553 "set `sovereign_role = \"voter\"` in .yah/infra/machines/{}.toml",
554 m.name
555 )
556 };
557 if workload_spec::sovereign::join_permitted(
558 joiner.sovereign_membership(),
559 target.sovereign_membership(),
560 ) {
561 return JoinVerdict::Permit;
562 }
563 let (j, t) = (
564 joiner.sovereign_group.as_deref(),
565 target.sovereign_group.as_deref(),
566 );
567 // Everything below is a refusal; the only permitted shape returned above.
568 //
569 // R605-F12: when both sides name the SAME group, the role is the only thing
570 // left that can have refused, and it gets its own message. Falling through
571 // to the arms below would print "cross-group join refused: 'us-west-003' is
572 // in "prod" and 'us-west-001' is in "prod"" — a message that reads as a bug
573 // in the check rather than a decision about the fleet.
574 //
575 // Deliberately not hoisted above the group comparison. A non-voting joiner
576 // whose target is standalone is refused for *both* reasons, and naming the
577 // role there would send the operator to fix a field that would not have
578 // made the join legal anyway.
579 if let (Some(a), Some(b)) = (j, t) {
580 if a == b {
581 for (m, side, other) in [
582 (joiner, "the joiner", &target.name),
583 (target, "the target", &joiner.name),
584 ] {
585 if m.sovereign_membership().role.is_voter() {
586 continue;
587 }
588 return JoinVerdict::Refuse(format!(
589 "join refused: {side} '{}' is a NON-VOTING member of sovereign group {a:?}, \
590 the same group as '{other}'. It is inside that blast radius — same secrets, \
591 same upgrade cadence, same destruction — but declares itself ineligible for \
592 the quorum, so this is refused by declaration rather than by omission. If it \
593 should genuinely vote, {}; if it should not, this refusal is the field doing \
594 its job and the join is the thing to reconsider.",
595 m.name,
596 role_hint(m),
597 ));
598 }
599 }
600 }
601 match (j, t) {
602 (Some(a), Some(b)) => JoinVerdict::Refuse(format!(
603 "cross-group join refused: '{}' is in sovereign group {a:?} and '{}' is in {b:?}. \
604 These are separate blast radii — separate quorums, separate upgrade cadences, \
605 separately destroyable — and merging them is not something a join can undo. If \
606 the move is genuinely intended, restamp '{}' to {b:?} first and treat it as \
607 leaving its old group.",
608 joiner.name,
609 target.name,
610 joiner.name,
611 )),
612 (None, Some(b)) => JoinVerdict::Refuse(format!(
613 "join refused: '{}' declares no sovereign_group, so it is standalone — in no \
614 group — while '{}' is in {b:?}. That is a cross-group join, not an unchecked \
615 one. To make '{}' a member of {b:?}, {}.",
616 joiner.name,
617 target.name,
618 joiner.name,
619 stamp_hint(joiner),
620 )),
621 (Some(a), None) => JoinVerdict::Refuse(format!(
622 "join refused: '{}' is in sovereign group {a:?} but '{}' declares none, so the \
623 target is standalone and has no group to join. Either {}, or found the group on \
624 '{}' rather than growing it.",
625 joiner.name,
626 target.name,
627 stamp_hint(target),
628 joiner.name,
629 )),
630 (None, None) => JoinVerdict::Refuse(format!(
631 "join refused: neither '{}' nor '{}' declares a sovereign_group, so this join \
632 would form a group nobody declared and nothing could later reason about. Name \
633 the group on both boxes first: {}, and the same for '{}'.",
634 joiner.name,
635 target.name,
636 stamp_hint(joiner),
637 target.name,
638 )),
639 }
640}
641
642impl MachineConfig {
643 /// This node's declared place in a sovereign group, as the shared join rule
644 /// wants it — R605-F12.
645 ///
646 /// The one place `sovereign_role`'s `None` is resolved. Absence means
647 /// [`SovereignRole::Voter`], which is what declaring a group meant before
648 /// the role existed; resolving it here rather than at each call site is what
649 /// keeps the camp-side and node-side gates from disagreeing about a node
650 /// that never wrote the field.
651 pub fn sovereign_membership(&self) -> Membership<'_> {
652 Membership {
653 group: self.sovereign_group.as_deref(),
654 role: self.sovereign_role.unwrap_or_default(),
655 }
656 }
657
658 /// Provider DC code, or `""` when omitted (static nodes). Most readers want
659 /// a `&str`; the driver-backed provision/status paths still go through
660 /// [`validate`](Self::validate) which guarantees presence for those.
661 pub fn location(&self) -> &str {
662 self.location.as_deref().unwrap_or("")
663 }
664
665 /// Provider SKU, or `""` when omitted (static nodes).
666 pub fn server_type(&self) -> &str {
667 self.server_type.as_deref().unwrap_or("")
668 }
669
670 /// Enforce the provisioning-only-field contract: a machine whose provider
671 /// has an auto-provision driver MUST declare `location` + `server_type`
672 /// (the driver can't create a server without them). Static nodes may omit
673 /// both. Call this before any provision/diff that assumes a driver.
674 pub fn validate(&self) -> Result<()> {
675 if provider_has_machine_driver(&self.provider) {
676 if self.location.is_none() {
677 anyhow::bail!(
678 "machine '{}' (provider '{}') has an auto-provision driver but no `location`",
679 self.name,
680 self.provider
681 );
682 }
683 if self.server_type.is_none() {
684 anyhow::bail!(
685 "machine '{}' (provider '{}') has an auto-provision driver but no `server_type`",
686 self.name,
687 self.provider
688 );
689 }
690 }
691 Ok(())
692 }
693
694 /// Declared taints that no placement decision can read (W305/R742-T4).
695 ///
696 /// Deliberately **not** folded into [`validate`](Self::validate): that
697 /// guard runs on the provision/diff hot path and answers a different
698 /// question (can the driver create this server). An inert taint is a lint
699 /// — it never breaks an operation in flight, it just means the file is
700 /// asserting something the scheduler will not honour. `yah cloud validate`
701 /// is where the operator asks for that judgement; see
702 /// [`crate::validate::check_inert_taints`].
703 pub fn inert_taints(&self) -> Vec<&str> {
704 self.taints
705 .iter()
706 .filter(|t| taint_effect(t) == TaintEffect::Inert)
707 .map(String::as_str)
708 .collect()
709 }
710
711 /// Yubaba's TOFU'd hostkey fingerprint, from `[registration]` and falling
712 /// back to the pre-R707-T1 top-level field. **The only read path** — a
713 /// caller that reaches for `legacy_hostkey_fingerprint` directly sees
714 /// `None` on every migrated machine.
715 pub fn hostkey_fingerprint(&self) -> Option<&str> {
716 self.registration
717 .hostkey_fingerprint
718 .as_deref()
719 .or(self.legacy_hostkey_fingerprint.as_deref())
720 }
721
722 /// Record (or clear) the observed hostkey fingerprint. Writes
723 /// `[registration]` and drops any pre-R707-T1 top-level value, so the two
724 /// locations can never disagree after a writeback.
725 pub fn set_hostkey_fingerprint(&mut self, fingerprint: Option<String>) {
726 self.registration.hostkey_fingerprint = fingerprint;
727 self.legacy_hostkey_fingerprint = None;
728 }
729
730 /// Mesh (tailnet) IPv4 for this node, or `None` pre-mesh.
731 ///
732 /// Prefers `[registration].mesh_ipv4`; falls back to the host of a legacy
733 /// `[connect].yubaba` URL when that host is in the `100.64.0.0/10` CGNAT
734 /// range the mesh uses. A loopback placeholder (`http://127.0.0.1:7443`,
735 /// meaning "pre-mesh, reachable only through an SSH tunnel") is *not* a
736 /// mesh address and yields `None`.
737 pub fn mesh_ipv4(&self) -> Option<&str> {
738 if let Some(ip) = self.registration.mesh_ipv4.as_deref() {
739 return Some(ip);
740 }
741 let url = self.connect.as_ref()?.yubaba.as_deref()?;
742 mesh_ipv4_from_url(url)
743 }
744
745 /// Base URL for this node's yubaba, or `None` when no reach resolves.
746 ///
747 /// Thin wrapper over [`reach`](Self::reach) for the many call sites that
748 /// only branch on presence. Prefer `reach` anywhere the operator sees the
749 /// outcome — a `None` here throws away a refusal that names exactly which
750 /// address is missing.
751 pub fn yubaba_url(&self) -> Option<String> {
752 self.reach().ok()
753 }
754
755 /// The **one** address automation dials for this node — mesh-only.
756 ///
757 /// `Err` is a *named refusal*, not an absence: a node with no mesh address
758 /// is unresolvable to every automated path, and R605-T10's whole complaint
759 /// is that this used to surface as a connect timeout against an address the
760 /// caller has no route to.
761 ///
762 /// Resolution order:
763 ///
764 /// 1. A declared `[connect].yubaba` on a **private** host (10/8,
765 /// 172.16/12, 192.168/16) is **not dialed** — see below.
766 /// 2. Any other declared `[connect].yubaba` wins verbatim. That includes
767 /// the pre-mesh loopback placeholder (`http://127.0.0.1:7443`, "I have
768 /// no mesh address; reach me through the SSH tunnel to `ssh`"), which is
769 /// a genuine declaration and stays honoured.
770 /// 3. Otherwise `[registration].mesh_ipv4` composed with
771 /// `[connect].yubaba_port`.
772 ///
773 /// **Why a LAN literal loses (R605-T10, operator 2026-08-19).** The LAN
774 /// address is an emergency break-glass route, never an official one, and
775 /// automation must ALWAYS assume the caller is not on that LAN — this camp
776 /// sits on 192.168.22.0/22 with no route to the fleet's 192.168.10.0/24 at
777 /// all. Writing one into the field every resolver dials does not sit beside
778 /// the mesh route, it *overrides* it: R707-T6 made a declared literal beat
779 /// `mesh_ipv4` outright, so us-west-011 (mesh-joined, healthy) was elected
780 /// for every aarch64 build and then dialed at an address that answers only
781 /// from inside bldg-2506.
782 ///
783 /// **What R707-T6 wanted is preserved elsewhere.** Its forcing case was
784 /// identity, not reach: the dev raft group advertises LAN addrs
785 /// (`192.168.10.11:7443`, verified live off `/raft/status` 2026-08-27), and
786 /// `rollout::yubaba::membership_to_nodes` has to map those back to declared
787 /// machines. That match now runs against [`lan_endpoint`](Self::lan_endpoint),
788 /// which is composed from the break-glass `[connect].address` metadata and
789 /// is never dialed — so the two concerns the old precedence rule fused are
790 /// split, and the literal can stop squatting a dialed field.
791 ///
792 /// The LAN address itself STAYS in the machine TOML. It is useful metadata
793 /// and the manual `ssh` path is entitled to it; it is only disconnected
794 /// from every automated process.
795 pub fn reach(&self) -> Result<String, String> {
796 let Some(connect) = self.connect.as_ref() else {
797 return Err(format!(
798 "machine {:?} declares no [connect] block, so nothing knows how to reach it \
799 \u{2192} declare one, or leave it unprovisioned and out of placement",
800 self.name
801 ));
802 };
803 let mesh = || {
804 self.registration
805 .mesh_ipv4
806 .as_deref()
807 .map(|ip| format!("http://{ip}:{}", connect.yubaba_port()))
808 };
809 if let Some(literal) = &connect.yubaba {
810 let Some(lan) = private_ipv4_from_url(literal) else {
811 return Ok(literal.clone());
812 };
813 return mesh().ok_or_else(|| {
814 format!(
815 "machine {:?} is unresolvable to automation: its only declared yubaba reach \
816 is the private literal {:?} and it has no [registration].mesh_ipv4\n\
817 \u{2192} a LAN address is an emergency break-glass route, never an official \
818 one (R605-T10) — every automated path assumes the caller is NOT on {}/24\n\
819 \u{2192} mesh-join the box and record `mesh_ipv4` under [registration], then \
820 delete `[connect].yubaba` so the port composes with it",
821 self.name,
822 literal,
823 lan.rsplit_once('.').map(|(net, _)| net).unwrap_or(lan),
824 )
825 });
826 }
827 mesh().ok_or_else(|| {
828 format!(
829 "machine {:?} has no [registration].mesh_ipv4 and declares no \
830 [connect].yubaba, so no automated path can reach it\n\
831 \u{2192} mesh-join the box and record its tailnet address, or taint it out of \
832 placement — do not point `[connect].yubaba` at a LAN address (R605-T10)",
833 self.name
834 )
835 })
836 }
837
838 /// The LAN `host:port` this node's yubaba answers on, composed from the
839 /// break-glass `[connect].address` metadata plus the declared port.
840 ///
841 /// **Identity only — never dial this.** It exists so a raft membership
842 /// entry that names a node by its LAN address can be mapped back to the
843 /// declared machine (`rollout::yubaba::membership_to_nodes`) without that
844 /// address having to live in a field a resolver reads. `None` when the
845 /// machine is unprovisioned.
846 pub fn lan_endpoint(&self) -> Option<String> {
847 let connect = self.connect.as_ref()?;
848 Some(format!("{}:{}", connect.address, connect.yubaba_port()))
849 }
850
851 /// Fold the pre-R707-T1 top-level `hostkey_fingerprint` into
852 /// `[registration]`, and lift a mesh IP out of a legacy `[connect].yubaba`
853 /// URL. Idempotent; a machine already on the split shape is untouched.
854 ///
855 /// [`save`](Self::save) calls this, so writing a machine TOML migrates it
856 /// rather than round-tripping the old shape back out.
857 pub fn normalize(&mut self) {
858 if let Some(fp) = self.legacy_hostkey_fingerprint.take() {
859 self.registration.hostkey_fingerprint.get_or_insert(fp);
860 }
861 if self.registration.mesh_ipv4.is_none() {
862 if let Some(ip) = self
863 .connect
864 .as_ref()
865 .and_then(|c| c.yubaba.as_deref())
866 .and_then(mesh_ipv4_from_url)
867 .map(str::to_string)
868 {
869 self.registration.mesh_ipv4 = Some(ip);
870 // The URL was pure derivation from mesh IP + port; keep only
871 // the declared half so the two can't drift apart.
872 if let Some(c) = self.connect.as_mut() {
873 c.yubaba = None;
874 }
875 }
876 }
877 }
878
879 /// Persist to `<cloud_dir>/machines/<name>.toml`, creating the dir if needed.
880 ///
881 /// ⚠ Serializes the struct, so **operator comments in the target file are
882 /// lost**. Pre-existing behaviour, not introduced here, but it is why
883 /// registration writeback (`yah cloud machine attach`) goes through
884 /// [`crate::state::MachineState`] and the comment-preserving path in the
885 /// CLI rather than calling this on a hand-authored inventory file.
886 pub fn save(&self, cloud_dir: &Path) -> Result<()> {
887 let dir = cloud_dir.join("machines");
888 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
889 let path = dir.join(format!("{}.toml", self.name));
890 let mut normalized = self.clone();
891 normalized.normalize();
892 let s = toml::to_string_pretty(&normalized)
893 .with_context(|| format!("serializing machine {}", self.name))?;
894 std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
895 }
896}
897
898/// Host of an `http://host:port` URL iff it is a mesh (headscale) IPv4 in the
899/// `100.64.0.0/10` CGNAT range. String-level rather than URL-parsed: the
900/// inventory format is stable and this crate carries no URL dependency (same
901/// reasoning as `fleet_metrics::extract_host` and
902/// `hub::coordinator::is_loopback_url`).
903fn mesh_ipv4_from_url(url: &str) -> Option<&str> {
904 let host = ipv4_host_of(url)?;
905 let ip: std::net::Ipv4Addr = host.parse().ok()?;
906 let [a, b, ..] = ip.octets();
907 // 100.64.0.0/10 ⇒ first octet 100, second octet 64..=127.
908 (a == 100 && (64..=127).contains(&b)).then_some(host)
909}
910
911/// Host of an `http://host:port` URL iff it is an **RFC1918 private** IPv4 —
912/// `10/8`, `172.16/12`, `192.168/16`. `None` for anything else, loopback and
913/// the `100.64/10` mesh range included: neither is a LAN literal.
914///
915/// The judgement R605-T10 turns on. A private literal is only ever reachable
916/// from inside one building, so it is metadata about where the box physically
917/// sits and never an address automation may dial — see
918/// [`MachineConfig::reach`] and [`crate::validate::check_lan_dial_targets`].
919pub fn private_ipv4_from_url(url: &str) -> Option<&str> {
920 let host = ipv4_host_of(url)?;
921 is_private_ipv4(host).then_some(host)
922}
923
924/// Whether a bare host string is an RFC1918 private IPv4 literal.
925pub fn is_private_ipv4(host: &str) -> bool {
926 let Ok(ip) = host.parse::<std::net::Ipv4Addr>() else {
927 return false;
928 };
929 ip.is_private()
930}
931
932/// Bare host of a `[scheme://]host[:port][/path]` string.
933fn ipv4_host_of(url: &str) -> Option<&str> {
934 let after_scheme = url.split("://").nth(1).unwrap_or(url);
935 after_scheme.split(['/', ':']).next()
936}
937
938/// Declared **reach** for a BYO `static` node (no provider API). Lives under
939/// `[connect]` in the machine TOML.
940///
941/// Reach only — how the camp gets to the box. *Permission* is a separate axis
942/// that belongs to cheers' scopes (W295 §"Deliberately deferred"); the two
943/// collapse in practice today (mesh membership grants everything) and the data
944/// model must not fuse them, so do not add an authorization field here.
945///
946/// `address` and `ssh` stay whole, literal, operator-authored strings even
947/// though their values often *look* derived. They are not: us-west-001 dials
948/// SSH over its public IP while us-west-002 was deliberately repointed at its
949/// tailnet IP (R608-F10) precisely because the LAN address is unreachable
950/// off-LAN. Decomposing them into user + host and recomposing would silently
951/// undo per-machine decisions like that one. `yubaba` is the field that *was*
952/// derived — mesh IP plus a fixed port, rewritten by mesh-join — so that is
953/// where R707-T1 cut.
954#[derive(Debug, Clone, Serialize, Deserialize)]
955#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
956pub struct ConnectSpec {
957 /// Reachable IPv4/host for the box, e.g. `"45.32.194.254"`. Declared: which
958 /// of a machine's several addresses the camp should use is an operator
959 /// choice (public IP vs. LAN IP vs. tailnet IP).
960 pub address: String,
961 /// SSH target the camp dials for bootstrap + (pre-mesh) tunneled deploys,
962 /// e.g. `"root@45.32.194.254"` or `"struc@100.64.0.4"`. Uses the operator's
963 /// `~/.ssh/yah` key. Declared, whole — see the type doc.
964 pub ssh: String,
965 /// Port yubaba listens on. Declared reach; defaults to 7443 when omitted,
966 /// which is every machine in the fleet today. Composed with the *observed*
967 /// [`MachineRegistration::mesh_ipv4`] by [`MachineConfig::yubaba_url`].
968 #[serde(default, skip_serializing_if = "Option::is_none")]
969 pub yubaba_port: Option<u16>,
970 /// Explicit yubaba base URL, overriding the composed form.
971 ///
972 /// Two live uses, both genuine declarations: a pre-mesh node saying
973 /// `"http://127.0.0.1:7443"` — "I have no mesh address; reach me through
974 /// the SSH tunnel to `ssh`" — and any node whose yubaba is not at
975 /// `mesh_ipv4:port`. A URL here whose host *is* a mesh IP is the
976 /// pre-R707-T1 shape; [`MachineConfig::normalize`] lifts it into
977 /// `[registration].mesh_ipv4` and clears this field so the two cannot
978 /// drift apart.
979 #[serde(default, skip_serializing_if = "Option::is_none")]
980 pub yubaba: Option<String>,
981}
982
983/// Default yubaba listen port, used when `[connect].yubaba_port` is omitted.
984pub const DEFAULT_YUBABA_PORT: u16 = 7443;
985
986impl ConnectSpec {
987 /// Declared yubaba port, defaulting to [`DEFAULT_YUBABA_PORT`].
988 pub fn yubaba_port(&self) -> u16 {
989 self.yubaba_port.unwrap_or(DEFAULT_YUBABA_PORT)
990 }
991}
992
993#[derive(Debug, Clone, Serialize, Deserialize)]
994#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
995pub struct BucketSpec {
996 pub name: String,
997 pub public_read: bool,
998}
999
1000/// Per-camp mirror declaration from `.yah/cloud/mirrors/<id>/mirror.toml`
1001/// (folder form) or the legacy `.yah/cloud/mirrors/<id>.toml` (flat form).
1002///
1003/// The folder form is preferred for new mirrors so that per-mirror secrets
1004/// and override files can sit next to `mirror.toml` without polluting the
1005/// top-level `mirrors/` directory.
1006#[derive(Debug, Clone, Serialize, Deserialize)]
1007pub struct LegacyMirrorConfig {
1008 /// Logical camp name this mirror hosts, e.g. `"yah"` or `"noisetable"`.
1009 ///
1010 /// Serialised as `camp`; accepts the legacy `rig` spelling for files that
1011 /// predate the R137 rig→camp rename (one-time migration: `sed -i ''
1012 /// 's/^rig = /camp = /' ~/.yah/cloud/mirrors/*.toml`).
1013 #[serde(rename = "camp", alias = "rig")]
1014 pub camp: String,
1015 pub regions: Vec<String>,
1016 /// Workload names deployed as part of this mirror (references `workloads/<name>.toml`).
1017 /// Renamed from `services` in R092-F1; use `yah cloud config migrate-services-to-workloads`
1018 /// on repos that still have the old `services/` layout.
1019 #[serde(alias = "services")]
1020 pub workloads: Vec<String>,
1021 /// Base domain for Cloudflare-fronted services on this mirror's machines.
1022 /// Combined with the machine's `location` to build virtual-host names:
1023 /// e.g. `cloud_domain = "cloud.noisetable.example"` on machine in location
1024 /// `pdx` → Caddyfile site address `pdx.cloud.noisetable.example`.
1025 /// Optional: if unset the Caddyfile falls back to `:port` listeners.
1026 #[serde(default, skip_serializing_if = "Option::is_none")]
1027 pub cloud_domain: Option<String>,
1028}
1029
1030/// Error from loading or validating a single workload TOML file.
1031#[derive(Debug, Error)]
1032pub enum WorkloadConfigError {
1033 #[error("reading {path}: {source}")]
1034 Io {
1035 path: String,
1036 source: std::io::Error,
1037 },
1038 #[error("parsing {path}: {source}")]
1039 Toml {
1040 path: String,
1041 source: toml::de::Error,
1042 },
1043 #[error("invalid WorkloadSpec in {path}: {source}")]
1044 Shape {
1045 path: String,
1046 source: validate::ShapeError,
1047 },
1048}
1049
1050/// A workload declaration loaded from `.yah/cloud/workloads/<name>.toml`.
1051///
1052/// Each file is the human-authored TOML serialization of a [`WorkloadSpec`].
1053/// On load, the spec is validated against the shape layer; failures surface as
1054/// a [`CloudConfigError::Workload`] with the file path and field path.
1055#[derive(Debug, Clone, Serialize, Deserialize)]
1056pub struct WorkloadConfig {
1057 /// The validated spec.
1058 #[serde(flatten)]
1059 pub spec: WorkloadSpec,
1060}
1061
1062impl WorkloadConfig {
1063 /// Persist to `<cloud_dir>/workloads/<name>.toml`, creating the dir if needed.
1064 pub fn save(&self, cloud_dir: &Path) -> Result<()> {
1065 let dir = cloud_dir.join("workloads");
1066 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
1067 let path = dir.join(format!("{}.toml", self.spec.name));
1068 let s = toml::to_string_pretty(self)
1069 .with_context(|| format!("serializing workload {}", self.spec.name))?;
1070 std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
1071 }
1072}
1073
1074/// Error surfaced by [`CloudConfig::load`] when a workload TOML fails validation.
1075#[derive(Debug, Error)]
1076pub enum CloudConfigError {
1077 #[error(transparent)]
1078 Anyhow(#[from] anyhow::Error),
1079 #[error("workload validation failed: {0}")]
1080 Workload(WorkloadConfigError),
1081}
1082
1083/// Mirror-to-machine assignment table from `.yah/cloud/topology.toml`.
1084///
1085/// Declares which logical mirror names are assigned to which machines.
1086/// This is the source-canonical placement until yubaba raft observes it
1087/// (per the migration tracker in the arch doc).
1088#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1089pub struct TopologyConfig {
1090 /// Mirror→machine assignments.
1091 #[serde(default)]
1092 pub assignments: Vec<MirrorAssignment>,
1093 /// Declared buckets, logged by `yah cloud bucket create`.
1094 /// Source-canonical until yubaba raft observes actual placement.
1095 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1096 pub buckets: Vec<BucketLogEntry>,
1097}
1098
1099impl TopologyConfig {
1100 /// Load from a `topology.toml` file, returning `Default` when absent.
1101 pub fn load(path: &Path) -> Result<Self> {
1102 if !path.exists() {
1103 return Ok(Self::default());
1104 }
1105 let s =
1106 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
1107 toml::from_str(&s).with_context(|| format!("parsing {}", path.display()))
1108 }
1109
1110 /// Persist to `topology.toml`, creating parent dirs if needed.
1111 pub fn save(&self, path: &Path) -> Result<()> {
1112 if let Some(parent) = path.parent() {
1113 std::fs::create_dir_all(parent)
1114 .with_context(|| format!("creating {}", parent.display()))?;
1115 }
1116 let s = toml::to_string_pretty(self).context("serializing topology")?;
1117 std::fs::write(path, s).with_context(|| format!("writing {}", path.display()))
1118 }
1119
1120 /// Find a declared bucket by name.
1121 pub fn bucket_by_name(&self, name: &str) -> Option<&BucketLogEntry> {
1122 self.buckets.iter().find(|b| b.name == name)
1123 }
1124
1125 /// Find a mutable declared bucket by name.
1126 pub fn bucket_by_name_mut(&mut self, name: &str) -> Option<&mut BucketLogEntry> {
1127 self.buckets.iter_mut().find(|b| b.name == name)
1128 }
1129
1130 /// Returns true if the bucket is declared as cross-machine (no owning machine).
1131 pub fn is_cross_machine_bucket(&self, name: &str) -> bool {
1132 self.buckets
1133 .iter()
1134 .any(|b| b.name == name && b.machine.is_none())
1135 }
1136}
1137
1138/// One mirror→machine placement entry in `topology.toml`.
1139#[derive(Debug, Clone, Serialize, Deserialize)]
1140pub struct MirrorAssignment {
1141 /// Logical mirror name, e.g. `"noisetable-pdx"`.
1142 pub mirror: String,
1143 /// Machine that hosts this mirror, e.g. `"noisetable-pdx-1"`.
1144 pub machine: String,
1145}
1146
1147/// A bucket declaration logged in `topology.toml` by `yah cloud bucket create`.
1148#[derive(Debug, Clone, Serialize, Deserialize)]
1149pub struct BucketLogEntry {
1150 pub name: String,
1151 /// Machine that owns this bucket. `None` marks it as cross-machine
1152 /// (no single-machine ownership; requires an explicit declaration in
1153 /// `topology.toml` before `yah cloud bucket create` will proceed without
1154 /// `--machine`).
1155 #[serde(default, skip_serializing_if = "Option::is_none")]
1156 pub machine: Option<String>,
1157 /// Logical location of the bucket, e.g. `"pdx"`.
1158 pub location: String,
1159 /// Current declared policy: `"private"` | `"public-read"` | `"signed-only"`.
1160 #[serde(default = "default_bucket_policy")]
1161 pub policy: String,
1162}
1163
1164fn default_bucket_policy() -> String {
1165 "private".to_string()
1166}
1167
1168/// Per-service config from `.yah/cloud/services/<name>.toml`.
1169///
1170/// **Deprecated.** The `services/` layout was replaced by `workloads/` in R092-F1.
1171/// Kept to allow in-place reads for repos that haven't migrated yet; use
1172/// `yah cloud config migrate-services-to-workloads` to upgrade.
1173#[derive(Debug, Clone, Serialize, Deserialize)]
1174pub struct LegacyServiceConfig {
1175 pub name: String,
1176 pub image: String,
1177 pub version: String,
1178 #[serde(default)]
1179 pub env: HashMap<String, String>,
1180 #[serde(default)]
1181 pub ports: Vec<PortMapping>,
1182 #[serde(default)]
1183 pub mesh_only: bool,
1184 /// Network interface this service binds to exclusively (e.g. `"tailscale0"`).
1185 ///
1186 /// When set the compose renderer emits `network_mode: "host"` and the
1187 /// service is NOT joined to the shared compose bridge network. The service
1188 /// process must bind its listen socket to the named interface's IP — for
1189 /// Postgres this means setting `POSTGRES_LISTEN_ADDRESSES` to the node's
1190 /// `tailscale ip --4` output at first boot. See [`crate::mesh_service`] for
1191 /// the standard pg_hba.conf snippet and ufw rules to pair with this field.
1192 #[serde(default, skip_serializing_if = "Option::is_none")]
1193 pub bind_interface: Option<String>,
1194
1195 /// Tenant this service belongs to (W206 isolation axis). Absent in the
1196 /// service TOML → [`TenantId::singleton`], keeping single-tenant machines
1197 /// on one shared compose network. When a machine hosts services from two
1198 /// or more distinct tenants, the compose renderer (R558-T2) splits them
1199 /// into per-tenant `<tenant>-<tier>` networks so cross-tenant stacks on the
1200 /// same host are not bridged together.
1201 #[serde(default = "TenantId::singleton")]
1202 pub tenant: TenantId,
1203}
1204
1205#[derive(Debug, Clone, Serialize, Deserialize)]
1206pub struct PortMapping {
1207 pub host: u16,
1208 pub container: u16,
1209}
1210
1211/// A loaded service plus its per-environment mirrors.
1212///
1213/// Wraps the `service.toml` body and the directory of `mirrors/<env>.toml`
1214/// files that project the service onto concrete infra.
1215#[derive(Debug, Clone, Serialize, Deserialize)]
1216pub struct ServiceWithMirrors {
1217 pub service: ServiceConfig,
1218 /// Mirrors keyed by environment name (file stem of `mirrors/<env>.toml`).
1219 pub mirrors: BTreeMap<String, MirrorConfig>,
1220 /// Transform recipe names keyed by component id. Populated from each
1221 /// static-asset component's `workload.toml` at load time — not stored
1222 /// in service.toml. Only present for components that declare
1223 /// `[asset.derive.transform] recipe = "..."`.
1224 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1225 pub component_transform_recipes: BTreeMap<String, String>,
1226}
1227
1228/// All cloud config loaded from a workspace root (the parent of `.yah/`).
1229///
1230/// Reads two trees:
1231/// - `.yah/infra/` — `machines/`, `providers/`
1232/// - `.yah/services/<svc>/` — `service.toml` + `mirrors/<env>.toml`
1233///
1234/// Pre-R215 fields (`legacy_mirrors`, `legacy_services`, `workloads`,
1235/// `topology`) are still populated from `.yah/cloud/` when present so
1236/// pre-R215 callers (compose.rs, bucket commands) keep compiling — they
1237/// just see empty collections in a post-B1 workspace where the legacy
1238/// data was deleted. These fields are scheduled for removal in B3-T3.
1239#[derive(Debug)]
1240pub struct CloudConfig {
1241 /// Workspace root that was loaded — useful for path-resolving
1242 /// component references on a [`ServiceComponent`].
1243 pub workspace_root: std::path::PathBuf,
1244
1245 // ─── R215+ tree ────────────────────────────────────────────────────────
1246 /// `.yah/infra/machines/<name>.toml`
1247 pub machines: Vec<MachineConfig>,
1248 /// `.yah/infra/providers/<id>.toml`
1249 pub providers: Vec<ProviderConfig>,
1250 /// Provenance for every entry in `machines` that came from a linked
1251 /// `.yah/infra/sources.toml` source rather than this camp's own
1252 /// `.yah/infra/machines/` (R615-F2 / W274). Keyed by
1253 /// [`MachineConfig::name`]; a name absent here is camp-local. Empty from
1254 /// [`CloudConfig::load_from_config_dir`] — see its doc for why sources
1255 /// don't apply to multi-root sibling trees.
1256 pub machine_origins: BTreeMap<String, InfraOrigin>,
1257 /// Same as [`machine_origins`](Self::machine_origins), keyed by
1258 /// [`ProviderConfig::id`].
1259 pub provider_origins: BTreeMap<String, InfraOrigin>,
1260 /// `.yah/services/<svc>/` — service.toml plus mirrors/<env>.toml.
1261 pub services: BTreeMap<String, ServiceWithMirrors>,
1262 /// `.yah/domains/<name>.toml` — public-facing routing manifests
1263 /// (R347). Single file per domain; no nested per-env tree because
1264 /// domains themselves aren't projected onto infra — they describe
1265 /// how a Worker bundle ingresses requests onto services.
1266 pub domains: BTreeMap<String, DomainConfig>,
1267
1268 // ─── Pre-R215 legacy (slated for removal in B3-T3) ────────────────────
1269 /// Legacy mirrors from `.yah/cloud/mirrors/`.
1270 pub legacy_mirrors: Vec<LegacyMirrorConfig>,
1271 /// Workloads from `.yah/cloud/workloads/*.toml` (R092-F1 schema).
1272 pub workloads: Vec<WorkloadConfig>,
1273 /// Topology from `.yah/cloud/topology.toml` (mirror→machine assignments).
1274 pub topology: TopologyConfig,
1275 /// Legacy services from `.yah/cloud/services/*.toml` (pre-R092 layout).
1276 pub legacy_services: Vec<LegacyServiceConfig>,
1277}
1278
1279impl CloudConfig {
1280 /// Load all cloud config rooted at `workspace_root` (the parent of `.yah/`).
1281 ///
1282 /// Reads the R215+ tree (`.yah/infra/`, `.yah/services/<svc>/`) eagerly
1283 /// and the pre-R215 `.yah/cloud/` tree opportunistically. Returns `Err`
1284 /// immediately if any TOML fails to parse or a workload TOML fails
1285 /// shape validation; the error includes the file path and field path.
1286 ///
1287 /// Cross-ref validation runs after both trees finish loading: every
1288 /// `mirror.providers.X.use = "<id>"` must resolve to a real provider
1289 /// declared under `.yah/infra/providers/`.
1290 pub fn load(workspace_root: &Path) -> Result<Self> {
1291 let mut providers = load_providers(&crate::paths::providers_dir(workspace_root))?;
1292 let services = load_services(&crate::paths::services_dir(workspace_root), workspace_root)?;
1293 let domains = load_domains(&crate::paths::domains_dir(workspace_root))?;
1294
1295 Self::cross_ref_validate(&providers, &services, &domains)?;
1296
1297 // Legacy `.yah/cloud/` reads — empty in post-B1 workspaces. Wrapped in
1298 // a helper so a missing tree is silent (no error, no warning).
1299 let cloud_dir = crate::paths::legacy_cloud_dir(workspace_root);
1300 let (legacy_machines, legacy_mirrors, legacy_workloads, topology, legacy_services) =
1301 if cloud_dir.exists() {
1302 (
1303 load_dir::<MachineConfig>(cloud_dir.join("machines"))?,
1304 load_mirrors(cloud_dir.join("mirrors"))?,
1305 load_workloads(cloud_dir.join("workloads"))?,
1306 load_topology(cloud_dir.join("topology.toml"))?,
1307 load_dir::<LegacyServiceConfig>(cloud_dir.join("services"))?,
1308 )
1309 } else {
1310 Default::default()
1311 };
1312
1313 // Workloads come from `.yah/infra/workloads/` (R215+). R568-T7: before
1314 // that path was read here, this field was populated *only* from the
1315 // legacy tree above — which R222-B1 emptied — so `cfg.workload(name)`
1316 // resolved nothing in every post-R215 camp and `yah cloud workload
1317 // deploy` could not find any declaration at all. The bug survived
1318 // because the only workloads ever deployed were forge/QED runs, which
1319 // build their spec in memory and never come through here. Same
1320 // dedupe-by-name shape as machines below: R215+ wins.
1321 let mut workloads = load_workloads(crate::paths::workloads_dir(workspace_root))?;
1322 let workload_names: std::collections::HashSet<String> =
1323 workloads.iter().map(|w| w.spec.name.clone()).collect();
1324 for w in legacy_workloads {
1325 if !workload_names.contains(&w.spec.name) {
1326 workloads.push(w);
1327 }
1328 }
1329
1330 // Machines come from `.yah/infra/machines/` (R215+); the pre-R215
1331 // tree shouldn't have any since B1 moved them, but if it does we
1332 // dedupe by name (R215 wins).
1333 let mut machines = load_dir::<MachineConfig>(crate::paths::machines_dir(workspace_root))?;
1334 let names: std::collections::HashSet<String> =
1335 machines.iter().map(|m| m.name.clone()).collect();
1336 for m in legacy_machines {
1337 if !names.contains(&m.name) {
1338 machines.push(m);
1339 }
1340 }
1341
1342 // R615-F2: overlay every linked `.yah/infra/sources.toml` source's
1343 // machines/providers UNDER what's already loaded above, so camp-local
1344 // (including the legacy-tree entries just merged in) always wins on a
1345 // name collision. `SourcesConfig::load` itself never touches the
1346 // network — git sources are read from `yah infra sync`'s cache
1347 // (R615-T3), so this call keeps `load()`'s whole offline contract.
1348 let sources = SourcesConfig::load(&crate::paths::infra_dir(workspace_root))?;
1349 let mut machine_origins = BTreeMap::new();
1350 let mut provider_origins = BTreeMap::new();
1351 overlay_infra_sources(
1352 workspace_root,
1353 &sources,
1354 &mut machines,
1355 &mut providers,
1356 &mut machine_origins,
1357 &mut provider_origins,
1358 );
1359
1360 Ok(Self {
1361 workspace_root: workspace_root.to_path_buf(),
1362 machines,
1363 providers,
1364 machine_origins,
1365 provider_origins,
1366 services,
1367 domains,
1368 legacy_mirrors,
1369 workloads,
1370 topology,
1371 legacy_services,
1372 })
1373 }
1374
1375 /// Load the R215+ tree (`infra/`, `services/`, `domains/`) rooted at an
1376 /// arbitrary config directory instead of the hardcoded `.yah/`. This is the
1377 /// building block for multi-root deployments (W206 config layout (b), sibling
1378 /// `.noisetable/` trees) — see [`crate::multi_root`]. Part of R558-F4.
1379 ///
1380 /// `config_dir` is the `.X/` directory itself (e.g. `<parent>/.noisetable`);
1381 /// `workspace_root` remains the camp dir (the config dir's parent) so a
1382 /// component's `path` reference resolves against the same tree the classic
1383 /// [`CloudConfig::load`] uses. The legacy `.yah/cloud/` reads are skipped —
1384 /// multi-root deployments are post-R215 by construction — so `legacy_*`,
1385 /// `workloads`, and `topology` come back empty. Machines are read from
1386 /// `config_dir/infra/machines` directly (sibling trees declare their own
1387 /// inventory or none).
1388 ///
1389 /// R615-F2 decision, explicit rather than silent: **sources.toml overlay
1390 /// does NOT apply here.** This function
1391 /// exists specifically because a multi-root sibling tree (W206 layout
1392 /// (b), e.g. `.noisetable/`) is a *second config root inside the same
1393 /// camp*, not a second camp — `config_dir` is already wherever the
1394 /// caller decided this tree's infra lives, and `.yah/infra/sources.toml`
1395 /// (singular, tied to `paths::infra_dir(workspace_root)`) has no
1396 /// well-defined meaning for an arbitrary `config_dir` that isn't that
1397 /// path. A sibling tree that wants borrowed infra declares its own
1398 /// `sources.toml` under whichever root actually calls
1399 /// [`CloudConfig::load`] for it; `machine_origins`/`provider_origins`
1400 /// come back empty here, not wrong — there is nothing to overlay.
1401 pub fn load_from_config_dir(config_dir: &Path, workspace_root: &Path) -> Result<Self> {
1402 let providers = load_providers(&config_dir.join("infra").join("providers"))?;
1403 let services = load_services(&config_dir.join("services"), workspace_root)?;
1404 let domains = load_domains(&config_dir.join("domains"))?;
1405
1406 Self::cross_ref_validate(&providers, &services, &domains)?;
1407
1408 let machines = load_dir::<MachineConfig>(config_dir.join("infra").join("machines"))?;
1409
1410 Ok(Self {
1411 workspace_root: workspace_root.to_path_buf(),
1412 machines,
1413 providers,
1414 machine_origins: BTreeMap::new(),
1415 provider_origins: BTreeMap::new(),
1416 services,
1417 domains,
1418 legacy_mirrors: vec![],
1419 workloads: vec![],
1420 topology: TopologyConfig::default(),
1421 legacy_services: vec![],
1422 })
1423 }
1424
1425 /// Cross-reference validation shared by [`CloudConfig::load`] and
1426 /// [`CloudConfig::load_from_config_dir`]: every mirror `providers.X.use =
1427 /// "<id>"` must resolve to a declared provider, and every domain route's
1428 /// `component = "<service>/<component-id>"` must resolve to a real component.
1429 fn cross_ref_validate(
1430 providers: &[ProviderConfig],
1431 services: &BTreeMap<String, ServiceWithMirrors>,
1432 domains: &BTreeMap<String, DomainConfig>,
1433 ) -> Result<()> {
1434 // Mirror `use = "<id>"` slots must resolve to a declared provider.
1435 let provider_ids: std::collections::HashSet<&str> =
1436 providers.iter().map(|p| p.id.as_str()).collect();
1437 for (svc_name, svc) in services {
1438 for (env, mirror) in &svc.mirrors {
1439 for (slot, body) in &mirror.providers {
1440 if let Some(id) = body.provider_id() {
1441 if !provider_ids.contains(id) {
1442 anyhow::bail!(
1443 "services/{svc_name}/mirrors/{env}.toml: \
1444 providers.{slot}.use = \"{id}\" — no such provider; \
1445 declare it at infra/providers/{id}.toml"
1446 );
1447 }
1448 }
1449 }
1450 // An `[[ingress]]` edge's own `use` is the same kind of
1451 // reference (R845) and gets the same check: a typo there is
1452 // otherwise invisible until `yah cloud apply` reaches the
1453 // Cloudflare arm and fails on a missing provider file.
1454 for (idx, edge) in mirror.ingress_edge_slice().iter().enumerate() {
1455 if let Some(id) = edge.provider_id.as_deref() {
1456 if !provider_ids.contains(id) {
1457 anyhow::bail!(
1458 "services/{svc_name}/mirrors/{env}.toml: \
1459 ingress[{idx}].use = \"{id}\" — no such provider; \
1460 declare it at infra/providers/{id}.toml"
1461 );
1462 }
1463 }
1464 }
1465 }
1466 }
1467
1468 // Every domain route's `component = "<service>/<component-id>"` must
1469 // resolve to a real component.
1470 for (dom_name, dom) in domains {
1471 for (idx, route) in dom.routes.iter().enumerate() {
1472 let Some(component_ref) = route.mode.component() else {
1473 continue; // redirects don't reference components
1474 };
1475 let Some((svc_name, comp_id)) = split_component_ref(component_ref) else {
1476 anyhow::bail!(
1477 "domains/{dom_name}.toml: routes[{idx}].component = \
1478 \"{component_ref}\" — expected \"<service>/<component-id>\""
1479 );
1480 };
1481 let Some(svc) = services.get(svc_name) else {
1482 anyhow::bail!(
1483 "domains/{dom_name}.toml: routes[{idx}].component = \
1484 \"{component_ref}\" — no such service \"{svc_name}\" \
1485 under services/"
1486 );
1487 };
1488 let Some(component) = svc.service.components.iter().find(|c| c.id == comp_id)
1489 else {
1490 anyhow::bail!(
1491 "domains/{dom_name}.toml: routes[{idx}].component = \
1492 \"{component_ref}\" — service \"{svc_name}\" has no \
1493 component with id \"{comp_id}\""
1494 );
1495 };
1496
1497 // R746: a mounted component must be routed where it publishes.
1498 // The publisher writes its bundle under the mount and the front
1499 // door looks a request up by its own path, so a route path and
1500 // a mount that disagree produce a 404 with its cause two files
1501 // away. Checked in both directions, since either one alone is
1502 // the same silent miss.
1503 //
1504 // Static routes only: `mount` is a *storage* prefix, and a
1505 // backend route proxies to an origin that owns its own paths.
1506 if !matches!(route.mode, RouteMode::Static { .. }) {
1507 continue;
1508 }
1509 let mount = component.mount.as_deref().map(normalize_mount);
1510 let route_prefix = route_path_prefix(&route.path);
1511 if let Some(mount) = mount {
1512 if mount != route_prefix {
1513 anyhow::bail!(
1514 "domains/{dom_name}.toml: routes[{idx}].path = \
1515 \"{path}\" serves \"{component_ref}\", which \
1516 declares mount = \"/{mount}\" — a mounted \
1517 component publishes under its mount, so the route \
1518 must be \"/{mount}\" or \"/{mount}/*\" (or drop \
1519 the mount to serve from the service root)",
1520 path = route.path,
1521 );
1522 }
1523 } else if !route_prefix.is_empty() {
1524 anyhow::bail!(
1525 "domains/{dom_name}.toml: routes[{idx}].path = \
1526 \"{path}\" serves \"{component_ref}\", which declares \
1527 no `mount` — its bundle publishes at the service root, \
1528 so nothing is stored under \"/{route_prefix}\". Set \
1529 mount = \"/{route_prefix}\" on the component, or route \
1530 it at \"/*\"",
1531 path = route.path,
1532 );
1533 }
1534 }
1535 }
1536 Ok(())
1537 }
1538
1539 /// Look up a domain manifest by name (file stem under `.yah/domains/`).
1540 pub fn domain(&self, name: &str) -> Option<&DomainConfig> {
1541 self.domains.get(name)
1542 }
1543
1544 pub fn machine(&self, name: &str) -> Option<&MachineConfig> {
1545 self.machines.iter().find(|m| m.name == name)
1546 }
1547
1548 /// Look up a provider by id (matches `provider.id`, not the file stem).
1549 pub fn provider(&self, id: &str) -> Option<&ProviderConfig> {
1550 self.providers.iter().find(|p| p.id == id)
1551 }
1552
1553 /// Look up a service by name (matches `service.toml`'s `name` field).
1554 pub fn service(&self, name: &str) -> Option<&ServiceWithMirrors> {
1555 self.services.get(name)
1556 }
1557
1558 /// Look up a legacy mirror by camp name (pre-R215 .yah/cloud/mirrors/).
1559 pub fn legacy_mirror(&self, camp: &str) -> Option<&LegacyMirrorConfig> {
1560 self.legacy_mirrors.iter().find(|m| m.camp == camp)
1561 }
1562
1563 pub fn workload(&self, name: &str) -> Option<&WorkloadConfig> {
1564 self.workloads.iter().find(|w| w.spec.name == name)
1565 }
1566
1567 /// Every machine declaring `sovereign_group == group`, in declaration order.
1568 ///
1569 /// W305/R742-F3. A sovereign group has no file of its own — it exists only
1570 /// as the set of machines that name the same string — so "which boxes are
1571 /// the dev cluster" has to be *derived*, and before this it was not derived
1572 /// anywhere: `yah cloud rollout plan` still takes a hand-listed
1573 /// `--voter us-west-011 --voter us-west-013 …` for a fact the machine TOMLs
1574 /// already state (W314 gap 1).
1575 ///
1576 /// **This is not placement.** Resolving a group to its members is a
1577 /// *lookup*, and it stays outside [`RequiredSpec`] on purpose — see
1578 /// [`MachineConfig::sovereign_group`]. `migrate` calls this to pick the
1579 /// candidate set it then admits a workload against; nothing here filters
1580 /// scheduling, and adding `sovereign_group` to `matches` would still be the
1581 /// category error that doc warns about.
1582 ///
1583 /// An empty result means no machine declares `group`, which is
1584 /// indistinguishable from a typo — callers should say so with
1585 /// [`Self::declared_sovereign_groups`] rather than reporting "no
1586 /// candidates".
1587 pub fn machines_in_group(&self, group: &str) -> Vec<&MachineConfig> {
1588 self.machines
1589 .iter()
1590 .filter(|m| m.sovereign_group.as_deref() == Some(group))
1591 .collect()
1592 }
1593
1594 /// Every distinct `sovereign_group` declared by any machine, sorted.
1595 ///
1596 /// Exists so a bad `--to` names the real vocabulary instead of complaining
1597 /// abstractly — the same fail-loud shape [`taint_effect`]'s legal-key list
1598 /// gives `check_inert_taints`. Standalone machines (`None`) contribute
1599 /// nothing: "in no group" is not a group you can migrate *to*.
1600 pub fn declared_sovereign_groups(&self) -> Vec<&str> {
1601 let mut groups: Vec<&str> = self
1602 .machines
1603 .iter()
1604 .filter_map(|m| m.sovereign_group.as_deref())
1605 .collect();
1606 groups.sort_unstable();
1607 groups.dedup();
1608 groups
1609 }
1610
1611 /// F16 placement v1: the first machine satisfying every hard axis of `req`
1612 /// (region/zone/provider membership + mesh_tags superset). Declaration order
1613 /// in `.yah/infra/machines/` decides ties — deterministic-greedy, no
1614 /// backtracking. A fully-unconstrained `req` matches the first machine.
1615 ///
1616 /// Fails loud with the constraint summary and the candidate machine names
1617 /// when nothing matches, so `yah cloud apply` surfaces *why* placement
1618 /// failed instead of a silent empty set.
1619 pub fn resolve_machine(&self, req: &RequiredSpec) -> Result<&MachineConfig> {
1620 resolve_machine_among(&self.machines, req)
1621 }
1622
1623 /// F16 placement: first machine whose `mesh_tags` is a superset of
1624 /// `required`. Declaration order in `.yah/infra/machines/` decides ties.
1625 /// Empty `required` matches the first machine; callers should treat
1626 /// empty-required as "no constraint" and skip this lookup.
1627 ///
1628 /// Back-compat thin wrapper over [`CloudConfig::resolve_machine`] for the
1629 /// mesh-tags-only call sites that predate the topology axes.
1630 pub fn resolve_machine_by_mesh_tags(&self, required: &[String]) -> Option<&MachineConfig> {
1631 let req = RequiredSpec {
1632 mesh_tags: required.to_vec(),
1633 ..Default::default()
1634 };
1635 self.resolve_machine(&req).ok()
1636 }
1637
1638 /// Admission: resolve the target machine for a remote [`WorkloadSpec`],
1639 /// honoring the R594 mesh-tag node-selector annotation
1640 /// (`velveteen_exec::remote::NODE_SELECTOR_MESH_TAGS_ANNOTATION` =
1641 /// `yah.node-selector.mesh-tags`, comma-joined).
1642 ///
1643 /// The producer side (`velveteen_exec::remote::build_workload_spec`, R594) writes
1644 /// `TaskLocation::RemoteAny.mesh_tags` — e.g. `[tag:build-worker, arch:x86]`
1645 /// from [`qed::platform::build_worker_mesh_tags`] — into the workload's
1646 /// annotations. This is the consumer: candidates are restricted to machines
1647 /// whose `mesh_tags` are a **superset** of the requested set, so an amd64
1648 /// build lands on the `arch:x86` build-worker (us-west-002) and an arm64
1649 /// build on a `arch:arm` Pi5. Declaration order in `.yah/infra/machines/`
1650 /// breaks ties.
1651 ///
1652 /// An absent or empty annotation means "no mesh-tag constraint" — pre-R594
1653 /// behavior (any node), matching [`RequiredSpec::is_unconstrained`].
1654 ///
1655 /// This is the single admission seam: R572-F5 extends it with the capacity
1656 /// floor (workload request fits node allocatable−committed) and taint
1657 /// repulsion/affinity by enriching [`RequiredSpec::matches`] /
1658 /// [`Self::resolve_machine`]. Do not fork a second selector.
1659 pub fn admit_workload(&self, ws: &WorkloadSpec) -> Result<&MachineConfig> {
1660 self.resolve_machine(&admission_spec(ws))
1661 }
1662
1663 /// [`Self::admit_workload`] restricted to the machines of one sovereign
1664 /// group (W305/R742-F3, `yah cloud migrate --to <group>`).
1665 ///
1666 /// Same [`RequiredSpec`], same [`RequiredSpec::matches`], same
1667 /// declaration-order tie-break — only the candidate *set* differs. That is
1668 /// the whole reason this is a narrowing of the admission seam rather than a
1669 /// second selector: a workload that cannot be scheduled onto a group's
1670 /// boxes must fail here for exactly the reason it would fail anywhere else,
1671 /// and `no-appliance` on the dev Pis (W305 finding 2) is precisely the case
1672 /// that must not be silently routed around by a migration verb.
1673 ///
1674 /// `Err` when the group has no members *or* when no member admits `ws`; the
1675 /// two are different mistakes, so callers wanting to tell them apart should
1676 /// check [`Self::machines_in_group`] first.
1677 pub fn admit_workload_in_group(
1678 &self,
1679 ws: &WorkloadSpec,
1680 group: &str,
1681 ) -> Result<&MachineConfig> {
1682 let members = self.machines_in_group(group);
1683 let empty_pool = format!(
1684 "(no machine declares sovereign_group = \"{group}\" — declared groups: {})",
1685 match self.declared_sovereign_groups().as_slice() {
1686 [] => "(none)".to_string(),
1687 gs => gs.join(", "),
1688 }
1689 );
1690 first_match(
1691 &members,
1692 &admission_spec(ws),
1693 &format!("machines in sovereign group '{group}'"),
1694 &empty_pool,
1695 )
1696 }
1697}
1698
1699/// **The** placement selector: the first candidate satisfying every axis of
1700/// `req`, declaration order breaking ties, deterministic-greedy with no
1701/// backtracking.
1702///
1703/// Every path that picks a machine goes through here, and the only thing any
1704/// of them varies is *which machines are candidates* — never the predicate.
1705/// [`CloudConfig::resolve_machine`] passes the whole fleet;
1706/// [`CloudConfig::admit_workload_in_group`] passes one sovereign group's
1707/// members. That split is the point: a candidate-set narrowing composes with
1708/// the [`RequiredSpec`] axes for free, whereas expressing the same narrowing
1709/// *as* an axis would put facts like blast radius into a filter they must
1710/// never be in (see [`MachineConfig::sovereign_group`]).
1711///
1712/// So a new placement scope is a new candidate set plus a `pool` label, and a
1713/// new placement *constraint* is a field on [`RequiredSpec`] — those are the
1714/// two extension points, and neither is a second selector. `pool` and
1715/// `empty_pool` exist only so the failure names the set it actually searched;
1716/// a refusal that says "no candidates" without saying *among what* is one the
1717/// operator has to reconstruct by hand.
1718/// F16 placement v1 resolution over an explicit machine list — the
1719/// `.machines`-only half of [`CloudConfig::resolve_machine`], for callers that
1720/// have loaded just the machines tree rather than the whole cross-ref-validated
1721/// config.
1722///
1723/// R772: `resolve_ingress_placements` (`reconciler::ingress`) is the reason
1724/// this is `pub(crate)` rather than staying folded into
1725/// `CloudConfig::resolve_machine` — ingress collation walks every mirror in
1726/// the workspace and has no business hard-failing over an unrelated mirror's
1727/// `providers.X.use = "<id>"` typo, which is what going through
1728/// `CloudConfig::load`'s cross-ref validation would do. "Do not fork a second
1729/// selector" (see the module doc above) still holds: this is the *same*
1730/// [`first_match`], just handed a narrower candidate set than `self.machines`.
1731pub(crate) fn resolve_machine_among<'a>(
1732 machines: &'a [MachineConfig],
1733 req: &RequiredSpec,
1734) -> Result<&'a MachineConfig> {
1735 let all: Vec<&MachineConfig> = machines.iter().collect();
1736 first_match(
1737 &all,
1738 req,
1739 "declared machines",
1740 "(no machines declared under .yah/infra/machines/)",
1741 )
1742}
1743
1744fn first_match<'a>(
1745 candidates: &[&'a MachineConfig],
1746 req: &RequiredSpec,
1747 pool: &str,
1748 empty_pool: &str,
1749) -> Result<&'a MachineConfig> {
1750 candidates
1751 .iter()
1752 .copied()
1753 .find(|m| req.matches(m))
1754 .ok_or_else(|| {
1755 let names = if candidates.is_empty() {
1756 empty_pool.to_string()
1757 } else {
1758 candidates
1759 .iter()
1760 .map(|m| m.name.as_str())
1761 .collect::<Vec<_>>()
1762 .join(", ")
1763 };
1764 anyhow::anyhow!(
1765 "no candidates matching {} — {pool}: {names}",
1766 req.describe()
1767 )
1768 })
1769}
1770
1771/// The [`RequiredSpec`] a workload is admitted against — the single place the
1772/// axes are derived from a [`WorkloadSpec`].
1773///
1774/// Extracted from [`CloudConfig::admit_workload`] so that
1775/// [`CloudConfig::admit_workload_in_group`] narrows the candidate set without
1776/// restating the axes. Forking that derivation is how the two paths would
1777/// silently disagree about whether a workload fits a node.
1778fn admission_spec(ws: &WorkloadSpec) -> RequiredSpec {
1779 RequiredSpec {
1780 mesh_tags: node_selector_mesh_tags(ws),
1781 // R833-F8: imperative node pin. Derived here alongside the inferred
1782 // mesh tags rather than short-circuiting the resolver, so a pinned
1783 // workload is still checked against capacity and taints.
1784 nodes: node_selector_node(ws).into_iter().collect(),
1785 // R572-F5: capacity floor from the workload's resource request.
1786 //
1787 // `memory_request_mb()` and NOT `resources.memory_mb`: the latter
1788 // is a cgroup ceiling, and reading a ceiling as a floor made
1789 // `for_forge`'s deliberately-roomy 32 GiB limit mean "only place
1790 // me on a 32 GiB node". That excluded every build-worker in the
1791 // fleet but one. The accessor falls back to `resources.memory_mb`
1792 // when no request is declared, so specs that never set one are
1793 // admitted exactly as before.
1794 memory_mb: ws.memory_request_mb(),
1795 cpu_millis: ws.resources.cpu_millis,
1796 // R572-F5: taint repulsion derived from the workload's effective archetype.
1797 repel_archetype: Some(ws.effective_archetype()),
1798 // R572-F5: taint affinity from the requires-taint annotation.
1799 requires_taint: ws.requires_taint().map(str::to_owned),
1800 ..Default::default()
1801 }
1802}
1803
1804/// Parse the R594 mesh-tag node-selector off a workload's annotations into the
1805/// requested tag set. Absent annotation or empty value ⇒ empty vec ("no
1806/// constraint"). Whitespace around each comma-separated tag is trimmed and
1807/// empty segments are dropped, so `"tag:build-worker, arch:x86"` and
1808/// `"tag:build-worker,arch:x86"` parse identically.
1809pub fn node_selector_mesh_tags(ws: &WorkloadSpec) -> Vec<String> {
1810 ws.annotations
1811 .get(velveteen_exec::remote::NODE_SELECTOR_MESH_TAGS_ANNOTATION)
1812 .map(|v| {
1813 v.split(',')
1814 .map(str::trim)
1815 .filter(|s| !s.is_empty())
1816 .map(String::from)
1817 .collect()
1818 })
1819 .unwrap_or_default()
1820}
1821
1822/// Parse the R833-F8 imperative node-selector off a workload's annotations —
1823/// the single machine `name` the operator pinned the run to
1824/// (`--where=node:us-west-003`). Absent or blank ⇒ `None` ("no constraint"),
1825/// which is every workload built before this axis existed.
1826///
1827/// One node, not a list: the annotation exists to express "run it *there*", and
1828/// a comma-joined set would be a worse spelling of the mesh-tag selector that
1829/// already handles "any of these".
1830pub fn node_selector_node(ws: &WorkloadSpec) -> Option<String> {
1831 ws.annotations
1832 .get(velveteen_exec::remote::NODE_SELECTOR_NODE_ANNOTATION)
1833 .map(|v| v.trim())
1834 .filter(|v| !v.is_empty())
1835 .map(String::from)
1836}
1837
1838/// Load every `.yah/infra/providers/*.toml` into a [`ProviderConfig`] list.
1839/// Missing directory → empty list.
1840fn load_providers(dir: &Path) -> Result<Vec<ProviderConfig>> {
1841 if !dir.exists() {
1842 return Ok(vec![]);
1843 }
1844 let mut items = vec![];
1845 let mut entries: Vec<_> = std::fs::read_dir(dir)
1846 .with_context(|| format!("reading {}", dir.display()))?
1847 .filter_map(|e| e.ok())
1848 .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
1849 .collect();
1850 entries.sort_by_key(|e| e.file_name());
1851 for entry in entries {
1852 items.push(ProviderConfig::load(&entry.path())?);
1853 }
1854 Ok(items)
1855}
1856
1857/// Map legacy mirror file stems to their canonical tier names.
1858///
1859/// Canonical tiers: `dev` / `pond` / `cloud` / `ha`.
1860/// Legacy stems pre-R362: `local` (dev tier), `local-sim` / `sim` (pond tier), `prod` (cloud tier).
1861/// Both forms are accepted; canonical names are preferred for new files.
1862pub fn canonical_tier(stem: &str) -> &str {
1863 match stem {
1864 "local" => "dev",
1865 "local-sim" | "sim" => "pond",
1866 "prod" => "cloud",
1867 other => other,
1868 }
1869}
1870
1871/// Walk `.yah/services/<svc>/` for every service and its mirrors.
1872/// Missing directory → empty map. Mirror file stems are normalized to canonical
1873/// tier names via [`canonical_tier`] so callers always see `dev/pond/cloud/ha`.
1874fn load_services(
1875 dir: &Path,
1876 workspace_root: &Path,
1877) -> Result<BTreeMap<String, ServiceWithMirrors>> {
1878 if !dir.exists() {
1879 return Ok(BTreeMap::new());
1880 }
1881 let mut out = BTreeMap::new();
1882 let mut entries: Vec<_> = std::fs::read_dir(dir)
1883 .with_context(|| format!("reading {}", dir.display()))?
1884 .filter_map(|e| e.ok())
1885 .filter(|e| e.path().is_dir())
1886 .collect();
1887 entries.sort_by_key(|e| e.file_name());
1888
1889 for entry in entries {
1890 let svc_dir = entry.path();
1891 let service_toml = svc_dir.join("service.toml");
1892 if !service_toml.exists() {
1893 // Skip directories without a service.toml — leaves room for
1894 // future siblings (e.g. `secrets/`, `README.md`) without
1895 // triggering false-positive parse errors.
1896 continue;
1897 }
1898 let service = ServiceConfig::load(&service_toml)?;
1899 let mut mirrors = BTreeMap::new();
1900 let mirrors_dir = svc_dir.join("mirrors");
1901 if mirrors_dir.exists() {
1902 let mut menv: Vec<_> = std::fs::read_dir(&mirrors_dir)
1903 .with_context(|| format!("reading {}", mirrors_dir.display()))?
1904 .filter_map(|e| e.ok())
1905 .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
1906 .collect();
1907 menv.sort_by_key(|e| e.file_name());
1908 for m in menv {
1909 let path = m.path();
1910 let stem = path
1911 .file_stem()
1912 .and_then(|s| s.to_str())
1913 .unwrap_or("")
1914 .to_string();
1915 let tier = canonical_tier(&stem).to_string();
1916 // Last-write wins if both legacy and canonical forms coexist
1917 // (e.g. local-sim.toml + pond.toml). Sort order ensures the
1918 // canonical file (pond.toml) wins because 'p' > 'l'.
1919 mirrors.insert(tier, MirrorConfig::load(&path)?);
1920 }
1921 }
1922 let mut component_transform_recipes = BTreeMap::new();
1923 for component in &service.components {
1924 if component.kind == "static-asset" {
1925 if let Some(recipe) =
1926 read_component_transform_recipe(workspace_root, &component.path)
1927 {
1928 component_transform_recipes.insert(component.id.clone(), recipe);
1929 }
1930 }
1931 }
1932 out.insert(
1933 service.name.clone(),
1934 ServiceWithMirrors {
1935 service,
1936 mirrors,
1937 component_transform_recipes,
1938 },
1939 );
1940 }
1941 Ok(out)
1942}
1943
1944/// Read the first transform recipe name from a component's `workload.toml`.
1945/// Returns `None` when the file is absent or has no `[asset.derive.transform]`
1946/// section. Best-effort — parse failures are silently ignored so a malformed
1947/// workload.toml doesn't abort the entire service catalog load.
1948fn read_component_transform_recipe(workspace_root: &Path, component_path: &str) -> Option<String> {
1949 let workload_path = workspace_root.join(component_path).join("workload.toml");
1950 let text = std::fs::read_to_string(&workload_path).ok()?;
1951 let value: toml::Value = toml::from_str(&text).ok()?;
1952 let assets = value.get("asset")?.as_array()?;
1953 for asset in assets {
1954 if let Some(recipe) = asset
1955 .get("derive")
1956 .and_then(|d| d.get("transform"))
1957 .and_then(|t| t.get("recipe"))
1958 .and_then(|r| r.as_str())
1959 {
1960 return Some(recipe.to_string());
1961 }
1962 }
1963 None
1964}
1965
1966/// Load every `.yah/domains/*.toml` into a [`DomainConfig`] map keyed by
1967/// file stem. Missing directory → empty map.
1968fn load_domains(dir: &Path) -> Result<BTreeMap<String, DomainConfig>> {
1969 if !dir.exists() {
1970 return Ok(BTreeMap::new());
1971 }
1972 let mut out = BTreeMap::new();
1973 let mut entries: Vec<_> = std::fs::read_dir(dir)
1974 .with_context(|| format!("reading {}", dir.display()))?
1975 .filter_map(|e| e.ok())
1976 .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
1977 .collect();
1978 entries.sort_by_key(|e| e.file_name());
1979 for entry in entries {
1980 let path = entry.path();
1981 let stem = path
1982 .file_stem()
1983 .and_then(|s| s.to_str())
1984 .unwrap_or("")
1985 .to_string();
1986 let dom = DomainConfig::load(&path)?;
1987 if dom.name != stem {
1988 anyhow::bail!(
1989 "domains/{}.toml: name = \"{}\" must match the file stem",
1990 stem,
1991 dom.name
1992 );
1993 }
1994 out.insert(dom.name.clone(), dom);
1995 }
1996 Ok(out)
1997}
1998
1999/// Load and shape-validate all `*.toml` files in `dir` as [`WorkloadConfig`].
2000fn load_workloads(dir: std::path::PathBuf) -> Result<Vec<WorkloadConfig>> {
2001 if !dir.exists() {
2002 return Ok(vec![]);
2003 }
2004 let mut items = vec![];
2005 let mut entries: Vec<_> = std::fs::read_dir(&dir)
2006 .with_context(|| format!("reading {}", dir.display()))?
2007 .filter_map(|e| e.ok())
2008 .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
2009 .collect();
2010 entries.sort_by_key(|e| e.file_name());
2011
2012 for entry in entries {
2013 let path = entry.path();
2014 let path_str = path.display().to_string();
2015 let src =
2016 std::fs::read_to_string(&path).with_context(|| format!("reading {}", path_str))?;
2017 let spec: WorkloadSpec =
2018 toml::from_str(&src).with_context(|| format!("parsing {}", path_str))?;
2019
2020 // Shape-validate before accepting into the loaded config.
2021 validate::shape(&spec)
2022 .map_err(|e| anyhow::anyhow!("workload {} failed shape validation: {e}", path_str))?;
2023
2024 items.push(WorkloadConfig { spec });
2025 }
2026 Ok(items)
2027}
2028
2029/// Load all mirror configs from the `mirrors/` directory.
2030///
2031/// Handles two layouts that may coexist:
2032/// - **Folder**: `mirrors/<id>/mirror.toml` — preferred; allows secrets and
2033/// per-mirror overrides to live next to the config file.
2034/// - **Flat**: `mirrors/<id>.toml` — legacy; still supported.
2035///
2036/// Each file is parsed as [`LegacyMirrorConfig`]. A malformed file returns an error
2037/// that includes the file path and the TOML field path + line/column, so the
2038/// caller can surface it to the user directly.
2039fn load_mirrors(dir: std::path::PathBuf) -> Result<Vec<LegacyMirrorConfig>> {
2040 if !dir.exists() {
2041 return Ok(vec![]);
2042 }
2043 let mut mirrors = vec![];
2044 let mut entries: Vec<_> = std::fs::read_dir(&dir)
2045 .with_context(|| format!("reading {}", dir.display()))?
2046 .filter_map(|e| e.ok())
2047 .collect();
2048 entries.sort_by_key(|e| e.file_name());
2049
2050 for entry in entries {
2051 let path = entry.path();
2052 if path.is_dir() {
2053 // Folder layout: mirrors/<id>/mirror.toml
2054 let mirror_toml = path.join("mirror.toml");
2055 if mirror_toml.exists() {
2056 let src = std::fs::read_to_string(&mirror_toml)
2057 .with_context(|| format!("reading {}", mirror_toml.display()))?;
2058 let cfg: LegacyMirrorConfig = toml::from_str(&src)
2059 .with_context(|| format!("parsing {}", mirror_toml.display()))?;
2060 mirrors.push(cfg);
2061 }
2062 } else if path.extension().map_or(false, |e| e == "toml") {
2063 // Flat layout: mirrors/<id>.toml
2064 let src = std::fs::read_to_string(&path)
2065 .with_context(|| format!("reading {}", path.display()))?;
2066 let cfg: LegacyMirrorConfig =
2067 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
2068 mirrors.push(cfg);
2069 }
2070 }
2071 Ok(mirrors)
2072}
2073
2074/// Load `topology.toml` if it exists; return a default (empty) topology otherwise.
2075fn load_topology(path: std::path::PathBuf) -> Result<TopologyConfig> {
2076 if !path.exists() {
2077 return Ok(TopologyConfig::default());
2078 }
2079 let src =
2080 std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
2081 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
2082}
2083
2084/// R555-S1: entries are sorted by file name before parsing, so "declaration
2085/// order in `.yah/infra/machines/` breaks ties" — the contract
2086/// [`CloudConfig::admit_workload`] documents — is actually true. `read_dir`
2087/// yields filesystem order, which is unspecified and differs between APFS and
2088/// a hashed-dir ext4; without the sort, *which* of two equally-matching nodes a
2089/// workload admits to could change when an unrelated file is added to the
2090/// directory. That was latent while each tag set had one match and became
2091/// observable the day us-west-003 joined us-west-002 on
2092/// `[tag:build-worker, arch:x86, os:linux]`. Same sort `load_providers` has
2093/// always done.
2094fn load_dir<T: for<'de> Deserialize<'de>>(dir: std::path::PathBuf) -> Result<Vec<T>> {
2095 if !dir.exists() {
2096 return Ok(vec![]);
2097 }
2098 let mut entries: Vec<_> = std::fs::read_dir(&dir)
2099 .with_context(|| format!("reading {}", dir.display()))?
2100 .collect::<std::io::Result<Vec<_>>>()
2101 .with_context(|| format!("reading {}", dir.display()))?;
2102 entries.sort_by_key(|e| e.file_name());
2103
2104 let mut items = vec![];
2105 for entry in entries {
2106 let path = entry.path();
2107 if path.extension().map_or(false, |e| e == "toml") {
2108 let src = std::fs::read_to_string(&path)
2109 .with_context(|| format!("reading {}", path.display()))?;
2110 let item: T =
2111 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
2112 items.push(item);
2113 }
2114 }
2115 Ok(items)
2116}
2117
2118// ─── New manifest shapes (R222 B2) ───────────────────────────────────────────
2119//
2120// The post-R215 layout splits substrate from service declarations:
2121//
2122// .yah/infra/providers/<id>.toml → ProviderConfig
2123// .yah/services/<svc>/service.toml → ServiceConfig
2124// .yah/services/<svc>/mirrors/<env>.toml → MirrorConfig
2125//
2126// CloudConfig::load still reads the legacy layout — B3 swaps in these types
2127// and removes the Legacy* shapes plus TopologyConfig.
2128
2129/// Tag for the infrastructure provider kind. Drives which fields are valid in
2130/// a [`ProviderConfig`] body or a [`MirrorProviderSlot::Inline`] block.
2131///
2132/// Two flavors:
2133/// - **Account/runtime providers** (`cloudflare`, `hetzner`, `local-container`)
2134/// live as files under `.yah/infra/providers/<id>.toml` and are referenced
2135/// from a mirror via `use = "<id>"`.
2136/// - **Inline-only providers** (`local-static`, `miniflare-container`,
2137/// `minio-container`) declare an operator-local stand-in directly inside a
2138/// mirror via `kind = "..."`. They carry no credentials and have no provider
2139/// file. The container-backed kinds ride on top of whichever
2140/// `local-container` runtime is declared in infra (orbstack/colima/docker);
2141/// the reconciler resolves the runtime at up-time.
2142#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2143#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2144#[serde(rename_all = "kebab-case")]
2145pub enum Provider {
2146 /// Cloudflare account: R2 buckets, DNS, Workers, Tunnels.
2147 Cloudflare,
2148 /// Hetzner Cloud + Object Storage account.
2149 Hetzner,
2150 /// Vultr cloud VPS — auto-provisioned via the `cloud.vps.*` Envoy
2151 /// (`VultrEnvoy`), the burst/scaling counterpart to Hetzner. Driver-backed.
2152 Vultr,
2153 /// BYO bare/static node (OVH, on-prem, anything we did NOT provision via a
2154 /// cloud API). Brought up over SSH (`stand-up-yubaba.sh` / `yah cloud
2155 /// machine bootstrap`); reach is declared in the machine's `[connect]`
2156 /// block. No create/destroy driver — placement-only.
2157 Static,
2158 /// Built-in static-file server bound to localhost. Inline-only; never
2159 /// declared as a standalone provider file because it carries no creds.
2160 LocalStatic,
2161 /// Local container runtime (orbstack/colima/docker). Configured by a
2162 /// provider file under `.yah/infra/providers/` so the discovery hints +
2163 /// runtime override sit in one place.
2164 LocalContainer,
2165 /// Dev-tier compute: the component runs as a kamaji-supervised host
2166 /// process against the operator's real workspace, no container and no
2167 /// build step per edit. Inline-only — it carries no credentials, and
2168 /// "the machine you are sitting at" is not an account to point at.
2169 /// See `reconciler::local_process`.
2170 LocalProcess,
2171 /// Containerized miniflare (workerd subprocess) fronting MinIO — the
2172 /// pond-tier stand-in for a CF Worker + R2 static surface. Inline-only;
2173 /// the reconciler spawns miniflare via the JS runtime and starts a MinIO
2174 /// container on the local-container runtime.
2175 MiniflareContainer,
2176 /// Containerized MinIO providing an S3-compatible API — the pond-tier
2177 /// stand-in for Cloudflare R2. Inline-only; the reconciler spins up the
2178 /// container on the local-container runtime and auto-creates the declared
2179 /// bucket on first up.
2180 MinioContainer,
2181 /// Dev-tier PostgreSQL — a real server speaking real pgwire on loopback,
2182 /// supervised by kamaji as the `yah-pg-dev` workload (W265, R584-F1). No
2183 /// docker daemon: the driver fetches a per-arch PostgreSQL tarball on first
2184 /// run and `initdb`s a cluster under `.yah/infra/state/dev/pg/`.
2185 ///
2186 /// Inline-only — it carries no credentials worth a provider file (the
2187 /// cluster is loopback-bound with a fixed dev password). Declared under
2188 /// [`MirrorConfig::drivers`], not `providers`:
2189 ///
2190 /// ```toml
2191 /// [drivers.pg]
2192 /// kind = "local-pg-dev"
2193 /// ```
2194 LocalPgDev,
2195}
2196
2197/// A provider account/runtime binding from `.yah/infra/providers/<id>.toml`.
2198///
2199/// The `kind` discriminator picks the schema for the remaining fields. Strict
2200/// on `kind` (unknown values are a parse error); permissive on per-kind fields
2201/// (carried as a free-form map so this loader stays stable as new fields land).
2202/// B3/B4 will tighten by introducing typed variants alongside JSON Schema.
2203#[derive(Debug, Clone, Serialize, Deserialize)]
2204#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2205pub struct ProviderConfig {
2206 pub schema_version: u32,
2207 pub id: String,
2208 pub kind: Provider,
2209 /// Reference into the OS keystore for live credentials (e.g.
2210 /// `"keystore://cloudflare/yah"`). `None` for providers that don't need
2211 /// creds (local-static, optionally local-container).
2212 #[serde(default, skip_serializing_if = "Option::is_none")]
2213 pub credentials: Option<String>,
2214 /// Kind-specific fields. Examples:
2215 /// - cloudflare: `default_zone`
2216 /// - hetzner: `default_location`, `default_server_type`, `ssh_keys`
2217 /// - local-container: `runtime`, `discovery`
2218 #[serde(flatten)]
2219 #[cfg_attr(
2220 feature = "json-schema",
2221 schemars(with = "std::collections::BTreeMap<String, serde_json::Value>")
2222 )]
2223 pub fields: BTreeMap<String, toml::Value>,
2224}
2225
2226impl ProviderConfig {
2227 /// Parse a single `providers/<id>.toml` file.
2228 pub fn load(path: &Path) -> Result<Self> {
2229 let src =
2230 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
2231 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
2232 }
2233}
2234
2235/// An operator-facing service declaration from
2236/// `.yah/services/<svc>/service.toml`.
2237///
2238/// A service groups one or more components (a static surface, a containerized
2239/// API, an almanac…) under a single domain. Mirrors project the service onto
2240/// concrete infra; see [`MirrorConfig`].
2241#[derive(Debug, Clone, Serialize, Deserialize)]
2242#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2243pub struct ServiceConfig {
2244 pub schema_version: u32,
2245 pub name: String,
2246 pub domain: String,
2247 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2248 pub components: Vec<ServiceComponent>,
2249 /// Databases this service exposes, grouped by environment (W241). Every
2250 /// entry becomes a data-workbench / `sql_*` catalog id of the shape
2251 /// `<env>:<service>:<name>` (e.g. `pond:scrabcake:main`). Optional and
2252 /// default-empty — services without databases omit the `[db]` table
2253 /// entirely.
2254 #[serde(default, skip_serializing_if = "DbCatalog::is_empty")]
2255 pub db: DbCatalog,
2256}
2257
2258impl ServiceConfig {
2259 /// Parse a single `services/<svc>/service.toml` file.
2260 pub fn load(path: &Path) -> Result<Self> {
2261 let src =
2262 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
2263 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
2264 }
2265
2266 /// Persist to `.yah/services/<name>/service.toml`, creating the service
2267 /// directory if needed. Create-or-overwrite — the canonical replacement
2268 /// for the legacy `sites.json` write path. `workspace_root` is the camp
2269 /// dir (the parent of `.yah/`).
2270 pub fn save(&self, workspace_root: &Path) -> Result<()> {
2271 let dir = crate::paths::service_dir(workspace_root, &self.name);
2272 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
2273 let path = crate::paths::service_toml(workspace_root, &self.name);
2274 let s = toml::to_string_pretty(self)
2275 .with_context(|| format!("serializing service {}", self.name))?;
2276 std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
2277 }
2278
2279 /// Remove `.yah/services/<name>/` and everything under it (service.toml
2280 /// plus its `mirrors/`). Returns `false` when the directory was already
2281 /// absent, so callers can distinguish "deleted" from "no-op".
2282 pub fn delete(workspace_root: &Path, name: &str) -> Result<bool> {
2283 let dir = crate::paths::service_dir(workspace_root, name);
2284 if !dir.exists() {
2285 return Ok(false);
2286 }
2287 std::fs::remove_dir_all(&dir).with_context(|| format!("removing {}", dir.display()))?;
2288 Ok(true)
2289 }
2290}
2291
2292/// A git source for a component (R561-F1, "BYO git").
2293///
2294/// When a [`ServiceComponent`] sets `git`, the component's code is NOT in this
2295/// workspace — it lives in an external repo that the reconciler shallow-clones
2296/// into a source cache before build (approach A: clone-at-reconcile, so config
2297/// load + validation stay offline). The component's `path` is then interpreted
2298/// relative to `<checkout>/<subdir>` instead of the workspace root.
2299#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2300#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2301pub struct GitSource {
2302 /// Clone URL (https or ssh) of the tenant repo.
2303 pub repo: String,
2304 /// Branch, tag, or commit SHA to check out. Defaults to `"main"`.
2305 #[serde(default = "default_git_ref")]
2306 pub r#ref: String,
2307 /// Optional sub-directory within the repo that the workspace is rooted at
2308 /// (e.g. a monorepo's `site/`). `path` is resolved relative to this.
2309 #[serde(default, skip_serializing_if = "Option::is_none")]
2310 pub subdir: Option<String>,
2311}
2312
2313fn default_git_ref() -> String {
2314 "main".to_string()
2315}
2316
2317/// How to reach an external infra root (R615-F1 / W274, "linked infra
2318/// sources"): a filesystem link to a sibling camp's live tree, or a git
2319/// checkout of an extracted infra repo.
2320#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2321#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2322#[serde(tag = "kind", rename_all = "kebab-case")]
2323pub enum InfraSourceKind {
2324 /// Filesystem link — reads the owner's live tree. The dev-loop shortcut,
2325 /// and the whole story until W274's "infra as its own repo" end-state.
2326 /// `path` is relative to *this* camp's root; infra is read from
2327 /// `<path>/.yah/infra/`.
2328 Path {
2329 path: String,
2330 },
2331 /// Git link — reused verbatim from [`GitSource`] (R561, "BYO git"),
2332 /// lifted here from "a component's code" to "a camp's infra registry."
2333 /// Loading stays offline (W274 §3): `yah infra sync` (R615-T3) is what
2334 /// clones/pulls this into `.yah/cache/infra/<owner>/`; `CloudConfig::load`
2335 /// only ever reads that cache, never the network.
2336 Git(GitSource),
2337}
2338
2339/// Write-gate for a linked [`InfraSource`] (R615-F1 / W274).
2340///
2341/// An enum, not a bool: the two states today are "borrower renders/plans but
2342/// cannot reconcile" and "this camp genuinely co-administers the shared
2343/// root," and a future read-write-with-approval tier is a third variant, not
2344/// a renamed boolean.
2345#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
2346#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2347#[serde(rename_all = "kebab-case")]
2348pub enum SourceMode {
2349 /// Borrower can render and plan against the linked entries but cannot
2350 /// reconcile/mutate them — the owner remains the single manager. Default:
2351 /// a borrower is opt-in to write access, never opt-out of the safe state.
2352 #[default]
2353 ReadOnly,
2354 /// Escape hatch for a camp that genuinely co-administers a shared root.
2355 Manage,
2356}
2357
2358/// One `[[source]]` entry in `.yah/infra/sources.toml` (R615-F1 / W274) — an
2359/// external infra root this camp borrows machines/providers from.
2360#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2361#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2362pub struct InfraSource {
2363 /// Logical owner name, badged in the Infra tab (e.g. `"yah"`). Distinct
2364 /// from any camp/repo name the `kind` resolves through — this is what an
2365 /// operator sees on a borrowed row, not a path.
2366 pub owner: String,
2367 #[serde(flatten)]
2368 pub kind: InfraSourceKind,
2369 #[serde(default)]
2370 pub mode: SourceMode,
2371 /// Optional filter — name globs or mesh-tag selectors — to borrow a
2372 /// subset of the source root rather than everything it declares. Empty
2373 /// (the default) borrows everything.
2374 #[serde(default)]
2375 pub select: Vec<String>,
2376}
2377
2378fn default_sources_schema_version() -> u32 {
2379 1
2380}
2381
2382/// `.yah/infra/sources.toml` — the ordered list of external infra roots this
2383/// camp borrows from (R615-F1 / W274).
2384#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2385#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2386pub struct SourcesConfig {
2387 #[serde(default = "default_sources_schema_version")]
2388 pub schema_version: u32,
2389 /// `[[source]]` entries, in declaration order — overlay order matters
2390 /// when two linked sources both name the same machine (R615-F2).
2391 #[serde(default, rename = "source")]
2392 pub source: Vec<InfraSource>,
2393}
2394
2395impl Default for SourcesConfig {
2396 fn default() -> Self {
2397 Self {
2398 schema_version: default_sources_schema_version(),
2399 source: Vec::new(),
2400 }
2401 }
2402}
2403
2404impl SourcesConfig {
2405 /// Load `<infra_dir>/sources.toml`. A missing file is not an error —
2406 /// every camp without linked infra has none, which today is every camp —
2407 /// and yields an empty source list rather than `Err`.
2408 pub fn load(infra_dir: &Path) -> Result<Self> {
2409 let path = infra_dir.join("sources.toml");
2410 if !path.exists() {
2411 return Ok(Self::default());
2412 }
2413 let src =
2414 std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
2415 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
2416 }
2417}
2418
2419impl InfraSource {
2420 /// Human-readable descriptor of *which* source this is, for
2421 /// [`InfraOrigin::source`] — distinguishes two linked sources from the
2422 /// same owner. Never includes credentials: `GitSource.repo` is a clone
2423 /// URL (https/ssh), the same thing R561 already treats as safe to log,
2424 /// with any real secret resolved separately via `keystore://` (W274's
2425 /// own precedent).
2426 fn describe(&self) -> String {
2427 match &self.kind {
2428 InfraSourceKind::Path { path } => format!("path:{path}"),
2429 InfraSourceKind::Git(g) => format!("git:{}@{}", g.repo, g.r#ref),
2430 }
2431 }
2432
2433 /// Resolve this source to an infra root directory (R615-F2 / W274 §3).
2434 /// Does no I/O and touches no network: `path` sources read the owner's
2435 /// live tree directly; `git` sources read wherever `yah infra sync`
2436 /// (R615-T3) last synced to, which may not exist yet (an unsynced git
2437 /// source overlays nothing, not an error — see [`load_dir_tolerant`]).
2438 ///
2439 /// `git.subdir` (reused verbatim from [`GitSource`]/R561) is honoured
2440 /// exactly like the component case: the checkout root when unset, or
2441 /// `<checkout>/<subdir>` when set — e.g. `subdir = "infra"` for a
2442 /// monorepo whose infra registry lives under `infra/` rather than at the
2443 /// clone's root. `yah infra sync` (R615-T3) clones into the *checkout*
2444 /// root ([`crate::paths::infra_source_cache_dir`]), never into a
2445 /// subdir-suffixed path, so this is the one place that appends `subdir`.
2446 fn infra_root(&self, workspace_root: &Path) -> std::path::PathBuf {
2447 match &self.kind {
2448 InfraSourceKind::Path { path } => workspace_root.join(path).join(".yah").join("infra"),
2449 InfraSourceKind::Git(g) => {
2450 let checkout = crate::paths::infra_source_cache_dir(workspace_root, &self.owner);
2451 match g.subdir.as_deref() {
2452 Some(subdir) => checkout.join(subdir),
2453 None => checkout,
2454 }
2455 }
2456 }
2457 }
2458}
2459
2460/// Provenance for a [`MachineConfig`] or [`ProviderConfig`] pulled in from a
2461/// linked `.yah/infra/sources.toml` entry, rather than declared in this
2462/// camp's own `.yah/infra/` (R615-F2 / W274).
2463///
2464/// Lives in [`CloudConfig::machine_origins`] / `provider_origins`, keyed by
2465/// name/id, rather than as a field on `MachineConfig`/`ProviderConfig`
2466/// themselves: those two types are constructed by struct literal in test
2467/// helpers across several crates (including ones this ticket has no reason to
2468/// touch), so widening either shape would ripple out past this crate for no
2469/// semantic gain — origin is a property of *this load*, not an inherent
2470/// property of the machine/provider. A name absent from the map is
2471/// camp-local; present means borrowed, and the Infra tab (R615-F4) / reconcile
2472/// gating (`InfraSource::mode`, copied onto `mode` below) read it from here.
2473#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2474#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2475pub struct InfraOrigin {
2476 /// The [`InfraSource::owner`] that supplied this entry, e.g. `"yah"`.
2477 pub owner: String,
2478 /// Which source, rendered — see [`InfraSource::describe`].
2479 pub source: String,
2480 /// The write-gate that applied when this entry was overlaid — copied
2481 /// from [`InfraSource::mode`] so a caller holding just the machine/
2482 /// provider doesn't need the source list in hand to know it's borrowed
2483 /// read-only.
2484 pub mode: SourceMode,
2485}
2486
2487/// Like [`load_dir`], but tolerant **per file**: a foreign infra root (an
2488/// owner's live tree, or a synced git checkout) can carry entries this
2489/// binary's `T` predates — noisetable's pre-migration machines used an older
2490/// schema than yah's, and the reverse will happen too as each side evolves
2491/// independently. One unparseable file on a source this camp doesn't own must
2492/// never sink every other entry in the same directory, let alone this camp's
2493/// own load (R615-F2 gotcha). Contrast [`load_dir`], which stays strict for
2494/// camp-local files, where a malformed TOML genuinely should be a hard error.
2495///
2496/// Returns the entries that parsed, plus `(path, error)` for every file that
2497/// didn't — the caller logs those, it doesn't drop them silently. A missing
2498/// or unreadable directory yields `(vec![], vec![])`, same "no entries" as
2499/// `load_dir`'s `!dir.exists()` case (an unsynced git source, or a source
2500/// root with no `providers/` at all, are both normal, not warnings).
2501fn load_dir_tolerant<T: for<'de> Deserialize<'de>>(
2502 dir: &Path,
2503) -> (Vec<T>, Vec<(std::path::PathBuf, anyhow::Error)>) {
2504 let Ok(read_dir) = std::fs::read_dir(dir) else {
2505 return (Vec::new(), Vec::new());
2506 };
2507 let mut entries: Vec<_> = read_dir.filter_map(|e| e.ok()).collect();
2508 entries.sort_by_key(|e| e.file_name());
2509
2510 let mut items = Vec::new();
2511 let mut skipped = Vec::new();
2512 for entry in entries {
2513 let path = entry.path();
2514 if path.extension().map_or(true, |e| e != "toml") {
2515 continue;
2516 }
2517 let parsed = std::fs::read_to_string(&path)
2518 .with_context(|| format!("reading {}", path.display()))
2519 .and_then(|src| {
2520 toml::from_str::<T>(&src).with_context(|| format!("parsing {}", path.display()))
2521 });
2522 match parsed {
2523 Ok(item) => items.push(item),
2524 Err(e) => skipped.push((path, e)),
2525 }
2526 }
2527 (items, skipped)
2528}
2529
2530/// Whether a borrowed machine passes an [`InfraSource::select`] filter
2531/// (R615-F2 / W274). Empty `select` borrows everything. A non-empty `select`
2532/// entry matches either the machine's exact `name` or literal membership in
2533/// its `mesh_tags` — the one shape W274's own example uses
2534/// (`select = ["tag:cloud-runner"]`). Not a glob engine: mesh tags are
2535/// already flat strings compared for exact equality everywhere else in this
2536/// crate (see `resolve_machine_by_mesh_tags`), so a select entry is that same
2537/// comparison, not a new pattern language.
2538fn machine_matches_select(machine: &MachineConfig, select: &[String]) -> bool {
2539 select.is_empty()
2540 || select
2541 .iter()
2542 .any(|s| *s == machine.name || machine.mesh_tags.contains(s))
2543}
2544
2545/// Overlay every linked `.yah/infra/sources.toml` source's machines and
2546/// providers into `machines`/`providers`, recording provenance into
2547/// `machine_origins`/`provider_origins` (R615-F2 / W274). Must be called
2548/// AFTER camp-local entries are already in both vectors and both origin maps
2549/// are seeded with every camp-local name/id already `HashSet`-tracked as
2550/// "seen": collision resolution is "first writer wins," so seeding with
2551/// camp-local first is what makes camp-local win over every source, and an
2552/// earlier source win over a later one.
2553///
2554/// `select` filters which machines a source contributes; it does not apply
2555/// to providers (nothing in W274 or the source ticket describes a
2556/// provider-scoped filter — every provider a source declares either overlays
2557/// whole or, on a name collision, doesn't).
2558fn overlay_infra_sources(
2559 workspace_root: &Path,
2560 sources: &SourcesConfig,
2561 machines: &mut Vec<MachineConfig>,
2562 providers: &mut Vec<ProviderConfig>,
2563 machine_origins: &mut BTreeMap<String, InfraOrigin>,
2564 provider_origins: &mut BTreeMap<String, InfraOrigin>,
2565) {
2566 let mut seen_machine_names: std::collections::HashSet<String> =
2567 machines.iter().map(|m| m.name.clone()).collect();
2568 let mut seen_provider_ids: std::collections::HashSet<String> =
2569 providers.iter().map(|p| p.id.clone()).collect();
2570
2571 for source in &sources.source {
2572 let root = source.infra_root(workspace_root);
2573 let origin = InfraOrigin {
2574 owner: source.owner.clone(),
2575 source: source.describe(),
2576 mode: source.mode,
2577 };
2578
2579 let (foreign_machines, skipped) = load_dir_tolerant::<MachineConfig>(&root.join("machines"));
2580 for (path, e) in skipped {
2581 tracing::warn!(
2582 "infra source {:?} ({}): skipping unparseable machine {}: {e:#}",
2583 source.owner,
2584 root.display(),
2585 path.display()
2586 );
2587 }
2588 for m in foreign_machines {
2589 if seen_machine_names.contains(&m.name) {
2590 continue; // camp-local, or an earlier source, already claimed this name
2591 }
2592 if !machine_matches_select(&m, &source.select) {
2593 continue;
2594 }
2595 seen_machine_names.insert(m.name.clone());
2596 machine_origins.insert(m.name.clone(), origin.clone());
2597 machines.push(m);
2598 }
2599
2600 let (foreign_providers, skipped) = load_dir_tolerant::<ProviderConfig>(&root.join("providers"));
2601 for (path, e) in skipped {
2602 tracing::warn!(
2603 "infra source {:?} ({}): skipping unparseable provider {}: {e:#}",
2604 source.owner,
2605 root.display(),
2606 path.display()
2607 );
2608 }
2609 for p in foreign_providers {
2610 if seen_provider_ids.contains(&p.id) {
2611 continue;
2612 }
2613 seen_provider_ids.insert(p.id.clone());
2614 provider_origins.insert(p.id.clone(), origin.clone());
2615 providers.push(p);
2616 }
2617 }
2618}
2619
2620/// One component of a [`ServiceConfig`]. The `kind` (e.g. `"mesofact-static"`,
2621/// `"almanac"`, `"container"`) selects which reconciler runs against the
2622/// pointed-at workload manifest.
2623#[derive(Debug, Clone, Serialize, Deserialize)]
2624#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2625pub struct ServiceComponent {
2626 pub id: String,
2627 pub kind: String,
2628 /// Path of the directory holding this component's `workload.toml`. Relative
2629 /// to the workspace root for in-tree components, or to the materialized
2630 /// `<checkout>/<subdir>` when [`git`](Self::git) is set.
2631 pub path: String,
2632 /// Optional external git source (R561-F1). When set, the component's code
2633 /// is materialized by shallow-clone before build; see [`GitSource`].
2634 #[serde(default, skip_serializing_if = "Option::is_none")]
2635 pub git: Option<GitSource>,
2636 /// Operator-facing role label, e.g. `"static"`, `"dynamic"`, `"compute"`.
2637 pub role: String,
2638 /// Optional artifact kind this component publishes (`"static"`,
2639 /// `"container-image"`, …). Drives mirror provider-slot routing.
2640 #[serde(default, skip_serializing_if = "Option::is_none")]
2641 pub publishes: Option<String>,
2642 /// URL sub-path a static component's build output is published under,
2643 /// relative to the service's publish prefix (R746). `None` = the service
2644 /// root, which is what every pre-R746 component means.
2645 ///
2646 /// Static publishers lay a component's `out_dir` down at
2647 /// `<bucket>/<service>/<env>/…` and the front door fetches
2648 /// `${ASSET_ORIGIN}/<request path>` — the request path *is* the key. So a
2649 /// service with two static components had them overwrite each other at
2650 /// one prefix, and there was no way to say "this bundle serves under
2651 /// /app". `mount` is that: it appends to the publish prefix, which makes
2652 /// the URL sub-path and the storage sub-path the same string by
2653 /// construction rather than by two manifests agreeing.
2654 ///
2655 /// Cross-checked against the domain route that names the component
2656 /// ([`CloudConfig::cross_ref_validate`]): a component mounted at `/app`
2657 /// must be routed at `/app` or `/app/*`, because a disagreement means
2658 /// requests land on a prefix nothing published to — a 404 whose cause is
2659 /// two files apart.
2660 #[serde(default, skip_serializing_if = "Option::is_none")]
2661 pub mount: Option<String>,
2662 /// Sync-wave index (0-based). Components in wave 0 roll out in parallel
2663 /// first; the reconciler waits for all wave-N components to become healthy
2664 /// before starting wave N+1. Defaults to 0 (all components in one wave).
2665 #[serde(default, skip_serializing_if = "is_zero_u32")]
2666 pub wave: u32,
2667}
2668
2669#[inline]
2670fn is_zero_u32(n: &u32) -> bool {
2671 *n == 0
2672}
2673
2674/// A service's declared databases, grouped by environment (W241 §Sections).
2675/// Parsed from the `[db]` table of `service.toml`; each `[[db.<env>]]` array
2676/// entry names one database. The environment tag drives backend selection at
2677/// query time (see the data-workbench's `db.query` / the `sql_*` MCP tools):
2678/// `dev` = local file, `pond` = a DB inside the running pond container stack
2679/// (reached on a declared localhost port), `cloud` = a remote libSQL/Turso or
2680/// Postgres endpoint whose auth comes from an env var (never stored in TOML).
2681#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
2682#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2683pub struct DbCatalog {
2684 /// Local-file SQLite databases used in dev mode.
2685 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2686 pub dev: Vec<DevDb>,
2687 /// Databases running inside the pond container stack.
2688 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2689 pub pond: Vec<PondDb>,
2690 /// Remote cloud databases (Turso, Postgres).
2691 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2692 pub cloud: Vec<CloudDb>,
2693}
2694
2695impl DbCatalog {
2696 /// True when no database is declared in any environment. Lets
2697 /// [`ServiceConfig`] skip serializing an empty `[db]` table.
2698 pub fn is_empty(&self) -> bool {
2699 self.dev.is_empty() && self.pond.is_empty() && self.cloud.is_empty()
2700 }
2701}
2702
2703/// A dev-mode local SQLite database (`[[db.dev]]`). `path` is resolved
2704/// relative to the workspace root and opened as a local file — read/write, no
2705/// network, no auth.
2706#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2707#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2708pub struct DevDb {
2709 /// Logical name, unique within the service's `dev` list. Forms the `name`
2710 /// segment of the catalog id `dev:<service>:<name>`.
2711 pub name: String,
2712 /// On-disk SQLite path, relative to the workspace root (or absolute).
2713 pub path: String,
2714}
2715
2716/// A database running inside the pond container stack (`[[db.pond]]`). The
2717/// pond publishes the DB on a localhost TCP port; the hub connects to
2718/// `127.0.0.1:<port>` when the pond is up and returns a clear error when it is
2719/// not. Either `port` (defaulting to a libSQL/`sqld` HTTP endpoint) or a full
2720/// `url` must be given.
2721#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2722#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2723pub struct PondDb {
2724 /// Logical name, unique within the service's `pond` list.
2725 pub name: String,
2726 /// Localhost TCP port the pond publishes the DB on. Interpreted per
2727 /// [`kind`](Self::kind). Mutually complete with `url` (provide one).
2728 #[serde(default, skip_serializing_if = "Option::is_none")]
2729 pub port: Option<u16>,
2730 /// Full connection URL, overriding `port` when set (e.g. a non-localhost
2731 /// host or an explicit scheme).
2732 #[serde(default, skip_serializing_if = "Option::is_none")]
2733 pub url: Option<String>,
2734 /// Wire protocol the pond DB speaks. Selects how a bare `port` becomes a
2735 /// URL: `turso` → `http://127.0.0.1:<port>` (libSQL/`sqld` over Hrana),
2736 /// `postgres` → `postgres://127.0.0.1:<port>`.
2737 #[serde(default)]
2738 pub kind: PondDbKind,
2739}
2740
2741/// Wire protocol of a [`PondDb`].
2742#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
2743#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2744#[serde(rename_all = "kebab-case")]
2745pub enum PondDbKind {
2746 /// libSQL / `sqld` over Hrana HTTP — the default.
2747 #[default]
2748 Turso,
2749 /// PostgreSQL wire protocol.
2750 Postgres,
2751}
2752
2753/// A remote cloud database (`[[db.cloud]]`). The connection `url` is stored in
2754/// TOML but the credential never is — `auth_token_env` names an environment
2755/// variable the daemon reads at connect time, so the same declaration works
2756/// whether the token is provisioned service-locally or camp-shared (W241;
2757/// operator confirmed both scopes are needed). A camp-wide cloud DB not owned
2758/// by any single service is declared identically in `.yah/db/cloud.toml`.
2759#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2760#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2761pub struct CloudDb {
2762 /// Logical name, unique within its `cloud` list.
2763 pub name: String,
2764 /// Connection URL: `libsql://…` / `http(s)://…` (Turso, `sqld`) or
2765 /// `postgres://…`.
2766 pub url: String,
2767 /// Name of the environment variable holding the auth token. Resolved in
2768 /// the daemon at connect time (value never stored on disk). For a libSQL
2769 /// URL the token is threaded as `?auth_token=…`.
2770 #[serde(default, skip_serializing_if = "Option::is_none")]
2771 pub auth_token_env: Option<String>,
2772}
2773
2774/// A camp-shared cloud database catalog, parsed from `.yah/db/cloud.toml`.
2775/// These are cloud DBs not owned by any single service — declared once at camp
2776/// scope and addressed as `cloud:<name>` (two-segment id), distinct from a
2777/// service-local `cloud:<service>:<name>`.
2778#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
2779#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2780pub struct CampCloudDbs {
2781 #[serde(default, rename = "cloud", skip_serializing_if = "Vec::is_empty")]
2782 pub cloud: Vec<CloudDb>,
2783}
2784
2785impl CampCloudDbs {
2786 /// Load `<camp_root>/.yah/db/cloud.toml`, or an empty catalog if the file
2787 /// is absent (the common case — most camps declare no shared cloud DBs).
2788 pub fn load(camp_root: &Path) -> Result<Self> {
2789 let path = camp_root.join(".yah/db/cloud.toml");
2790 if !path.exists() {
2791 return Ok(Self::default());
2792 }
2793 let src = std::fs::read_to_string(&path)
2794 .with_context(|| format!("reading {}", path.display()))?;
2795 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
2796 }
2797}
2798
2799/// Topological shape of a mirror — how its providers sit relative to each other.
2800#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2801#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2802#[serde(rename_all = "kebab-case")]
2803pub enum MirrorShape {
2804 /// Single machine hosts compute (and any non-Cloudflare-fronted static).
2805 SingleMachine,
2806 /// Operator-local dev mirror — static via built-in file server, compute
2807 /// via the local container runtime.
2808 Local,
2809 /// Multi-machine deployment (machines listed per provider slot).
2810 MultiMachine,
2811}
2812
2813/// Which public-ingress provider fronts this mirror's compute (W267, R594-F11).
2814///
2815/// Both arms answer exactly one question — *given these local workload ports,
2816/// make them publicly reachable at these hostnames* — and they differ only in
2817/// where the ingress rules live and who supervises the front door:
2818///
2819/// | | [`CloudflareTunnel`](Self::CloudflareTunnel) | [`Passway`](Self::Passway) |
2820/// |---|---|---|
2821/// | Ingress rules live | Cloudflare's API (token-form tunnels are remotely-managed) | the pingora `Backends` set in the proxy process |
2822/// | How they get there | an API call per deployed workload | passway polls `GET /service-records?ready=true` |
2823/// | Front door lifecycle | a kamaji-supervised `cloudflared` appliance | a kamaji-supervised passway appliance |
2824///
2825/// Flipping this field is the whole tier ladder: rented edge → sovereign edge
2826/// is a one-line mirror edit, not a rewrite. The provider owns **addressing**
2827/// and never **rendering** — the W173 render cube stays in mesofact's manifest.
2828#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
2829#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2830#[serde(rename_all = "kebab-case")]
2831pub enum IngressProvider {
2832 /// No public front door for this mirror. The default: a mirror that
2833 /// publishes to R2 behind a Worker, or a mesh-only compute tier, has no
2834 /// ingress provider to reconcile.
2835 #[default]
2836 None,
2837 /// Rented edge — `cloudflared` dials *out* from the node to Cloudflare's
2838 /// edge. Zero inbound ports, no TLS to manage on the box, hostname rules
2839 /// held in Cloudflare's API.
2840 CloudflareTunnel,
2841 /// Sovereign edge — passway terminates TLS on the node and load-balances
2842 /// an upstream set discovered from yubaba's service records.
2843 Passway,
2844}
2845
2846impl IngressProvider {
2847 /// `true` when this mirror declares a front door that has to be reconciled.
2848 pub fn is_declared(self) -> bool {
2849 !matches!(self, Self::None)
2850 }
2851
2852 /// Kebab-case wire name, as it appears in `mirrors/<env>.toml`.
2853 pub fn as_str(self) -> &'static str {
2854 match self {
2855 Self::None => "none",
2856 Self::CloudflareTunnel => "cloudflare-tunnel",
2857 Self::Passway => "passway",
2858 }
2859 }
2860}
2861
2862/// One declared **edge**: a front door, the slots it fronts, and the nodes it
2863/// is placed on (W305 F2).
2864///
2865/// A mirror declares a *list* of these, which is what lets one service mix
2866/// front doors — cloudflare for the public web tier, passway for an internal or
2867/// high-throughput one. Before this, [`MirrorConfig::ingress`] was a single
2868/// [`IngressProvider`], so a mirror could **swap** front doors but never mix
2869/// them.
2870///
2871/// ```toml
2872/// [[ingress]]
2873/// provider = "passway"
2874/// machines = ["us-east-001", "us-south-001"]
2875/// slots = ["bundle"]
2876///
2877/// [[ingress]]
2878/// provider = "cloudflare-tunnel"
2879/// hostnames = ["issues.yah.dev"]
2880/// ```
2881///
2882/// **The per-node appliance is derived from this, never declared beside it.**
2883/// An edge does invoke a cloudflared or passway process on a box, but that is a
2884/// *consequence* of the service's declaration:
2885/// [`collate_front_doors`](crate::reconciler::collate_front_doors) walks every
2886/// service and derives what each node must run. Declaring it node-side too is
2887/// what produces two sources of truth for one fact.
2888#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2889#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2890pub struct IngressEdge {
2891 /// Which front door this edge is. [`IngressProvider::None`] is rejected at
2892 /// plan time — an edge that fronts with nothing is always a typo, never an
2893 /// intent (write no edge instead).
2894 pub provider: IngressProvider,
2895 /// Nodes this front door is placed on — **independent of where the fronted
2896 /// workload runs** (R330-F37).
2897 ///
2898 /// Empty falls back to the fronted slot's own `machine` / `machines`, which
2899 /// is the co-located shape every mirror had before front-door placement was
2900 /// expressible. Listing several is what lets the ingress tier and the
2901 /// service tier scale independently: **N front doors over ONE deployment**,
2902 /// one rendered copy, so no cache coherence to settle.
2903 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2904 pub machines: Vec<String>,
2905 /// Provider slot roles this edge fronts (`"bundle"`, `"compute"`, …).
2906 ///
2907 /// One of the two selectors. With a single edge both may be empty, meaning
2908 /// "every fronted slot" — the legacy shape. With **several** edges a
2909 /// selector is mandatory on each, and the partition must be total and
2910 /// disjoint: a slot claimed by no edge, or by two, is an error naming it.
2911 /// An implicit catch-all across mixed front doors would silently publish a
2912 /// service through the wrong one.
2913 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2914 pub slots: Vec<String>,
2915 /// Public hostnames this edge fronts — the other selector, for partitioning
2916 /// by what the world dials rather than by which slot serves it.
2917 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2918 pub hostnames: Vec<String>,
2919 /// Cloudflare Tunnel id this edge publishes through, overriding the
2920 /// fronting machine's [`MachineConfig::cloudflared`].
2921 ///
2922 /// This is W267 Gap 3's real fix, and it is the *service* side of it: a node
2923 /// can join two cohorts' orange networks, and since §Granularity argues the
2924 /// tunnel credential **is** the isolation boundary, which cohort a given
2925 /// service fronts through is a property of the service, not of the box.
2926 /// `MachineConfig.cloudflared` stays as the per-node default (one tunnel is
2927 /// the common case, and the credential does live on the node), but it is no
2928 /// longer the only way to say it — so the node never has to enumerate
2929 /// cohorts.
2930 #[serde(default, skip_serializing_if = "Option::is_none")]
2931 pub tunnel_id: Option<String>,
2932 /// Infra provider id whose credentials this edge's front door authenticates
2933 /// with — `use = "cloudflare"`, resolved through
2934 /// `.yah/infra/providers/<id>.toml` exactly as a slot's `use` is.
2935 ///
2936 /// Same split as [`tunnel_id`](Self::tunnel_id), one field over: whose
2937 /// Cloudflare account holds the tunnel is a property of the **front door**,
2938 /// not of the box that runs the compute. Without this the account was read
2939 /// off the fronted slot's own `use`, which conflates two unrelated facts —
2940 /// and is unwritable for a slot whose compute provider is `kind = "static"`
2941 /// (a borrowed bare box: placement only, no credentials). Such a mirror had
2942 /// no way to name a Cloudflare account at all, short of writing
2943 /// `use = "cloudflare"` on the compute slot and lying about what runs it
2944 /// (R845).
2945 ///
2946 /// `None` falls back to the fronted slot's `use`, which is what every
2947 /// mirror written before this field meant.
2948 #[serde(default, rename = "use", skip_serializing_if = "Option::is_none")]
2949 pub provider_id: Option<String>,
2950}
2951
2952impl IngressEdge {
2953 /// An edge with no selector — fronts every fronted slot, legal only when it
2954 /// is the mirror's only edge.
2955 pub fn all_slots(provider: IngressProvider, machines: Vec<String>) -> Self {
2956 Self {
2957 provider,
2958 machines,
2959 slots: Vec::new(),
2960 hostnames: Vec::new(),
2961 tunnel_id: None,
2962 provider_id: None,
2963 }
2964 }
2965
2966 /// `true` when this edge names which slots/hostnames it fronts.
2967 pub fn has_selector(&self) -> bool {
2968 !self.slots.is_empty() || !self.hostnames.is_empty()
2969 }
2970
2971 /// Does this edge claim the rule derived from `slot` publishing `hostname`?
2972 ///
2973 /// A selectorless edge claims everything; that is checked to be
2974 /// unambiguous (one edge only) before this is consulted.
2975 pub fn claims(&self, slot: &str, hostname: &str) -> bool {
2976 if !self.has_selector() {
2977 return true;
2978 }
2979 self.slots.iter().any(|s| s == slot) || self.hostnames.iter().any(|h| h == hostname)
2980 }
2981
2982 /// Human-readable identity for an error message — the provider plus
2983 /// whichever selector was written.
2984 pub fn label(&self) -> String {
2985 let sel = match (self.slots.is_empty(), self.hostnames.is_empty()) {
2986 (true, true) => "no selector".to_string(),
2987 (false, true) => format!("slots = {:?}", self.slots),
2988 (true, false) => format!("hostnames = {:?}", self.hostnames),
2989 (false, false) => format!("slots = {:?} + hostnames = {:?}", self.slots, self.hostnames),
2990 };
2991 format!("[[ingress]] provider = {:?} ({sel})", self.provider.as_str())
2992 }
2993}
2994
2995/// A mirror's `ingress` declaration, in either spelling.
2996///
2997/// The list is the general form; the bare provider is shorthand for the single
2998/// edge fronting everything, and is kept rather than migrated because it is the
2999/// honest spelling for the common case — one service, one front door. Both
3000/// normalize to the same `Vec<IngressEdge>` through
3001/// [`MirrorConfig::ingress_edges`], so nothing downstream branches on which was
3002/// written.
3003#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3004#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3005#[serde(untagged)]
3006pub enum IngressDecl {
3007 /// `ingress = "passway"` — one edge fronting every fronted slot, placed by
3008 /// the sibling [`MirrorConfig::ingress_machines`].
3009 Provider(IngressProvider),
3010 /// `[[ingress]]` — one entry per declared edge.
3011 Edges(Vec<IngressEdge>),
3012}
3013
3014/// Hand-written because `#[serde(untagged)]` throws the real error away.
3015///
3016/// A derived untagged `Deserialize` tries each variant and, on failure, reports
3017/// only `data did not match any variant of untagged enum IngressDecl` — so a
3018/// misspelled `provider = "passwya"` says nothing about providers, nothing about
3019/// the legal values, and points at the `[[ingress]]` header rather than the
3020/// field. Dispatching on the input shape first means each arm's own error
3021/// survives: a bad string names the legal provider vocabulary, a bad edge table
3022/// names the offending field.
3023impl<'de> Deserialize<'de> for IngressDecl {
3024 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
3025 struct DeclVisitor;
3026
3027 impl<'de> serde::de::Visitor<'de> for DeclVisitor {
3028 type Value = IngressDecl;
3029
3030 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
3031 f.write_str(
3032 "a provider name (`ingress = \"passway\"`) or a list of edge tables \
3033 (`[[ingress]]`)",
3034 )
3035 }
3036
3037 fn visit_str<E: serde::de::Error>(self, v: &str) -> std::result::Result<Self::Value, E> {
3038 IngressProvider::deserialize(serde::de::value::StrDeserializer::new(v))
3039 .map(IngressDecl::Provider)
3040 }
3041
3042 fn visit_seq<A: serde::de::SeqAccess<'de>>(
3043 self,
3044 seq: A,
3045 ) -> std::result::Result<Self::Value, A::Error> {
3046 Vec::<IngressEdge>::deserialize(serde::de::value::SeqAccessDeserializer::new(seq))
3047 .map(IngressDecl::Edges)
3048 }
3049 }
3050
3051 d.deserialize_any(DeclVisitor)
3052 }
3053}
3054
3055/// No front door — the shape of every mirror that publishes to R2 behind a
3056/// Worker, or runs a mesh-only compute tier.
3057impl Default for IngressDecl {
3058 fn default() -> Self {
3059 Self::Provider(IngressProvider::None)
3060 }
3061}
3062
3063impl IngressDecl {
3064 /// `true` when this mirror declares no front door at all.
3065 pub fn is_absent(&self) -> bool {
3066 match self {
3067 Self::Provider(p) => !p.is_declared(),
3068 Self::Edges(e) => e.is_empty(),
3069 }
3070 }
3071}
3072
3073impl From<IngressProvider> for IngressDecl {
3074 fn from(p: IngressProvider) -> Self {
3075 Self::Provider(p)
3076 }
3077}
3078
3079/// A service mirror — the projection of a [`ServiceConfig`] onto concrete
3080/// infra. Lives at `.yah/services/<svc>/mirrors/<env>.toml`.
3081#[derive(Debug, Clone, Serialize, Deserialize)]
3082#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3083pub struct MirrorConfig {
3084 pub schema_version: u32,
3085 pub shape: MirrorShape,
3086 /// Public-ingress edges fronting this mirror (W267, W305 F2). Defaults to
3087 /// none.
3088 ///
3089 /// Two spellings, one meaning — see [`IngressDecl`]. `ingress = "passway"`
3090 /// is one edge fronting everything; `[[ingress]]` entries declare several,
3091 /// each naming its provider plus the slots or hostnames it fronts. Read it
3092 /// through [`ingress_edges`](Self::ingress_edges), never by matching on the
3093 /// enum, so the two spellings cannot drift apart.
3094 ///
3095 /// Declared at mirror scope rather than per provider slot because a front
3096 /// door does **fan-in**: one `cloudflared` (or one passway) on a node
3097 /// multiplexes every hostname→port rule it fronts, so pinning one to a
3098 /// single slot would mint one edge connection per slot for no gain. An
3099 /// edge's `slots` selector is the general form of that — it groups slots
3100 /// behind one front door, it does not split a front door per slot.
3101 #[serde(default, skip_serializing_if = "IngressDecl::is_absent")]
3102 pub ingress: IngressDecl,
3103 /// Machines the front door is placed on — **independent of where the
3104 /// fronted workload runs** (R330-F37).
3105 ///
3106 /// The single-edge spelling of [`IngressEdge::machines`]: it applies to the
3107 /// one edge `ingress = "<provider>"` declares, and combining it with
3108 /// `[[ingress]]` entries is an error rather than a silent precedence rule.
3109 ///
3110 /// Empty (the default) keeps the pre-existing behaviour: the front door is
3111 /// co-located with the fronted slot's own `machine` / `machines`. That was
3112 /// never a design choice, it was an artifact of bundles binding
3113 /// `127.0.0.1` — nothing off-node could reach a workload, so a proxy had to
3114 /// sit on top of it. R599-F12 landed mesh binding, which removes the
3115 /// constraint: passway is a reverse proxy, and a valid front door needs a
3116 /// cert and an upstream it can *reach*, not a local copy of the service.
3117 ///
3118 /// Listing several machines is what lets the ingress tier and the service
3119 /// tier scale independently — **N front doors over ONE deployment**. There
3120 /// is still exactly one rendered copy of the site, so fanning the front door
3121 /// out introduces no cache-coherence problem; that only appears if you
3122 /// deploy the *workload* to every node instead.
3123 ///
3124 /// ```toml
3125 /// ingress = "passway"
3126 /// ingress_machines = ["us-east-001", "us-west-001"]
3127 /// ```
3128 ///
3129 /// Declaring this without [`ingress`](Self::ingress) is an error, not a
3130 /// no-op — it always means the operator expected a front door somewhere.
3131 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3132 pub ingress_machines: Vec<String>,
3133 /// Provider slots, keyed by role (`"static"`, `"compute"`, …). Each value
3134 /// either references a provider declared under `.yah/infra/providers/` or
3135 /// inlines a local-only provider (no creds, no infra file).
3136 ///
3137 /// A role is normally service-wide — one slot serves every component that
3138 /// shares it — but [`ReconcileCtx::slot`](crate::reconciler::ReconcileCtx::slot)
3139 /// looks up the component-qualified key `"<role>:<component id>"` first.
3140 /// A service with two components of the same role (e.g. two
3141 /// `mesofact-static` components under one mirror) declares
3142 /// `providers."static:<id>"` per component to give each its own port;
3143 /// omitting the qualifier keeps the pre-existing single-slot behavior.
3144 #[serde(default)]
3145 pub providers: BTreeMap<String, MirrorProviderSlot>,
3146 /// Capability→driver bindings, keyed by **capability** (`"pg"`, `"s3"`, …)
3147 /// rather than by slot role (W265 §Drivers).
3148 ///
3149 /// This is the generalization of [`Self::providers`]: `providers.static` /
3150 /// `providers.object_store` are the special case where the slot name and
3151 /// the capability happen to coincide, and keying by capability is what stops
3152 /// the slot enum growing one arm per tier-specific implementation. A service
3153 /// says "I need pg"; the mirror says which implementation of pg *this tier*
3154 /// uses; the app talks the same wire protocol either way and never forks.
3155 ///
3156 /// ```toml
3157 /// [drivers.pg]
3158 /// kind = "local-pg-dev" # dev — kamaji-supervised loopback postgres
3159 /// ```
3160 ///
3161 /// Additive in P1: `drivers` lands *alongside* `providers`, and migrating
3162 /// the existing `providers.static` / `providers.object_store` declarations
3163 /// over is a separate pass (W265 §"Open follow-ups"). A mirror that declares
3164 /// neither is unchanged.
3165 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3166 pub drivers: BTreeMap<String, MirrorProviderSlot>,
3167 /// Per-environment alias overrides for `kind = "static-asset"` components.
3168 ///
3169 /// Keys are logical names (e.g. `"whisper-default"`); values must be
3170 /// filenames present in the component's `workload.toml` catalog.
3171 /// **Resolution only** — this table may never introduce a filename absent
3172 /// from the catalog. Validated against the workload catalog at sync time.
3173 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3174 pub asset_aliases: BTreeMap<String, String>,
3175}
3176
3177impl MirrorConfig {
3178 /// This mirror's declared edges, with both spellings normalized (W305 F2).
3179 ///
3180 /// The single place `ingress` + `ingress_machines` are reconciled, so no
3181 /// consumer has to know which spelling was written. Returns an empty vec
3182 /// when the mirror declares no front door.
3183 ///
3184 /// Errors are the declarations that cannot mean anything:
3185 ///
3186 /// - `ingress_machines` with no `ingress` — front-door placement with no
3187 /// front door to place, always a typo (R330-F37);
3188 /// - `ingress_machines` alongside `[[ingress]]` — placement declared twice,
3189 /// in a form where one silently wins;
3190 /// - `provider = "none"` on an edge — an edge that fronts with nothing.
3191 /// The `[[ingress]]` entries exactly as written, without normalizing the
3192 /// scalar spelling or validating anything.
3193 ///
3194 /// [`ingress_edges`](Self::ingress_edges) is the one to reach for; this
3195 /// exists for the checks that must run *before* a mirror is known to be
3196 /// well-formed — cross-reference validation walks every mirror in the
3197 /// workspace, and hard-failing there on an unrelated mirror's shape error
3198 /// would report the wrong file. Empty for the scalar spelling, which has no
3199 /// edge table to carry per-edge fields.
3200 pub fn ingress_edge_slice(&self) -> &[IngressEdge] {
3201 match &self.ingress {
3202 IngressDecl::Edges(edges) => edges,
3203 IngressDecl::Provider(_) => &[],
3204 }
3205 }
3206
3207 pub fn ingress_edges(&self) -> Result<Vec<IngressEdge>> {
3208 match &self.ingress {
3209 IngressDecl::Provider(p) if !p.is_declared() => {
3210 if !self.ingress_machines.is_empty() {
3211 bail!(
3212 "mirror declares `ingress_machines = {:?}` but no `ingress` provider — \
3213 front-door placement with no front door to place. Add \
3214 `ingress = \"passway\"` (or \"cloudflare-tunnel\"), or drop \
3215 `ingress_machines`.",
3216 self.ingress_machines
3217 );
3218 }
3219 Ok(Vec::new())
3220 }
3221 IngressDecl::Provider(p) => Ok(vec![IngressEdge::all_slots(
3222 *p,
3223 self.ingress_machines.clone(),
3224 )]),
3225 IngressDecl::Edges(edges) => {
3226 if !self.ingress_machines.is_empty() {
3227 bail!(
3228 "mirror declares both `[[ingress]]` edges and the single-edge \
3229 `ingress_machines = {:?}` — front-door placement stated twice. Move \
3230 those names onto the edge they place: `machines = [...]` inside the \
3231 `[[ingress]]` entry.",
3232 self.ingress_machines
3233 );
3234 }
3235 for edge in edges {
3236 if !edge.provider.is_declared() {
3237 bail!(
3238 "{}: `provider = \"none\"` fronts nothing. An edge exists to name a \
3239 front door — delete the entry instead.",
3240 edge.label()
3241 );
3242 }
3243 }
3244 Ok(edges.clone())
3245 }
3246 }
3247 }
3248
3249 /// Parse a single `mirrors/<env>.toml` file.
3250 pub fn load(path: &Path) -> Result<Self> {
3251 let src =
3252 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
3253 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
3254 }
3255
3256 /// Persist to `.yah/services/<service>/mirrors/<env>.toml`, creating the
3257 /// `mirrors/` directory if needed. Create-or-overwrite. The mirror file is
3258 /// named by `env` (its stem); `service` selects the owning service dir.
3259 pub fn save(&self, workspace_root: &Path, service: &str, env: &str) -> Result<()> {
3260 let dir = crate::paths::service_mirrors_dir(workspace_root, service);
3261 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
3262 let path = crate::paths::service_mirror_toml(workspace_root, service, env);
3263 let s = toml::to_string_pretty(self)
3264 .with_context(|| format!("serializing mirror {service}/{env}"))?;
3265 std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
3266 }
3267
3268 /// Remove `.yah/services/<service>/mirrors/<env>.toml`. Returns `false`
3269 /// when the file was already absent. Leaves the service and its other
3270 /// mirrors untouched.
3271 ///
3272 /// Also checks legacy stems (e.g. `local-sim` when `env = "pond"`) so
3273 /// deleting a canonical tier name removes whichever file exists on disk.
3274 pub fn delete(workspace_root: &Path, service: &str, env: &str) -> Result<bool> {
3275 let path = crate::paths::service_mirror_toml(workspace_root, service, env);
3276 if path.exists() {
3277 std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
3278 return Ok(true);
3279 }
3280 // Try legacy file stems for canonical tier names.
3281 let legacy: &[&str] = match env {
3282 "dev" => &["local"],
3283 "pond" => &["local-sim", "sim"],
3284 "cloud" => &["prod"],
3285 _ => &[],
3286 };
3287 for stem in legacy {
3288 let alt = crate::paths::service_mirror_toml(workspace_root, service, stem);
3289 if alt.exists() {
3290 std::fs::remove_file(&alt)
3291 .with_context(|| format!("removing {}", alt.display()))?;
3292 return Ok(true);
3293 }
3294 }
3295 Ok(false)
3296 }
3297}
3298
3299/// A provider slot inside a [`MirrorConfig`]. Two shapes:
3300/// - **Reference** (`use = "<provider-id>"`) — point at an infra-declared
3301/// provider; extra fields are slot-specific (bucket, zone, dns, …).
3302/// - **Inline** (`kind = "local-*"`) — for providers that need no infra
3303/// declaration because they carry no credentials.
3304#[derive(Debug, Clone, Serialize, Deserialize)]
3305#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3306#[serde(untagged)]
3307pub enum MirrorProviderSlot {
3308 Reference {
3309 #[serde(rename = "use")]
3310 provider_id: String,
3311 #[serde(flatten)]
3312 #[cfg_attr(
3313 feature = "json-schema",
3314 schemars(with = "std::collections::BTreeMap<String, serde_json::Value>")
3315 )]
3316 fields: BTreeMap<String, toml::Value>,
3317 },
3318 Inline {
3319 kind: Provider,
3320 #[serde(flatten)]
3321 #[cfg_attr(
3322 feature = "json-schema",
3323 schemars(with = "std::collections::BTreeMap<String, serde_json::Value>")
3324 )]
3325 fields: BTreeMap<String, toml::Value>,
3326 },
3327}
3328
3329impl MirrorProviderSlot {
3330 /// Provider id this slot references, or `None` for inline slots.
3331 pub fn provider_id(&self) -> Option<&str> {
3332 match self {
3333 Self::Reference { provider_id, .. } => Some(provider_id),
3334 Self::Inline { .. } => None,
3335 }
3336 }
3337
3338 /// Provider kind for inline slots, or `None` for reference slots
3339 /// (resolve via the referenced [`ProviderConfig`]).
3340 pub fn inline_kind(&self) -> Option<Provider> {
3341 match self {
3342 Self::Reference { .. } => None,
3343 Self::Inline { kind, .. } => Some(*kind),
3344 }
3345 }
3346
3347 pub fn fields(&self) -> &BTreeMap<String, toml::Value> {
3348 match self {
3349 Self::Reference { fields, .. } | Self::Inline { fields, .. } => fields,
3350 }
3351 }
3352
3353 /// F16 placement: parse the optional `required = { … }` sub-table on this
3354 /// slot. Returns `None` when absent or unparseable (callers treat as no
3355 /// constraint). See [`RequiredSpec`] for the field grammar.
3356 pub fn required(&self) -> Option<RequiredSpec> {
3357 let v = self.fields().get("required")?.clone();
3358 v.try_into().ok()
3359 }
3360}
3361
3362/// F16 placement constraints declared on a [`MirrorProviderSlot`], lives under
3363/// `[providers.<role>] required = { regions = [...], mesh_tags = [...] }` in
3364/// `mirrors/<env>.toml`.
3365///
3366/// Hard (must-satisfy) axes, all AND-ed together:
3367/// - `regions` / `zones` / `providers` — *membership*: the machine's
3368/// `region` / `zone` / `provider` must be one of the listed values.
3369/// - `mesh_tags` — *superset*: the machine's `mesh_tags` must contain every
3370/// listed tag.
3371/// - `memory_mb` / `cpu_millis` — *capacity floor* (R572-F5): the machine's
3372/// `allocatable` budget must cover the demand. `0` = no constraint.
3373/// - `repel_archetype` — *taint repulsion* (R572-F5): the machine must not
3374/// carry the taint `"no-<archetype.taint_key()>"` for the workload's class.
3375/// `None` = no repulsion check. Absolute — see [`Self::repel_archetype`].
3376/// - `requires_taint` — *taint affinity* (R572-F5): the machine must carry
3377/// this taint key (in `taints` or `mesh_tags`). `None` = no affinity.
3378///
3379/// These two are the **only** readers of [`MachineConfig::taints`], which is
3380/// what makes [`taint_effect`]'s closed vocabulary well-founded.
3381///
3382/// [`MachineConfig::sovereign_group`] is deliberately **not** an axis here and
3383/// must not become one (W305/R742-F1). A sovereign group is a blast radius,
3384/// not a filter: which quorum a box votes in says nothing about whether a
3385/// workload may run on it, and a dev-group node exists precisely so dev-mode
3386/// services — stateful ones included — can be scheduled onto it. Filtering on
3387/// it would re-make the mistake W305 exists to undo, where one mechanism
3388/// silently carried three unrelated properties.
3389///
3390/// An empty / zero / None on every axis means "no constraint on that axis".
3391/// A fully-unconstrained `RequiredSpec` matches every machine (see
3392/// [`RequiredSpec::is_unconstrained`]).
3393#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3394#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3395pub struct RequiredSpec {
3396 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3397 pub regions: Vec<String>,
3398 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3399 pub zones: Vec<String>,
3400 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3401 pub providers: Vec<String>,
3402 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3403 pub mesh_tags: Vec<String>,
3404
3405 /// R833-F8: **imperative** placement — the machine must be one of these by
3406 /// `name`. Empty (the default) = no constraint, which is every pre-R833-F8
3407 /// caller.
3408 ///
3409 /// This is the one axis that is not a *capability* the scheduler infers.
3410 /// The operator typed `--where=node:us-west-003`, so it composes with the
3411 /// other axes exactly like the rest — a named node that fails the capacity
3412 /// floor or carries a repelling taint still does not match, and the refusal
3413 /// names why rather than silently placing the work somewhere else.
3414 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3415 pub nodes: Vec<String>,
3416
3417 /// R572-F5: minimum memory (MiB) the target node must have in its
3418 /// declared `allocatable` budget. `0` = no constraint. Filled by
3419 /// [`CloudConfig::admit_workload`] from the workload's
3420 /// `memory_request_mb()` — its placement **request**, which is not the
3421 /// same number as the `resources.memory_mb` cgroup **ceiling**.
3422 #[serde(default, skip_serializing_if = "is_zero_u32")]
3423 pub memory_mb: u32,
3424 /// R572-F5: minimum CPU (millicores) the target node must have in its
3425 /// declared `allocatable` budget. `0` = no constraint. Filled by
3426 /// [`CloudConfig::admit_workload`] from the workload's `resources.cpu_millis`.
3427 #[serde(default, skip_serializing_if = "is_zero_u32")]
3428 pub cpu_millis: u32,
3429 /// R572-F5: effective archetype of the workload being placed. The scheduler
3430 /// rejects any node that carries the taint `"no-<archetype.taint_key()>"`.
3431 /// `None` = no repulsion check (backwards-compat for callers that don't
3432 /// thread a spec through).
3433 ///
3434 /// **This is an absolute block, not a preference.**
3435 /// [`CloudConfig::admit_workload`] sets it unconditionally from the
3436 /// workload's effective archetype, and nothing in the tree tolerates a
3437 /// taint — so a workload cannot opt out of a `no-<archetype>` node
3438 /// (W305 finding 2 / R742-T4). Adding toleration means giving
3439 /// `WorkloadSpec` a tolerations list and consulting it here; until then,
3440 /// do not describe this as "repel-unless-tolerate".
3441 #[serde(skip)]
3442 pub repel_archetype: Option<LifecycleArchetype>,
3443 /// R572-F5: taint the workload requires the target node to carry
3444 /// (annotation `yah.placement.requires-taint`). The node must have the
3445 /// key in its `taints` list or `mesh_tags`. `None` = no affinity constraint.
3446 #[serde(skip)]
3447 pub requires_taint: Option<String>,
3448}
3449
3450impl RequiredSpec {
3451 /// True when no axis carries a constraint — every machine matches.
3452 pub fn is_unconstrained(&self) -> bool {
3453 self.regions.is_empty()
3454 && self.zones.is_empty()
3455 && self.providers.is_empty()
3456 && self.mesh_tags.is_empty()
3457 && self.nodes.is_empty()
3458 && self.memory_mb == 0
3459 && self.cpu_millis == 0
3460 && self.repel_archetype.is_none()
3461 && self.requires_taint.is_none()
3462 }
3463
3464 /// Whether `machine` satisfies every hard axis.
3465 ///
3466 /// - Membership axes (region/zone/provider): machine must carry the field
3467 /// and it must appear in the constraint list.
3468 /// - `mesh_tags`: machine tags must be a superset of the required set.
3469 /// - **R572-F5 capacity floor**: `machine.allocatable.{memory,cpu}` must
3470 /// cover `self.{memory,cpu}`. A machine with no `allocatable` block passes
3471 /// unconditionally (capacity unknown → no constraint enforced).
3472 /// - **R572-F5 taint repulsion**: machine must not carry the taint
3473 /// `"no-<archetype.taint_key()>"` for the workload's class. Absolute —
3474 /// the workload has no way to tolerate it (W305 finding 2).
3475 /// - **R572-F5 taint affinity**: if `requires_taint` is set, the machine
3476 /// must carry that key in its `taints` list or `mesh_tags`.
3477 ///
3478 /// Any *other* taint on the machine is ignored here, which is precisely
3479 /// why [`crate::validate::check_inert_taints`] refuses to let one be
3480 /// declared: it would read as a constraint and be none.
3481 pub fn matches(&self, machine: &MachineConfig) -> bool {
3482 let member_ok = |constraint: &[String], value: Option<&str>| -> bool {
3483 constraint.is_empty() || value.map_or(false, |v| constraint.iter().any(|c| c == v))
3484 };
3485
3486 // R833-F8: imperative node pin, checked first because it is the axis a
3487 // human asserted rather than one the scheduler derived — a refusal
3488 // should read "us-west-003 does not match" and not lead with a tag set
3489 // the operator never typed.
3490 if !member_ok(&self.nodes, Some(machine.name.as_str())) {
3491 return false;
3492 }
3493
3494 // Membership + mesh-tags (pre-existing axes).
3495 if !member_ok(&self.regions, machine.region.as_deref())
3496 || !member_ok(&self.zones, machine.zone.as_deref())
3497 || !member_ok(&self.providers, Some(machine.provider.as_str()))
3498 || !self
3499 .mesh_tags
3500 .iter()
3501 .all(|t| machine.mesh_tags.iter().any(|mt| mt == t))
3502 {
3503 return false;
3504 }
3505
3506 // R572-F5: capacity floor. Skipped when machine has no allocatable
3507 // declaration (unknown capacity → passes, consistent with pre-F5 behaviour).
3508 if self.memory_mb > 0 || self.cpu_millis > 0 {
3509 if let Some(alloc) = &machine.allocatable {
3510 if self.memory_mb > alloc.memory_mb || self.cpu_millis > alloc.cpu_millis {
3511 return false;
3512 }
3513 }
3514 }
3515
3516 // R572-F5: taint repulsion. A node taint "no-<archetype>" rejects the
3517 // workload class outright — there is no toleration list to consult.
3518 if let Some(arch) = self.repel_archetype {
3519 let repel_key = format!("no-{}", arch.taint_key());
3520 if machine.taints.iter().any(|t| *t == repel_key) {
3521 return false;
3522 }
3523 }
3524
3525 // R572-F5: taint affinity. Machine must carry the required taint key
3526 // in either its `taints` list or `mesh_tags`.
3527 if let Some(req) = &self.requires_taint {
3528 let has_it = machine.taints.iter().any(|t| t == req)
3529 || machine.mesh_tags.iter().any(|t| t == req);
3530 if !has_it {
3531 return false;
3532 }
3533 }
3534
3535 true
3536 }
3537
3538 /// Human-readable summary of the constraints, for fail-loud error messages.
3539 /// Example: `required.regions=[us-west] + required.mesh_tags=[tag:cloud-runner]`.
3540 pub fn describe(&self) -> String {
3541 let mut parts = Vec::new();
3542 let mut push = |label: &str, vals: &[String]| {
3543 if !vals.is_empty() {
3544 parts.push(format!("required.{label}=[{}]", vals.join(",")));
3545 }
3546 };
3547 push("nodes", &self.nodes);
3548 push("regions", &self.regions);
3549 push("zones", &self.zones);
3550 push("providers", &self.providers);
3551 push("mesh_tags", &self.mesh_tags);
3552 if self.memory_mb > 0 {
3553 parts.push(format!("memory_mb>={}", self.memory_mb));
3554 }
3555 if self.cpu_millis > 0 {
3556 parts.push(format!("cpu_millis>={}", self.cpu_millis));
3557 }
3558 if let Some(arch) = self.repel_archetype {
3559 parts.push(format!("not-tainted(no-{})", arch.taint_key()));
3560 }
3561 if let Some(req) = &self.requires_taint {
3562 parts.push(format!("requires_taint={req}"));
3563 }
3564 if parts.is_empty() {
3565 "no constraints".to_string()
3566 } else {
3567 parts.join(" + ")
3568 }
3569 }
3570}
3571
3572/// Which front door actually serves a domain's requests (R594-F12).
3573///
3574/// Every domain manifest must say this out loud. Before it existed the
3575/// difference between "R2 serves this hostname directly" and "a Worker
3576/// serves it" was expressed *only* by whether the file happened to carry
3577/// `[[routes]]` — so binding a route-carrying domain straight to R2 was
3578/// accepted silently and served 200s on its SSG half while losing clean
3579/// URLs, SPA shell fallback, deferred-route pointers and branded error
3580/// pages. All of those live in the Worker
3581/// (`oss/mesofact/packages/mesofact-edge/src/router.ts`) or in
3582/// mesofact-serve; an R2 custom domain has none of them.
3583///
3584/// The vocabulary mirrors `scripts/cf-apex-mode.sh` (worker | grey | orange)
3585/// — this moves the choice into the config where it can be checked instead
3586/// of living in one bash script.
3587///
3588/// A front door does **fan-in** only. The render cube (SSG / SPA / SSR /
3589/// deferred / 404) is mesofact's manifest, not this one — see W173 and
3590/// `.yah/docs/working/W267-sovereign-public-ingress.md`
3591/// §"Two front doors, one render contract".
3592#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3593#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3594#[serde(rename_all = "kebab-case")]
3595pub enum FrontDoor {
3596 /// Cloudflare R2 custom domain. Requests hit R2 objects with edge
3597 /// caching and nothing else — no clean URLs, no SPA fallback, no
3598 /// branded errors. Correct for a pure asset tier (W175's verdict for
3599 /// `cdn.yah.dev`) and wrong for anything that renders pages.
3600 /// Implies zero `[[routes]]` and no `worker_bundle_path`.
3601 BucketDirect,
3602 /// Cloudflare Worker generated from this manifest's route table.
3603 Worker,
3604 /// Sovereign L7 ingress — the `passway` proxy on yah-owned metal
3605 /// (`oss/passway`, W267). Same route table as `worker`; different
3606 /// machine terminates TLS.
3607 Passway,
3608}
3609
3610impl FrontDoor {
3611 /// Whether this front door consumes the manifest's `[[routes]]` table.
3612 /// `bucket-direct` does not; the other two are nothing without it.
3613 pub fn is_route_driven(self) -> bool {
3614 matches!(self, FrontDoor::Worker | FrontDoor::Passway)
3615 }
3616
3617 /// The manifest spelling, for error messages.
3618 pub fn as_str(self) -> &'static str {
3619 match self {
3620 FrontDoor::BucketDirect => "bucket-direct",
3621 FrontDoor::Worker => "worker",
3622 FrontDoor::Passway => "passway",
3623 }
3624 }
3625}
3626
3627/// A routing manifest for one domain, from `.yah/domains/<name>.toml`.
3628///
3629/// The domain manifest is the *only* place that knows about path routing:
3630/// services declare static/backend components by opaque ID, and this
3631/// manifest binds those components to URL paths on a public-facing
3632/// domain. Generated Worker bundles consume this. See
3633/// `.yah/docs/working/W118-yah-domain-tiers.md` (R347).
3634#[derive(Debug, Clone, Serialize, Deserialize)]
3635#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3636pub struct DomainConfig {
3637 pub schema_version: u32,
3638 /// Stable identifier for this domain (file stem of the manifest).
3639 /// Example: `"yah-dev"` for the `yah.dev` zone.
3640 pub name: String,
3641 /// The fully-qualified domain this manifest routes for. Example:
3642 /// `"yah.dev"`, `"app.yah.dev"`.
3643 pub domain: String,
3644 /// Which front door serves this domain (R594-F12). **Required** — a
3645 /// default here would silently re-create the defect the field exists to
3646 /// close. Cross-checked against `routes` / `worker_bundle_path` by
3647 /// [`DomainConfig::validate_front_door`] at load time.
3648 pub front_door: FrontDoor,
3649 /// Public CDN bucket name. Static-mode route components publish into
3650 /// this bucket. Owned by the domain, *not* by any single service.
3651 pub cdn_bucket: String,
3652 /// Optional path (relative to workspace root) where the generated
3653 /// Worker bundle lands. `None` while the bundle generator (R347-F4)
3654 /// is still being wired up.
3655 #[serde(default, skip_serializing_if = "Option::is_none")]
3656 pub worker_bundle_path: Option<String>,
3657 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3658 pub routes: Vec<DomainRoute>,
3659}
3660
3661/// One entry in a [`DomainConfig`]'s route table.
3662///
3663/// The `mode` discriminator picks the variant's body via serde's
3664/// internally-tagged enum representation. Path patterns follow the
3665/// Worker convention: a trailing `*` matches everything underneath.
3666#[derive(Debug, Clone, Serialize, Deserialize)]
3667#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3668pub struct DomainRoute {
3669 /// URL pattern this route matches. Examples: `"/"`, `"/dashboard/*"`,
3670 /// `"/camp/ws"`.
3671 pub path: String,
3672 /// Response headers the front door sets on every response served under
3673 /// this route (R746). Empty by default.
3674 ///
3675 /// This is the manifest's answer to "who decides a path's response
3676 /// headers". Before it existed the answer was *nobody*: a `_headers` file
3677 /// is a Cloudflare Pages / Netlify convention, and neither of this
3678 /// repo's front doors reads one — a Worker returns what it fetched from
3679 /// R2, and R2 serves only the object's own httpMetadata. So a site could
3680 /// carry a `_headers` file declaring COOP/COEP and ship without them,
3681 /// which is exactly how it was found: `SharedArrayBuffer` is simply
3682 /// absent in a document served cross-origin-isolation-free, with no
3683 /// error anywhere to say why.
3684 ///
3685 /// Deliberately a free-form `name -> value` map rather than named fields
3686 /// for the isolation headers: the domain manifest has no business
3687 /// knowing which headers a route's payload happens to need. Ordering
3688 /// follows the route table's own rule — first matching route wins, no
3689 /// merging across routes (see the Worker's `applyRouteHeaders`).
3690 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3691 pub headers: BTreeMap<String, String>,
3692 #[serde(flatten)]
3693 pub mode: RouteMode,
3694}
3695
3696/// Body of a [`DomainRoute`]. Three modes:
3697/// - **Static** — Worker reads from the domain's CDN bucket. Component
3698/// ref points at a `kind = "mesofact-static"` (or similar) service
3699/// component.
3700/// - **Backend** — Worker proxies to an HTTP origin owned by a backend
3701/// component (yubaba workload, gateway, etc.).
3702/// - **Redirect** — Worker emits a 30x to the target URL. Used to keep
3703/// old paths alive during domain refactors.
3704#[derive(Debug, Clone, Serialize, Deserialize)]
3705#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3706#[serde(tag = "mode", rename_all = "kebab-case")]
3707pub enum RouteMode {
3708 Static {
3709 /// Component reference `"<service>/<component-id>"`. Validated
3710 /// at [`CloudConfig::load`] time.
3711 component: String,
3712 },
3713 Backend {
3714 /// Component reference `"<service>/<component-id>"`. Validated
3715 /// at [`CloudConfig::load`] time.
3716 component: String,
3717 /// Origin URL the Worker `fetch()`es. Schema-permissive — could
3718 /// be `https://...`, `wss://...`, or a yah-internal mesh URL
3719 /// resolved by yubaba.
3720 origin: String,
3721 },
3722 Redirect {
3723 /// Absolute URL or path the Worker emits a 30x to.
3724 target: String,
3725 /// HTTP status code. Defaults to 308 (permanent + method-preserving)
3726 /// so deprecations don't silently turn POSTs into GETs.
3727 #[serde(default = "default_redirect_status")]
3728 status: u16,
3729 },
3730}
3731
3732fn default_redirect_status() -> u16 {
3733 308
3734}
3735
3736/// Normalize a component `mount` to a storage/URL key prefix: strip the
3737/// surrounding slashes. `"/app"`, `"app/"`, `"/app/"` → `"app"`; `"/"`, `""`
3738/// → `""` (the service root).
3739///
3740/// One producer on purpose — the publisher's key prefix, the route-path
3741/// cross-check and the front door's key lookup must all agree on what `/app`
3742/// means down to the byte, and three copies of `trim_matches('/')` is how they
3743/// stop agreeing.
3744pub fn normalize_mount(raw: &str) -> String {
3745 raw.trim_matches('/').to_string()
3746}
3747
3748/// The key prefix a domain route pattern serves under: `"/*"` → `""`,
3749/// `"/app/*"` and `"/app"` → `"app"`. The twin of [`normalize_mount`] on the
3750/// routing side.
3751pub fn route_path_prefix(path: &str) -> String {
3752 normalize_mount(path.strip_suffix('*').unwrap_or(path))
3753}
3754
3755/// The route-driven domain whose route table binds a component of `service`,
3756/// if any. Used by static publishers to pick up the per-route response
3757/// headers a service's paths were declared with.
3758///
3759/// Deterministic by `BTreeMap` key order when more than one domain routes the
3760/// same service (a legitimate shape: an apex and a staging host serving one
3761/// bundle). Returning the first is a real limitation, not a considered
3762/// choice — the day two such domains want *different* headers for one
3763/// component, this needs the domain identity threaded in rather than inferred.
3764pub fn domain_serving_service<'a>(
3765 domains: &'a BTreeMap<String, DomainConfig>,
3766 service: &str,
3767) -> Option<&'a DomainConfig> {
3768 domains
3769 .values()
3770 .find(|d| d.front_door.is_route_driven() && d.serves_service(service))
3771}
3772
3773/// The `ROUTE_HEADERS` Worker-binding value for `service`, read from the
3774/// workspace's domain manifests. `"[]"` when no route-driven domain routes the
3775/// service, or when the one that does declares no headers.
3776///
3777/// Reads `.yah/domains/` directly rather than taking a loaded [`CloudConfig`]:
3778/// the static reconcilers are handed a per-component [`ReconcileCtx`], not the
3779/// whole workspace config, and threading a config reference through all 22 of
3780/// its construction sites to reach one string would be a wide change for a
3781/// narrow read. Manifest parse errors propagate — a domain file that no longer
3782/// loads is a deploy-stopping fact, not a reason to ship a Worker with the
3783/// headers quietly missing.
3784pub fn route_headers_for_service(workspace_root: &Path, service: &str) -> Result<String> {
3785 let domains = load_domains(&crate::paths::domains_dir(workspace_root))?;
3786 Ok(domain_serving_service(&domains, service)
3787 .map(DomainConfig::route_headers_json)
3788 .unwrap_or_else(|| "[]".to_string()))
3789}
3790
3791impl DomainConfig {
3792 /// Parse a single `.yah/domains/<name>.toml`, rejecting a manifest whose
3793 /// declared front door contradicts its route table
3794 /// ([`Self::validate_front_door`]).
3795 pub fn load(path: &Path) -> Result<Self> {
3796 let src =
3797 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
3798 let dom: Self =
3799 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
3800 dom.validate_front_door()
3801 .with_context(|| format!("validating {}", path.display()))?;
3802 dom.validate_route_headers()
3803 .with_context(|| format!("validating {}", path.display()))?;
3804 Ok(dom)
3805 }
3806
3807 /// R594-F12 — the front door must agree with the rest of the manifest.
3808 ///
3809 /// - `bucket-direct` is an R2 custom domain: a Worker route table would
3810 /// never be consulted, so declaring one means the author expected
3811 /// Worker behaviour (clean URLs, SPA fallback, branded errors) from a
3812 /// surface that cannot provide it. Rejected rather than silently
3813 /// ignored. Same for `worker_bundle_path` — nothing would deploy it.
3814 /// - `worker` / `passway` with an empty route table is a silent 404
3815 /// machine: the front door exists, has nothing to serve, and every
3816 /// request falls through to the catch-all.
3817 ///
3818 /// Called from [`Self::load`], so both [`CloudConfig::load`] and
3819 /// [`CloudConfig::load_from_config_dir`] enforce it.
3820 pub fn validate_front_door(&self) -> Result<()> {
3821 match self.front_door {
3822 FrontDoor::BucketDirect => {
3823 if let Some(route) = self.routes.first() {
3824 anyhow::bail!(
3825 "front_door = \"bucket-direct\" but routes[0].path = \"{}\" — \
3826 an R2 custom domain never consults a route table, so this \
3827 route would silently do nothing (no clean URLs, no SPA \
3828 fallback, no branded errors). Set front_door = \"worker\" \
3829 (or \"passway\") to keep the routes, or drop the [[routes]] \
3830 to keep the bucket-direct binding.",
3831 route.path
3832 );
3833 }
3834 if let Some(path) = &self.worker_bundle_path {
3835 anyhow::bail!(
3836 "front_door = \"bucket-direct\" but worker_bundle_path = \
3837 \"{path}\" — nothing deploys a Worker bundle for a domain \
3838 bound straight to R2"
3839 );
3840 }
3841 }
3842 FrontDoor::Worker | FrontDoor::Passway => {
3843 if self.routes.is_empty() {
3844 anyhow::bail!(
3845 "front_door = \"{}\" but [[routes]] is empty — a front door \
3846 with no route table is a silent 404 machine. Declare at \
3847 least one route, or set front_door = \"bucket-direct\" if \
3848 this domain really is served straight from R2.",
3849 self.front_door.as_str()
3850 );
3851 }
3852 }
3853 }
3854 Ok(())
3855 }
3856
3857 /// The `ROUTE_HEADERS` Worker binding for this domain (R746) — the route
3858 /// table's `path` + `headers` pairs, in manifest order, with routes that
3859 /// declare no headers dropped. `"[]"` when nothing declares any.
3860 ///
3861 /// Order is load-bearing and must survive serialization: the front door
3862 /// applies the FIRST matching rule, so `/app/*` above `/*` is what gives
3863 /// the app its isolation headers and leaves the marketing site alone.
3864 /// That is why this is a `Vec` of pairs and not a map keyed by path.
3865 ///
3866 /// Infallible by design — [`Self::validate_route_headers`] has already run
3867 /// at [`Self::load`], so by the time a reconciler calls this the table is
3868 /// known to be one both front doors can apply.
3869 pub fn route_headers_json(&self) -> String {
3870 #[derive(Serialize)]
3871 struct Rule<'a> {
3872 path: &'a str,
3873 headers: &'a BTreeMap<String, String>,
3874 }
3875 let rules: Vec<Rule<'_>> = self
3876 .routes
3877 .iter()
3878 .filter(|r| !r.headers.is_empty())
3879 .map(|r| Rule {
3880 path: &r.path,
3881 headers: &r.headers,
3882 })
3883 .collect();
3884 serde_json::to_string(&rules).unwrap_or_else(|_| "[]".to_string())
3885 }
3886
3887 /// R749-T5 — everything [`Self::route_headers_json`] emits must be
3888 /// *applicable*, checked here where the table is PRODUCED.
3889 ///
3890 /// That method serializes a typed struct, so the table's JSON *shape* is
3891 /// sound by construction. Its contents are not: a route's `headers` map is
3892 /// a free-form `name -> value` read verbatim out of hand-written TOML, so
3893 /// `"Cross Origin Opener Policy"` (spaces instead of hyphens) or a value
3894 /// carrying a newline ships a structurally-valid table that neither front
3895 /// door can apply — and they fail *differently*, neither naming the
3896 /// manifest line responsible:
3897 ///
3898 /// - **passway** — `mesofact::route_headers::RouteHeaderTable::parse`
3899 /// refuses the start, so the origin is simply down.
3900 /// - **worker** — `validateRouteHeaderTable` accepts it (it checks shape,
3901 /// not header validity) and `applyRouteHeaders` then throws inside the
3902 /// exported `fetch`, which is a 500 on every request, not the
3903 /// serve-without-the-headers degradation that code intends.
3904 ///
3905 /// So the strictness lives at the producer: a table that cannot be applied
3906 /// fails `yah cloud apply` at manifest load, naming domain, route and
3907 /// header. This is deliberately *not* a second parser — the check is
3908 /// `HeaderName`/`HeaderValue`'s own, the very constructors the passway door
3909 /// runs on the far side, and route *matching* semantics stay defined once,
3910 /// at the doors. Only routes that contribute to the table are checked, so
3911 /// the invariant is exactly "`route_headers_json`'s output parses".
3912 ///
3913 /// Called from [`Self::load`], alongside [`Self::validate_front_door`].
3914 pub fn validate_route_headers(&self) -> Result<()> {
3915 use axum::http::{HeaderName, HeaderValue};
3916
3917 for route in self.routes.iter().filter(|r| !r.headers.is_empty()) {
3918 if route.path.is_empty() {
3919 anyhow::bail!(
3920 "domain \"{}\" declares response headers on a route whose `path` is \
3921 empty — a rule that matches nothing (or everything, depending on \
3922 which front door reads it) is not a policy",
3923 self.name
3924 );
3925 }
3926 for (name, value) in &route.headers {
3927 HeaderName::try_from(name.as_str()).with_context(|| {
3928 format!(
3929 "domain \"{}\" route \"{}\" declares {name:?}, which is not a valid \
3930 HTTP header name — names are token characters only, so it is \
3931 `Cross-Origin-Opener-Policy`, never `Cross Origin Opener Policy`",
3932 self.name, route.path
3933 )
3934 })?;
3935 HeaderValue::try_from(value.as_str()).with_context(|| {
3936 format!(
3937 "domain \"{}\" route \"{}\" declares {name} = {value:?}, which is not \
3938 a valid HTTP header value — no newlines and no control characters",
3939 self.name, route.path
3940 )
3941 })?;
3942 }
3943 }
3944 Ok(())
3945 }
3946
3947 /// Whether this domain's route table binds any component of `service`.
3948 pub fn serves_service(&self, service: &str) -> bool {
3949 self.routes.iter().any(|r| {
3950 r.mode
3951 .component()
3952 .and_then(split_component_ref)
3953 .is_some_and(|(svc, _)| svc == service)
3954 })
3955 }
3956
3957 /// Persist to `.yah/domains/<name>.toml`, creating the domains
3958 /// directory if needed. Create-or-overwrite.
3959 pub fn save(&self, workspace_root: &Path) -> Result<()> {
3960 let dir = crate::paths::domains_dir(workspace_root);
3961 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
3962 let path = crate::paths::domain_toml(workspace_root, &self.name);
3963 let s = toml::to_string_pretty(self)
3964 .with_context(|| format!("serializing domain {}", self.name))?;
3965 std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
3966 }
3967
3968 /// Remove `.yah/domains/<name>.toml`. Returns `false` when the file
3969 /// was already absent.
3970 pub fn delete(workspace_root: &Path, name: &str) -> Result<bool> {
3971 let path = crate::paths::domain_toml(workspace_root, name);
3972 if !path.exists() {
3973 return Ok(false);
3974 }
3975 std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
3976 Ok(true)
3977 }
3978}
3979
3980impl RouteMode {
3981 /// Component reference for static/backend modes; `None` for redirects.
3982 pub fn component(&self) -> Option<&str> {
3983 match self {
3984 Self::Static { component } | Self::Backend { component, .. } => Some(component),
3985 Self::Redirect { .. } => None,
3986 }
3987 }
3988}
3989
3990// ─── Service-group vault (R706 / W294) ───────────────────────────────────────
3991
3992/// A camp's declaration of one cluster secret, from
3993/// `.yah/infra/secrets/<slug>.toml`.
3994///
3995/// This is the *authoring* side of the fleet's cluster-secret store: it names
3996/// where the value lives in the camp (a `fob` vault slot), what the fleet should
3997/// call it, and — the point of R706 — which workloads are allowed to mount it.
3998///
3999/// The declaration is not itself the enforcement point. `yah cloud secret put`
4000/// reads this file, seals the vault value under the cluster KEK, and ships the
4001/// ciphertext **with its access rule** into raft; yubaba's `ClusterResolver`
4002/// evaluates the rule on the node at mount time. Deleting this file does not
4003/// revoke anything — the record in raft is the live authority. That asymmetry is
4004/// deliberate: a rule that lived only in a git-tracked camp file would be
4005/// trivially bypassed by anyone who could reach the fleet without the camp.
4006///
4007/// ```toml
4008/// #:schema ../../schema/secret.toml.schema.json
4009/// schema_version = 1
4010/// name = "cheers/cloud-admin/verify-key"
4011/// vault_slot = "cheers-cloud-admin-verify-key"
4012/// description = "Ed25519 public key yah-cloud-admin verifies operator PASETOs with"
4013///
4014/// [access]
4015/// workloads = [{ workload = "yah-cloud-admin" }]
4016///
4017/// [target]
4018/// kind = "file"
4019/// path = "/run/secrets/cheers-verify.key"
4020/// mode = 0o400
4021/// ```
4022#[derive(Debug, Clone, Serialize, Deserialize)]
4023#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4024pub struct SecretConfig {
4025 pub schema_version: u32,
4026
4027 /// Logical cluster-secret key, as `SecretRef::Cluster { name }` spells it —
4028 /// e.g. `"tls/yah.dev/cert"`, `"cheers/cloud-admin/verify-key"`. May contain
4029 /// `/`; the file stem is a filesystem-safe slug and carries no meaning.
4030 pub name: String,
4031
4032 /// The `fob` vault slot in this camp holding the plaintext value. Read by
4033 /// `yah cloud secret put` at ship time and never recorded anywhere else — in
4034 /// particular the value is not in this file, so the declaration is safe to
4035 /// commit.
4036 pub vault_slot: String,
4037
4038 /// Human note for `yah cloud secret ls`. What this secret is and who minted
4039 /// it — the thing nobody remembers 6 months later.
4040 #[serde(default, skip_serializing_if = "Option::is_none")]
4041 pub description: Option<String>,
4042
4043 /// How the vault slot's text decodes into the bytes the consumer expects.
4044 ///
4045 /// `fob` slots hold strings, but plenty of real secrets are **binary** — an
4046 /// Ed25519 key is exactly 32 raw bytes, and `yah-cloud-admin` rejects a key
4047 /// file of any other length. Without this field the only way to ship such a
4048 /// key would be to hope its bytes happened to be valid UTF-8, which for a
4049 /// random key they are not.
4050 ///
4051 /// Defaults to [`SecretEncoding::Utf8`] — the right answer for tokens,
4052 /// passwords, and PEM, which is most secrets.
4053 #[serde(default)]
4054 pub encoding: SecretEncoding,
4055
4056 /// Who may mount it. Stamped onto the raft record verbatim.
4057 ///
4058 /// Defaults to [`SecretAccess::default`] — the deny-all empty allow-list. A
4059 /// declaration that forgets this field produces a secret nobody can mount,
4060 /// which is the correct direction to fail in.
4061 ///
4062 /// Three forms:
4063 ///
4064 /// ```toml
4065 /// access = "allow_any" # explicit escape hatch
4066 ///
4067 /// [access] # named workloads
4068 /// workloads = [{ workload = "yah-cloud-admin" }]
4069 ///
4070 /// [access] # signed recipes (R555-F5)
4071 /// recipes = [{ recipe = "rusty-v8-musl", key = "3d40…" }]
4072 /// ```
4073 ///
4074 /// Use the `recipes` form for a credential a **dispatched build** needs (the
4075 /// R2 write key, the cosign signing key). A remote QED run's workload name
4076 /// is a fresh `forge-<uuid>` every time, so `workloads` cannot name it and
4077 /// `allow_any` over-answers — see W235 §Seam (c) secret scoping. `key` is
4078 /// the hex Ed25519 public key from the recipe's `[admission]` block.
4079 #[serde(default)]
4080 pub access: SecretAccess,
4081
4082 /// Advisory: the mount shape a consuming workload should declare. Not
4083 /// enforced — yubaba honours whatever the `WorkloadSpec` asks for — but it
4084 /// lets `yah cloud secret put` print the exact `SecretMount` to paste, so
4085 /// the consumer and the declaration can't drift on path or mode.
4086 #[serde(default, skip_serializing_if = "Option::is_none")]
4087 pub target: Option<SecretTargetDecl>,
4088}
4089
4090/// How a [`SecretConfig`]'s vault text becomes the bytes delivered to the
4091/// container (R706 / W294).
4092#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
4093#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4094#[serde(rename_all = "kebab-case")]
4095pub enum SecretEncoding {
4096 /// Ship the vault string's UTF-8 bytes verbatim. Tokens, passwords, PEM.
4097 #[default]
4098 Utf8,
4099 /// The vault string is hex; ship the decoded bytes. Use for binary key
4100 /// material — e.g. a raw Ed25519 key, which must land as exactly 32 bytes.
4101 Hex,
4102}
4103
4104/// Advisory mount shape on a [`SecretConfig`]. Mirrors
4105/// `workload_spec::SecretTarget` in a TOML-friendly, externally-tagged-free
4106/// shape (a `kind` discriminator reads better in a hand-written manifest than
4107/// serde's default enum encoding).
4108#[derive(Debug, Clone, Serialize, Deserialize)]
4109#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4110#[serde(tag = "kind", rename_all = "kebab-case")]
4111pub enum SecretTargetDecl {
4112 /// Mounted as a tmpfs-backed file inside the container.
4113 File {
4114 /// Absolute path inside the container.
4115 path: String,
4116 /// Unix permission bits. Defaults to `0o400` (owner-read-only).
4117 #[serde(default = "default_secret_mode")]
4118 mode: u32,
4119 },
4120 /// Injected as an environment variable. Prefer `file` — env vars leak
4121 /// through subprocess environments and log dumps.
4122 EnvVar { name: String },
4123}
4124
4125fn default_secret_mode() -> u32 {
4126 0o400
4127}
4128
4129impl SecretTargetDecl {
4130 /// The `workload_spec` target this declaration describes.
4131 pub fn to_target(&self) -> workload_spec::SecretTarget {
4132 match self {
4133 Self::File { path, mode } => workload_spec::SecretTarget::File {
4134 path: path.into(),
4135 mode: *mode,
4136 },
4137 Self::EnvVar { name } => workload_spec::SecretTarget::EnvVar { name: name.clone() },
4138 }
4139 }
4140}
4141
4142impl SecretConfig {
4143 /// Parse a single `.yah/infra/secrets/<slug>.toml`.
4144 pub fn load(path: &Path) -> Result<Self> {
4145 let src =
4146 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
4147 let cfg: Self =
4148 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
4149 cfg.validate()
4150 .with_context(|| format!("validating {}", path.display()))?;
4151 Ok(cfg)
4152 }
4153
4154 /// Load every declaration in `dir`, keyed by logical secret name. A missing
4155 /// directory is an empty map (a camp with no cluster secrets is normal).
4156 ///
4157 /// Two files declaring the same `name` is a hard error, not a last-writer-
4158 /// wins merge: they would race to define the access rule for one record, and
4159 /// whichever lost would look correct in git while being inert on the fleet.
4160 pub fn load_dir(dir: &Path) -> Result<BTreeMap<String, Self>> {
4161 let mut out: BTreeMap<String, Self> = BTreeMap::new();
4162 if !dir.exists() {
4163 return Ok(out);
4164 }
4165 for entry in std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? {
4166 let path = entry?.path();
4167 if path.extension().is_none_or(|e| e != "toml") {
4168 continue;
4169 }
4170 let cfg = Self::load(&path)?;
4171 if let Some(prev) = out.insert(cfg.name.clone(), cfg) {
4172 anyhow::bail!(
4173 "two secret declarations both claim name {:?} (one of them is {}); \
4174 a cluster secret must have exactly one declaration so its access \
4175 rule has one author",
4176 prev.name,
4177 path.display()
4178 );
4179 }
4180 }
4181 Ok(out)
4182 }
4183
4184 /// Reject declarations that would produce an unusable or dangerous record.
4185 pub fn validate(&self) -> Result<()> {
4186 if self.name.trim().is_empty() {
4187 anyhow::bail!("`name` must not be empty");
4188 }
4189 if self.vault_slot.trim().is_empty() {
4190 anyhow::bail!(
4191 "`vault_slot` must not be empty — it names the fob slot holding the value"
4192 );
4193 }
4194 // A deny-all rule is a *valid* record (it is the fail-closed default the
4195 // resolver relies on) but it is never a useful thing to deliberately
4196 // ship, so catching it here saves an operator the round-trip of
4197 // deploying a workload that mysteriously can't see its own secret.
4198 if let SecretAccess::Workloads(entries) = &self.access {
4199 if entries.is_empty() {
4200 anyhow::bail!(
4201 "`[access]` admits nobody: list the workloads allowed to mount {:?} \
4202 (e.g. `workloads = [{{ workload = \"my-service\" }}]`), or set \
4203 `access = \"allow_any\"` to store it unrestricted",
4204 self.name
4205 );
4206 }
4207 if let Some(bad) = entries.iter().find(|e| e.workload.trim().is_empty()) {
4208 anyhow::bail!("`[access]` entry has an empty `workload` name: {bad:?}");
4209 }
4210 }
4211 Ok(())
4212 }
4213}
4214
4215#[cfg(test)]
4216mod secret_config_tests {
4217 use super::*;
4218
4219 fn parse(body: &str) -> Result<SecretConfig> {
4220 let cfg: SecretConfig = toml::from_str(body)?;
4221 cfg.validate()?;
4222 Ok(cfg)
4223 }
4224
4225 #[test]
4226 fn minimal_declaration_parses_with_narrow_defaults() {
4227 let cfg = parse(
4228 r#"
4229schema_version = 1
4230name = "svc/token"
4231vault_slot = "svc-token"
4232[access]
4233workloads = [{ workload = "svc" }]
4234"#,
4235 )
4236 .unwrap();
4237
4238 assert_eq!(cfg.encoding, SecretEncoding::Utf8, "text is the default");
4239 assert!(cfg.target.is_none());
4240 // The omitted tenant/namespace must narrow to the singletons, not widen
4241 // to a wildcard.
4242 assert!(cfg
4243 .access
4244 .admits(&workload_spec::secrets::SecretConsumer::workload("svc")));
4245 assert!(!cfg
4246 .access
4247 .admits(&workload_spec::secrets::SecretConsumer::workload("other")));
4248 }
4249
4250 #[test]
4251 fn allow_any_is_spelled_as_a_bare_string() {
4252 // The operator-facing spelling, pinned: `access = "allow_any"`.
4253 let cfg = parse(
4254 r#"
4255schema_version = 1
4256name = "public/thing"
4257vault_slot = "slot"
4258access = "allow_any"
4259"#,
4260 )
4261 .unwrap();
4262 assert_eq!(cfg.access, SecretAccess::AllowAny);
4263 }
4264
4265 #[test]
4266 fn a_declaration_with_no_access_block_is_rejected() {
4267 // Omitting `[access]` defaults to deny-all, which is the correct
4268 // *runtime* default but never a correct authoring intent — so it must
4269 // not silently produce a secret nobody can mount.
4270 let err = parse(
4271 r#"
4272schema_version = 1
4273name = "svc/token"
4274vault_slot = "svc-token"
4275"#,
4276 )
4277 .unwrap_err()
4278 .to_string();
4279 assert!(err.contains("admits nobody"), "got {err}");
4280 }
4281
4282 #[test]
4283 fn empty_name_or_slot_is_rejected() {
4284 assert!(parse(
4285 r#"
4286schema_version = 1
4287name = ""
4288vault_slot = "slot"
4289access = "allow_any"
4290"#
4291 )
4292 .is_err());
4293 assert!(parse(
4294 r#"
4295schema_version = 1
4296name = "x"
4297vault_slot = " "
4298access = "allow_any"
4299"#
4300 )
4301 .is_err());
4302 }
4303
4304 #[test]
4305 fn target_declaration_maps_onto_the_workload_spec_type() {
4306 let cfg = parse(
4307 r#"
4308schema_version = 1
4309name = "svc/token"
4310vault_slot = "slot"
4311access = "allow_any"
4312[target]
4313kind = "file"
4314path = "/run/secrets/t"
4315"#,
4316 )
4317 .unwrap();
4318 match cfg.target.unwrap().to_target() {
4319 workload_spec::SecretTarget::File { path, mode } => {
4320 assert_eq!(path, std::path::PathBuf::from("/run/secrets/t"));
4321 assert_eq!(mode, 0o400, "owner-read-only by default");
4322 }
4323 other => panic!("expected File, got {other:?}"),
4324 }
4325 }
4326
4327 #[test]
4328 fn load_dir_is_empty_for_a_camp_with_no_secrets() {
4329 let tmp = tempfile::TempDir::new().unwrap();
4330 assert!(SecretConfig::load_dir(&tmp.path().join("nope"))
4331 .unwrap()
4332 .is_empty());
4333 }
4334}
4335
4336/// Split a `"<service>/<component-id>"` ref. Returns `None` if the ref
4337/// isn't shaped like `service/component`.
4338fn split_component_ref(s: &str) -> Option<(&str, &str)> {
4339 let (svc, comp) = s.split_once('/')?;
4340 if svc.is_empty() || comp.is_empty() || comp.contains('/') {
4341 return None;
4342 }
4343 Some((svc, comp))
4344}
4345
4346#[cfg(test)]
4347mod tests {
4348 use super::*;
4349 use std::path::PathBuf;
4350
4351 fn make_machine(name: &str, mesh_tags: Vec<&str>) -> MachineConfig {
4352 MachineConfig {
4353 name: name.into(),
4354 provider: "hetzner".into(),
4355 location: Some("hil".into()),
4356 server_type: Some("ccx13".into()),
4357 hosts_mirrors: vec![],
4358 mesh_tags: mesh_tags.into_iter().map(String::from).collect(),
4359 region: None,
4360 zone: None,
4361 arch: None,
4362 bucket: None,
4363 vendor: None,
4364 nickname: None,
4365 legacy_hostkey_fingerprint: None,
4366 registration: Default::default(),
4367 ssh_keys: vec![],
4368 cloudflared: None,
4369 hosts_operator_bridge: false,
4370 connect: None,
4371 allocatable: None,
4372 taints: vec![],
4373 sovereign_group: None,
4374 sovereign_role: None,
4375 }
4376 }
4377
4378 /// Like [`make_machine`] but with explicit topology axes for F16 tests.
4379 fn make_machine_topo(
4380 name: &str,
4381 provider: &str,
4382 region: &str,
4383 mesh_tags: Vec<&str>,
4384 ) -> MachineConfig {
4385 MachineConfig {
4386 provider: provider.into(),
4387 region: Some(region.into()),
4388 zone: Some(region.into()),
4389 ..make_machine(name, mesh_tags)
4390 }
4391 }
4392
4393 fn make_empty_cfg(machines: Vec<MachineConfig>) -> CloudConfig {
4394 CloudConfig {
4395 workspace_root: PathBuf::new(),
4396 machines,
4397 providers: vec![],
4398 machine_origins: BTreeMap::new(),
4399 provider_origins: BTreeMap::new(),
4400 services: BTreeMap::new(),
4401 domains: BTreeMap::new(),
4402 legacy_mirrors: vec![],
4403 workloads: vec![],
4404 topology: TopologyConfig::default(),
4405 legacy_services: vec![],
4406 }
4407 }
4408
4409 #[test]
4410 fn required_spec_parses_from_provider_fields() {
4411 let toml_src = r#"
4412use = "hetzner-primary"
4413[required]
4414mesh_tags = ["tag:cloud-runner"]
4415"#;
4416 let slot: MirrorProviderSlot = toml::from_str(toml_src).unwrap();
4417 let req = slot.required().expect("required block present");
4418 assert_eq!(req.mesh_tags, vec!["tag:cloud-runner"]);
4419 }
4420
4421 #[test]
4422 fn required_spec_absent_when_field_missing() {
4423 let slot: MirrorProviderSlot = toml::from_str(r#"use = "hetzner-primary""#).unwrap();
4424 assert!(slot.required().is_none());
4425 }
4426
4427 #[test]
4428 fn db_catalog_parses_all_env_blocks() {
4429 // W241 / R571-F8: a service.toml [db] table with dev/pond/cloud.
4430 let toml_src = r#"
4431schema_version = 1
4432name = "scrabcake"
4433domain = "scrabcake.net.yah.dev"
4434
4435[[db.dev]]
4436name = "main"
4437path = "data/dev.sqlite"
4438
4439[[db.pond]]
4440name = "main"
4441port = 5433
4442
4443[[db.pond]]
4444name = "pg"
4445port = 5432
4446kind = "postgres"
4447
4448[[db.cloud]]
4449name = "main"
4450url = "libsql://scrabcake.turso.io"
4451auth_token_env = "SCRABCAKE_TURSO_TOKEN"
4452"#;
4453 let svc: ServiceConfig = toml::from_str(toml_src).unwrap();
4454 assert_eq!(svc.db.dev.len(), 1);
4455 assert_eq!(svc.db.dev[0].path, "data/dev.sqlite");
4456 assert_eq!(svc.db.pond.len(), 2);
4457 assert_eq!(svc.db.pond[0].port, Some(5433));
4458 assert_eq!(svc.db.pond[0].kind, PondDbKind::Turso); // default
4459 assert_eq!(svc.db.pond[1].kind, PondDbKind::Postgres);
4460 assert_eq!(
4461 svc.db.cloud[0].auth_token_env.as_deref(),
4462 Some("SCRABCAKE_TURSO_TOKEN")
4463 );
4464 }
4465
4466 #[test]
4467 fn service_without_db_table_has_empty_catalog() {
4468 let svc: ServiceConfig =
4469 toml::from_str("schema_version = 1\nname = \"s\"\ndomain = \"s.dev\"\n").unwrap();
4470 assert!(svc.db.is_empty());
4471 // And an empty [db] must not appear when re-serialized.
4472 let out = toml::to_string(&svc).unwrap();
4473 assert!(
4474 !out.contains("[db"),
4475 "empty db table should be skipped: {out}"
4476 );
4477 }
4478
4479 #[test]
4480 fn camp_shared_cloud_toml_parses() {
4481 let src = r#"
4482[[cloud]]
4483name = "analytics"
4484url = "postgres://shared/analytics"
4485"#;
4486 let shared: CampCloudDbs = toml::from_str(src).unwrap();
4487 assert_eq!(shared.cloud.len(), 1);
4488 assert_eq!(shared.cloud[0].name, "analytics");
4489 }
4490
4491 #[test]
4492 fn resolve_machine_by_mesh_tags_superset_match() {
4493 let cfg = make_empty_cfg(vec![
4494 make_machine("yah-bnt-1", vec!["tag:primary-yah", "tag:tier-scratch"]),
4495 make_machine("us-west-001", vec!["tag:primary-yah", "tag:cloud-runner"]),
4496 ]);
4497 let picked = cfg
4498 .resolve_machine_by_mesh_tags(&["tag:cloud-runner".into()])
4499 .map(|m| m.name.as_str());
4500 assert_eq!(picked, Some("us-west-001"));
4501 }
4502
4503 #[test]
4504 fn resolve_machine_by_mesh_tags_returns_none_when_no_match() {
4505 let cfg = make_empty_cfg(vec![make_machine("yah-bnt-1", vec!["tag:primary-yah"])]);
4506 assert!(cfg
4507 .resolve_machine_by_mesh_tags(&["tag:cloud-runner".into()])
4508 .is_none());
4509 }
4510
4511 // ─── R590-F1 mesh-tag node-selector admission ───────────────────────────
4512
4513 /// Build a forge WorkloadSpec carrying the R594 node-selector annotation.
4514 /// `selector` is the comma-joined mesh-tag set; `None` omits the annotation
4515 /// entirely (pre-R594 "no constraint").
4516 fn ws_with_selector(selector: Option<&str>) -> WorkloadSpec {
4517 use workload_spec::{ImageRef, TierTag};
4518 let mut ws = WorkloadSpec::for_forge(
4519 "R590-F1-test",
4520 ImageRef {
4521 registry: "docker.io".into(),
4522 repository: "library/busybox".into(),
4523 tag: "latest".into(),
4524 digest: workload_spec::testing::test_digest(),
4525 },
4526 TierTag("infra".into()),
4527 vec![],
4528 );
4529 if let Some(sel) = selector {
4530 ws.annotations.insert(
4531 velveteen_exec::remote::NODE_SELECTOR_MESH_TAGS_ANNOTATION.into(),
4532 sel.into(),
4533 );
4534 }
4535 ws
4536 }
4537
4538 /// The build-worker fleet shape: one x86 node (us-west-002) and one arm
4539 /// node (a Pi5), both carrying `tag:build-worker`.
4540 fn build_worker_fleet() -> CloudConfig {
4541 make_empty_cfg(vec![
4542 make_machine("us-west-002", vec!["tag:build-worker", "arch:x86"]),
4543 make_machine("pi5-001", vec!["tag:build-worker", "arch:arm"]),
4544 ])
4545 }
4546
4547 #[test]
4548 fn admit_workload_routes_amd64_to_x86_worker() {
4549 let cfg = build_worker_fleet();
4550 let ws = ws_with_selector(Some("tag:build-worker,arch:x86"));
4551 let picked = cfg.admit_workload(&ws).unwrap();
4552 assert_eq!(picked.name, "us-west-002");
4553 }
4554
4555 #[test]
4556 fn admit_workload_routes_arm64_to_pi5_worker() {
4557 let cfg = build_worker_fleet();
4558 let ws = ws_with_selector(Some("tag:build-worker,arch:arm"));
4559 let picked = cfg.admit_workload(&ws).unwrap();
4560 assert_eq!(picked.name, "pi5-001");
4561 }
4562
4563 /// A forge run must be admissible on a build-worker smaller than its own
4564 /// cgroup ceiling.
4565 ///
4566 /// The fleet's arm build-workers are 8 GiB Pi-5s and `for_forge` sets a
4567 /// 32 GiB ceiling, so while admission read `resources.memory_mb` as the
4568 /// capacity floor this returned "no candidates" and *every* offloaded qed
4569 /// step to those nodes failed at dispatch — measured on desktop-release run
4570 /// b04cef47, where the aarch64-linux row died in 1.6s. The other
4571 /// build-workers (16 GiB us-west-003, and the arm Pi-5s) were excluded the
4572 /// same way, leaving one 47 GiB node as the fleet's only legal target for
4573 /// remote CI.
4574 #[test]
4575 fn admit_workload_places_a_forge_run_on_a_worker_smaller_than_its_ceiling() {
4576 let mut pi = make_machine("pi5-001", vec!["tag:build-worker", "arch:arm"]);
4577 pi.allocatable = Some(NodeAllocatable {
4578 memory_mb: 8192,
4579 cpu_millis: 4000,
4580 });
4581 let cfg = make_empty_cfg(vec![pi]);
4582
4583 let ws = ws_with_selector(Some("tag:build-worker,arch:arm"));
4584 assert!(
4585 ws.resources.memory_mb > 8192,
4586 "precondition: the ceiling must exceed the node, or this proves nothing"
4587 );
4588
4589 let picked = cfg
4590 .admit_workload(&ws)
4591 .expect("an 8 GiB build-worker must admit a forge run");
4592 assert_eq!(picked.name, "pi5-001");
4593 }
4594
4595 /// The floor is still enforced — the fix separates two numbers, it does not
4596 /// disable the R572-F5 capacity check.
4597 #[test]
4598 fn admit_workload_still_rejects_a_node_below_the_declared_request() {
4599 let mut tiny = make_machine("tiny-001", vec!["tag:build-worker", "arch:arm"]);
4600 tiny.allocatable = Some(NodeAllocatable {
4601 memory_mb: 512,
4602 cpu_millis: 4000,
4603 });
4604 let cfg = make_empty_cfg(vec![tiny]);
4605
4606 let ws = ws_with_selector(Some("tag:build-worker,arch:arm"));
4607 assert!(
4608 cfg.admit_workload(&ws).is_err(),
4609 "a 512 MiB node cannot satisfy a 2 GiB forge request"
4610 );
4611 }
4612
4613 // ─── R833-F8 imperative node-selector admission ─────────────────────────
4614
4615 /// Build a forge WorkloadSpec carrying the R833-F8 imperative node
4616 /// selector — the operator's `--where=node:<machine>`.
4617 fn ws_pinned_to(node: &str) -> WorkloadSpec {
4618 let mut ws = ws_with_selector(None);
4619 ws.annotations.insert(
4620 velveteen_exec::remote::NODE_SELECTOR_NODE_ANNOTATION.into(),
4621 node.into(),
4622 );
4623 ws
4624 }
4625
4626 /// The ticket's acceptance shape: a named node wins over the
4627 /// declaration-order tie-break that would otherwise decide placement.
4628 /// `us-west-002` is declared first and carries every tag, so an inferred
4629 /// placement lands there; the pin must reach `pi5-001` regardless.
4630 #[test]
4631 fn admit_workload_honours_an_explicitly_named_node() {
4632 let cfg = build_worker_fleet();
4633 assert_eq!(
4634 cfg.admit_workload(&ws_with_selector(Some("tag:build-worker")))
4635 .unwrap()
4636 .name,
4637 "us-west-002",
4638 "precondition: inference elects the first-declared node",
4639 );
4640 assert_eq!(
4641 cfg.admit_workload(&ws_pinned_to("pi5-001")).unwrap().name,
4642 "pi5-001",
4643 );
4644 }
4645
4646 /// A pin at a machine that is not declared fails loud, naming the
4647 /// constraint and the pool — the operator mistyped a node, and silently
4648 /// running the build somewhere else is the one outcome that must not
4649 /// happen.
4650 #[test]
4651 fn admit_workload_refuses_a_node_that_is_not_declared() {
4652 let cfg = build_worker_fleet();
4653 let err = cfg
4654 .admit_workload(&ws_pinned_to("us-west-404"))
4655 .unwrap_err()
4656 .to_string();
4657 assert!(err.contains("required.nodes=[us-west-404]"), "{err}");
4658 assert!(err.contains("us-west-002"), "the pool must be named: {err}");
4659 }
4660
4661 /// The pin narrows the candidate set; it does not suspend the other axes.
4662 /// A named node that cannot fit the workload still refuses, rather than
4663 /// being handed work it has no room for.
4664 #[test]
4665 fn a_pinned_node_is_still_checked_against_capacity() {
4666 let mut tiny = make_machine("tiny-001", vec!["tag:build-worker", "arch:arm"]);
4667 tiny.allocatable = Some(NodeAllocatable {
4668 memory_mb: 512,
4669 cpu_millis: 4000,
4670 });
4671 let cfg = make_empty_cfg(vec![tiny]);
4672 assert!(cfg.admit_workload(&ws_pinned_to("tiny-001")).is_err());
4673 }
4674
4675 /// Inference is untouched: with no node annotation the `nodes` axis is
4676 /// empty, which is "no constraint" — every pre-R833-F8 workload is admitted
4677 /// exactly as before.
4678 #[test]
4679 fn an_unpinned_workload_carries_no_node_constraint() {
4680 assert!(node_selector_node(&ws_with_selector(Some("arch:x86"))).is_none());
4681 assert_eq!(
4682 node_selector_node(&ws_pinned_to("us-west-003")).as_deref(),
4683 Some("us-west-003")
4684 );
4685 assert!(RequiredSpec::default().is_unconstrained());
4686 assert!(!RequiredSpec {
4687 nodes: vec!["us-west-003".into()],
4688 ..Default::default()
4689 }
4690 .is_unconstrained());
4691 }
4692
4693 #[test]
4694 fn admit_workload_rejects_node_missing_required_tag() {
4695 // Only an arm worker exists; an x86 build must NOT land on it.
4696 let cfg = make_empty_cfg(vec![make_machine(
4697 "pi5-001",
4698 vec!["tag:build-worker", "arch:arm"],
4699 )]);
4700 let ws = ws_with_selector(Some("tag:build-worker,arch:x86"));
4701 assert!(cfg.admit_workload(&ws).is_err());
4702 }
4703
4704 /// R555-S1 regression: with TWO nodes carrying the same tag set, which one
4705 /// admits must be decided by *declaration order* (file name), which is the
4706 /// contract `admit_workload` documents — not by `read_dir` order, which is
4707 /// filesystem-dependent and can change when an unrelated file appears in
4708 /// the directory. Written creation-order-reversed so a filesystem that
4709 /// yields creation order (rather than sorted order) trips it without the
4710 /// sort in `load_dir`.
4711 ///
4712 /// Live consequence this guards: `.yah/infra/machines/` carries both
4713 /// us-west-002 and us-west-003 on `[tag:build-worker, arch:x86, os:linux]`,
4714 /// so an x86 QED offload has two equal candidates. Unstable selection means
4715 /// a retried build cannot be relied on to land back on the node whose
4716 /// working state it left behind.
4717 #[test]
4718 fn equally_matching_machines_admit_in_file_name_order() {
4719 let tmp = tempfile::TempDir::new().unwrap();
4720 let machines = tmp.path().join(".yah").join("infra").join("machines");
4721 std::fs::create_dir_all(&machines).unwrap();
4722 let toml_for = |name: &str| {
4723 format!(
4724 r#"name = "{name}"
4725provider = "static"
4726mesh_tags = ["tag:build-worker", "arch:x86"]
4727"#
4728 )
4729 };
4730 // Reverse-of-sorted creation order on purpose.
4731 std::fs::write(machines.join("b-second.toml"), toml_for("b-second")).unwrap();
4732 std::fs::write(machines.join("a-first.toml"), toml_for("a-first")).unwrap();
4733
4734 let cfg = CloudConfig::load(tmp.path()).unwrap();
4735 assert_eq!(
4736 cfg.machines.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(),
4737 vec!["a-first", "b-second"],
4738 "machines must load in file-name order, not read_dir order"
4739 );
4740
4741 let ws = ws_with_selector(Some("tag:build-worker,arch:x86"));
4742 assert_eq!(cfg.admit_workload(&ws).unwrap().name, "a-first");
4743 }
4744
4745 #[test]
4746 fn admit_workload_empty_selector_is_unconstrained() {
4747 // Absent annotation ⇒ no mesh-tag constraint ⇒ first declared machine
4748 // (pre-R594 behavior preserved).
4749 let cfg = build_worker_fleet();
4750 let ws = ws_with_selector(None);
4751 let picked = cfg.admit_workload(&ws).unwrap();
4752 assert_eq!(picked.name, "us-west-002");
4753 }
4754
4755 #[test]
4756 fn node_selector_mesh_tags_trims_and_drops_empties() {
4757 let ws = ws_with_selector(Some(" tag:build-worker , arch:x86 ,"));
4758 assert_eq!(
4759 node_selector_mesh_tags(&ws),
4760 vec!["tag:build-worker".to_string(), "arch:x86".to_string()]
4761 );
4762 assert!(node_selector_mesh_tags(&ws_with_selector(None)).is_empty());
4763 }
4764
4765 // ─── F16 topology-aware resolver ────────────────────────────────────────
4766
4767 fn two_region_fleet() -> CloudConfig {
4768 make_empty_cfg(vec![
4769 make_machine_topo(
4770 "us-west-001",
4771 "hetzner",
4772 "us-west",
4773 vec!["tag:cloud-runner"],
4774 ),
4775 make_machine_topo(
4776 "eu-west-001",
4777 "hetzner",
4778 "eu-west",
4779 vec!["tag:cloud-runner"],
4780 ),
4781 ])
4782 }
4783
4784 #[test]
4785 fn resolve_machine_matches_on_region_plus_mesh_tags() {
4786 let cfg = two_region_fleet();
4787 let req = RequiredSpec {
4788 regions: vec!["us-west".into()],
4789 mesh_tags: vec!["tag:cloud-runner".into()],
4790 ..Default::default()
4791 };
4792 let picked = cfg.resolve_machine(&req).unwrap();
4793 assert_eq!(picked.name, "us-west-001");
4794 }
4795
4796 #[test]
4797 fn resolve_machine_region_disambiguates_same_tag() {
4798 // Both boxes carry tag:cloud-runner; the region axis selects eu-west.
4799 let cfg = two_region_fleet();
4800 let req = RequiredSpec {
4801 regions: vec!["eu-west".into()],
4802 mesh_tags: vec!["tag:cloud-runner".into()],
4803 ..Default::default()
4804 };
4805 assert_eq!(cfg.resolve_machine(&req).unwrap().name, "eu-west-001");
4806 }
4807
4808 #[test]
4809 fn resolve_machine_fails_loud_with_constraint_summary() {
4810 let cfg = two_region_fleet();
4811 let req = RequiredSpec {
4812 regions: vec!["us-central".into()],
4813 mesh_tags: vec!["tag:cloud-runner".into()],
4814 ..Default::default()
4815 };
4816 let err = cfg.resolve_machine(&req).unwrap_err().to_string();
4817 assert!(err.contains("required.regions=[us-central]"), "got: {err}");
4818 assert!(
4819 err.contains("required.mesh_tags=[tag:cloud-runner]"),
4820 "got: {err}"
4821 );
4822 // Names the candidates it rejected.
4823 assert!(err.contains("us-west-001"), "got: {err}");
4824 }
4825
4826 #[test]
4827 fn resolve_machine_provider_axis_filters() {
4828 let cfg = make_empty_cfg(vec![
4829 make_machine_topo("aws-west-1", "aws", "us-west", vec!["tag:cloud-runner"]),
4830 make_machine_topo("hz-west-1", "hetzner", "us-west", vec!["tag:cloud-runner"]),
4831 ]);
4832 let req = RequiredSpec {
4833 regions: vec!["us-west".into()],
4834 providers: vec!["hetzner".into()],
4835 ..Default::default()
4836 };
4837 assert_eq!(cfg.resolve_machine(&req).unwrap().name, "hz-west-1");
4838 }
4839
4840 #[test]
4841 fn unconstrained_required_spec_matches_first_machine() {
4842 let cfg = two_region_fleet();
4843 assert!(RequiredSpec::default().is_unconstrained());
4844 assert_eq!(
4845 cfg.resolve_machine(&RequiredSpec::default()).unwrap().name,
4846 "us-west-001"
4847 );
4848 }
4849
4850 #[test]
4851 fn required_spec_parses_topology_axes_from_toml() {
4852 let toml_src = r#"
4853use = "hetzner-primary"
4854[required]
4855regions = ["us-west"]
4856mesh_tags = ["tag:cloud-runner"]
4857"#;
4858 let slot: MirrorProviderSlot = toml::from_str(toml_src).unwrap();
4859 let req = slot.required().expect("required block present");
4860 assert_eq!(req.regions, vec!["us-west"]);
4861 assert_eq!(req.mesh_tags, vec!["tag:cloud-runner"]);
4862 assert!(req.zones.is_empty());
4863 }
4864
4865 #[test]
4866 fn round_trip_machine() {
4867 let cfg = MachineConfig {
4868 name: "test-pdx-1".into(),
4869 provider: "hetzner".into(),
4870 location: Some("pdx".into()),
4871 server_type: Some("cpx22".into()),
4872 hosts_mirrors: vec!["noisetable".into()],
4873 mesh_tags: vec!["region:pdx".into()],
4874 region: Some("us-west".into()),
4875 zone: Some("pdx".into()),
4876 arch: None,
4877 bucket: Some(BucketSpec {
4878 name: "test-assets-pdx-1".into(),
4879 public_read: false,
4880 }),
4881 vendor: None,
4882 nickname: None,
4883 legacy_hostkey_fingerprint: None,
4884 registration: Default::default(),
4885 ssh_keys: vec![],
4886 cloudflared: None,
4887 hosts_operator_bridge: false,
4888 connect: None,
4889 allocatable: None,
4890 taints: vec![],
4891 sovereign_group: None,
4892 sovereign_role: None,
4893 };
4894 let s = toml::to_string(&cfg).unwrap();
4895 let back: MachineConfig = toml::from_str(&s).unwrap();
4896 assert_eq!(back.name, cfg.name);
4897 assert_eq!(back.location, cfg.location);
4898 assert_eq!(back.region.as_deref(), Some("us-west"));
4899 assert_eq!(back.zone.as_deref(), Some("pdx"));
4900 }
4901
4902 #[test]
4903 fn round_trip_mirror() {
4904 let cfg = LegacyMirrorConfig {
4905 camp: "noisetable".into(),
4906 regions: vec!["pdx".into(), "iad".into()],
4907 workloads: vec!["asset-registry".into()],
4908 cloud_domain: None,
4909 };
4910 let s = toml::to_string(&cfg).unwrap();
4911 let back: LegacyMirrorConfig = toml::from_str(&s).unwrap();
4912 assert_eq!(back.camp, cfg.camp);
4913 assert_eq!(back.regions, cfg.regions);
4914 assert_eq!(back.workloads, cfg.workloads);
4915 }
4916
4917 #[test]
4918 fn mirror_serialises_as_camp_key() {
4919 // Serialised form should use `camp`, not `rig`.
4920 let cfg = LegacyMirrorConfig {
4921 camp: "noisetable".into(),
4922 regions: vec!["pdx".into()],
4923 workloads: vec![],
4924 cloud_domain: None,
4925 };
4926 let s = toml::to_string(&cfg).unwrap();
4927 assert!(
4928 s.contains("camp = "),
4929 "serialised key should be 'camp': {s}"
4930 );
4931 assert!(!s.contains("rig = "), "old key should not appear: {s}");
4932 }
4933
4934 #[test]
4935 fn mirror_rig_alias_still_loads() {
4936 // Old mirrors/*.toml files use `rig = "..."` before the R137 rename;
4937 // the alias keeps them loading until the one-time `sed` migration runs.
4938 let toml_str =
4939 "rig = \"noisetable\"\nregions = [\"pdx\"]\nworkloads = [\"asset-registry\"]\n";
4940 let cfg: LegacyMirrorConfig = toml::from_str(toml_str).unwrap();
4941 assert_eq!(cfg.camp, "noisetable");
4942 }
4943
4944 #[test]
4945 fn mirror_services_alias_still_loads() {
4946 // Old mirrors/*.toml files use `services = [...]`; the alias keeps them
4947 // loading without a migration step.
4948 let toml_str =
4949 "camp = \"noisetable\"\nregions = [\"pdx\"]\nservices = [\"asset-registry\"]\n";
4950 let cfg: LegacyMirrorConfig = toml::from_str(toml_str).unwrap();
4951 assert_eq!(cfg.workloads, vec!["asset-registry"]);
4952 }
4953
4954 #[test]
4955 fn round_trip_service_legacy() {
4956 let cfg = LegacyServiceConfig {
4957 name: "asset-registry".into(),
4958 image: "ghcr.io/noisetable/asset-registry".into(),
4959 version: "v1.0.0".into(),
4960 env: HashMap::new(),
4961 ports: vec![PortMapping {
4962 host: 8080,
4963 container: 8080,
4964 }],
4965 mesh_only: false,
4966 bind_interface: None,
4967 tenant: TenantId::singleton(),
4968 };
4969 let s = toml::to_string(&cfg).unwrap();
4970 let back: LegacyServiceConfig = toml::from_str(&s).unwrap();
4971 assert_eq!(back.name, cfg.name);
4972 assert_eq!(back.image, cfg.image);
4973 }
4974
4975 #[test]
4976 fn service_bind_interface_round_trips() {
4977 let cfg = LegacyServiceConfig {
4978 name: "postgres".into(),
4979 image: "postgres".into(),
4980 version: "16".into(),
4981 env: HashMap::new(),
4982 ports: vec![PortMapping {
4983 host: 5432,
4984 container: 5432,
4985 }],
4986 mesh_only: true,
4987 bind_interface: Some("tailscale0".into()),
4988 tenant: TenantId::singleton(),
4989 };
4990 let s = toml::to_string(&cfg).unwrap();
4991 let back: LegacyServiceConfig = toml::from_str(&s).unwrap();
4992 assert_eq!(back.bind_interface.as_deref(), Some("tailscale0"));
4993 }
4994
4995 #[test]
4996 fn service_bind_interface_absent_is_none() {
4997 let toml_str = "name = \"app\"\nimage = \"app\"\nversion = \"v1\"\n";
4998 let cfg: LegacyServiceConfig = toml::from_str(toml_str).unwrap();
4999 assert!(
5000 cfg.bind_interface.is_none(),
5001 "bind_interface should default to None"
5002 );
5003 }
5004
5005 #[test]
5006 fn service_bind_interface_skipped_when_none() {
5007 let cfg = LegacyServiceConfig {
5008 name: "app".into(),
5009 image: "app".into(),
5010 version: "v1".into(),
5011 env: HashMap::new(),
5012 ports: vec![],
5013 mesh_only: false,
5014 bind_interface: None,
5015 tenant: TenantId::singleton(),
5016 };
5017 let s = toml::to_string(&cfg).unwrap();
5018 assert!(!s.contains("bind_interface"), "None should be skipped: {s}");
5019 }
5020
5021 #[test]
5022 fn load_dir_missing_is_empty() {
5023 let dir = std::path::PathBuf::from("/nonexistent/path");
5024 let result: Vec<MachineConfig> = load_dir(dir).unwrap();
5025 assert!(result.is_empty());
5026 }
5027
5028 #[test]
5029 fn topology_round_trip() {
5030 let topo = TopologyConfig {
5031 assignments: vec![
5032 MirrorAssignment {
5033 mirror: "noisetable-pdx".into(),
5034 machine: "noisetable-pdx-1".into(),
5035 },
5036 MirrorAssignment {
5037 mirror: "noisetable-iad".into(),
5038 machine: "noisetable-iad-1".into(),
5039 },
5040 ],
5041 buckets: vec![],
5042 };
5043 let s = toml::to_string(&topo).unwrap();
5044 let back: TopologyConfig = toml::from_str(&s).unwrap();
5045 assert_eq!(back.assignments.len(), 2);
5046 assert_eq!(back.assignments[0].mirror, "noisetable-pdx");
5047 assert_eq!(back.assignments[1].machine, "noisetable-iad-1");
5048 }
5049
5050 #[test]
5051 fn topology_absent_returns_default() {
5052 let tmp = tempfile::TempDir::new().unwrap();
5053 let path = tmp.path().join("topology.toml");
5054 // file doesn't exist
5055 let topo = load_topology(path).unwrap();
5056 assert!(topo.assignments.is_empty());
5057 }
5058
5059 /// Helper: lay out a `<workspace_root>/.yah/cloud/` legacy tree for the
5060 /// pre-R215 cargo tests below; returns the legacy cloud_dir for writes.
5061 fn make_legacy_cloud_dir(root: &std::path::Path) -> std::path::PathBuf {
5062 let cloud_dir = root.join(".yah").join("cloud");
5063 std::fs::create_dir_all(&cloud_dir).unwrap();
5064 cloud_dir
5065 }
5066
5067 #[test]
5068 fn cloud_config_load_and_lookup() {
5069 let tmp = tempfile::TempDir::new().unwrap();
5070 let root = tmp.path();
5071 let cloud_dir = make_legacy_cloud_dir(root);
5072
5073 let machine = MachineConfig {
5074 name: "noisetable-pdx-1".into(),
5075 provider: "hetzner".into(),
5076 location: Some("pdx".into()),
5077 server_type: Some("cpx22".into()),
5078 hosts_mirrors: vec!["noisetable".into(), "yah".into()],
5079 mesh_tags: vec!["region:pdx".into(), "tier:t2".into()],
5080 region: None,
5081 zone: None,
5082 arch: None,
5083 bucket: Some(BucketSpec {
5084 name: "noisetable-assets-pdx-1".into(),
5085 public_read: false,
5086 }),
5087 vendor: None,
5088 nickname: None,
5089 legacy_hostkey_fingerprint: None,
5090 registration: Default::default(),
5091 ssh_keys: vec![],
5092 cloudflared: None,
5093 hosts_operator_bridge: false,
5094 connect: None,
5095 allocatable: None,
5096 taints: vec![],
5097 sovereign_group: None,
5098 sovereign_role: None,
5099 };
5100 // Land in the legacy tree so the legacy machine loader picks it up.
5101 machine.save(&cloud_dir).unwrap();
5102
5103 let mirror_toml = "camp = \"noisetable\"\nregions = [\"pdx\", \"iad\", \"fsn\"]\nworkloads = [\"asset-registry\"]\n";
5104 std::fs::create_dir_all(cloud_dir.join("mirrors")).unwrap();
5105 std::fs::write(cloud_dir.join("mirrors/noisetable.toml"), mirror_toml).unwrap();
5106
5107 // Legacy services/ dir (backward compat)
5108 let svc_toml = "name = \"asset-registry\"\nimage = \"ghcr.io/noisetable/asset-registry\"\nversion = \"v1.0.0\"\nmesh_only = false\n";
5109 std::fs::create_dir_all(cloud_dir.join("services")).unwrap();
5110 std::fs::write(cloud_dir.join("services/asset-registry.toml"), svc_toml).unwrap();
5111
5112 let cfg = CloudConfig::load(root).unwrap();
5113
5114 assert_eq!(cfg.machines.len(), 1);
5115 assert_eq!(cfg.legacy_mirrors.len(), 1);
5116 assert_eq!(cfg.legacy_services.len(), 1);
5117 assert_eq!(cfg.workloads.len(), 0); // no workloads/ dir yet
5118 assert!(cfg.services.is_empty(), "no R215+ services/ tree");
5119 assert!(cfg.providers.is_empty(), "no R215+ providers/ tree");
5120
5121 let m = cfg.machine("noisetable-pdx-1").unwrap();
5122 assert_eq!(m.location(), "pdx");
5123 assert_eq!(m.bucket.as_ref().unwrap().name, "noisetable-assets-pdx-1");
5124
5125 let mir = cfg.legacy_mirror("noisetable").unwrap();
5126 assert_eq!(mir.regions, vec!["pdx", "iad", "fsn"]);
5127 assert_eq!(mir.workloads, vec!["asset-registry"]);
5128 }
5129
5130 #[test]
5131 fn mirror_folder_layout_loads() {
5132 // Folder layout: mirrors/<id>/mirror.toml — new preferred form.
5133 let tmp = tempfile::TempDir::new().unwrap();
5134 let root = tmp.path();
5135 let cloud_dir = make_legacy_cloud_dir(root);
5136 let mirror_dir = cloud_dir.join("mirrors").join("yah-com");
5137 std::fs::create_dir_all(&mirror_dir).unwrap();
5138 std::fs::write(
5139 mirror_dir.join("mirror.toml"),
5140 "camp = \"yah\"\nregions = [\"pdx\"]\nworkloads = [\"yah-web\"]\n",
5141 )
5142 .unwrap();
5143
5144 let cfg = CloudConfig::load(root).unwrap();
5145 assert_eq!(cfg.legacy_mirrors.len(), 1);
5146 let mir = cfg.legacy_mirror("yah").unwrap();
5147 assert_eq!(mir.camp, "yah");
5148 assert_eq!(mir.workloads, vec!["yah-web"]);
5149 }
5150
5151 #[test]
5152 fn mirror_folder_and_flat_coexist() {
5153 // Both layouts may coexist in the same mirrors/ directory.
5154 let tmp = tempfile::TempDir::new().unwrap();
5155 let root = tmp.path();
5156 let cloud_dir = make_legacy_cloud_dir(root);
5157 let mirrors_root = cloud_dir.join("mirrors");
5158 std::fs::create_dir_all(&mirrors_root).unwrap();
5159
5160 // Flat legacy mirror
5161 std::fs::write(
5162 mirrors_root.join("noisetable.toml"),
5163 "camp = \"noisetable\"\nregions = [\"pdx\"]\nworkloads = []\n",
5164 )
5165 .unwrap();
5166
5167 // Folder-form mirror
5168 let yah_com_dir = mirrors_root.join("yah-com");
5169 std::fs::create_dir_all(&yah_com_dir).unwrap();
5170 std::fs::write(
5171 yah_com_dir.join("mirror.toml"),
5172 "camp = \"yah\"\nregions = [\"pdx\"]\nworkloads = []\n",
5173 )
5174 .unwrap();
5175
5176 let cfg = CloudConfig::load(root).unwrap();
5177 assert_eq!(cfg.legacy_mirrors.len(), 2);
5178 assert!(cfg.legacy_mirror("noisetable").is_some());
5179 assert!(cfg.legacy_mirror("yah").is_some());
5180 }
5181
5182 #[test]
5183 fn mirror_malformed_fails_with_field_path() {
5184 // A malformed mirror.toml should fail at load with a clear error
5185 // that includes the file path.
5186 let tmp = tempfile::TempDir::new().unwrap();
5187 let root = tmp.path();
5188 let cloud_dir = make_legacy_cloud_dir(root);
5189 let mirror_dir = cloud_dir.join("mirrors").join("bad");
5190 std::fs::create_dir_all(&mirror_dir).unwrap();
5191 // Missing required `camp` field
5192 std::fs::write(
5193 mirror_dir.join("mirror.toml"),
5194 "regions = [\"pdx\"]\nworkloads = []\n",
5195 )
5196 .unwrap();
5197
5198 let err = CloudConfig::load(root).unwrap_err();
5199 let msg = err.to_string();
5200 assert!(
5201 msg.contains("mirror.toml"),
5202 "error should reference the file path, got: {msg}"
5203 );
5204 }
5205
5206 #[test]
5207 fn workload_config_load_and_validate() {
5208 use workload_spec::{
5209 ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
5210 RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
5211 };
5212
5213 let tmp = tempfile::TempDir::new().unwrap();
5214 let root = tmp.path();
5215 let cloud_dir = make_legacy_cloud_dir(root);
5216 std::fs::create_dir_all(cloud_dir.join("workloads")).unwrap();
5217
5218 let spec = WorkloadSpec {
5219 schema_version: SchemaVersion::V1,
5220 name: "asset-registry".into(),
5221 image: ImageRef {
5222 registry: "ghcr.io".into(),
5223 repository: "noisetable/asset-registry".into(),
5224 tag: "v1.0.0".into(),
5225 digest: workload_spec::testing::test_digest(),
5226 },
5227 tier: TierTag("tenant".into()),
5228 replicas: 1,
5229 command: None,
5230 entrypoint: None,
5231 workdir: None,
5232 user: None,
5233 env: vec![],
5234 secrets: vec![],
5235 volumes: vec![],
5236 resources: ResourceLimits {
5237 memory_mb: 256,
5238 cpu_millis: 512,
5239 ephemeral_storage_mb: 512,
5240 },
5241 depends_on: vec![],
5242 healthcheck: None,
5243 restart_policy: RestartPolicy::Always,
5244 archetype: None,
5245 stop_policy: StopPolicy {
5246 signal: 15,
5247 grace_period: workload_spec::Millis::from_secs(10),
5248 },
5249 expose: ExposeSpec {
5250 mesh: MeshExpose {
5251 identity: MeshIdent("asset-registry.pdx".into()),
5252 ports: vec![8080],
5253 allow_from: vec![],
5254 },
5255 public: None,
5256 operator: None,
5257 },
5258 tenant: TenantId::singleton(),
5259 namespace: NamespaceId::singleton(),
5260 labels: Default::default(),
5261 annotations: Default::default(),
5262 };
5263
5264 let toml_str = toml::to_string_pretty(&spec).unwrap();
5265 std::fs::write(cloud_dir.join("workloads/asset-registry.toml"), &toml_str).unwrap();
5266
5267 let cfg = CloudConfig::load(root).unwrap();
5268 assert_eq!(cfg.workloads.len(), 1);
5269 assert_eq!(cfg.workloads[0].spec.name, "asset-registry");
5270 assert_eq!(cfg.workload("asset-registry").unwrap().spec.replicas, 1);
5271 }
5272
5273 /// Minimal valid spec for the R215+ loader tests below. Kept as a helper so
5274 /// the two tests differ only in *where* the file lands, which is the whole
5275 /// thing under test.
5276 #[cfg(test)]
5277 fn minimal_spec(name: &str, replicas: u32) -> workload_spec::WorkloadSpec {
5278 use workload_spec::{
5279 ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
5280 RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
5281 };
5282 WorkloadSpec {
5283 schema_version: SchemaVersion::V1,
5284 name: name.into(),
5285 image: ImageRef {
5286 registry: "cr.yah.dev".into(),
5287 repository: name.into(),
5288 tag: "v1".into(),
5289 digest: workload_spec::testing::test_digest(),
5290 },
5291 tier: TierTag("infra".into()),
5292 replicas,
5293 command: None,
5294 entrypoint: None,
5295 workdir: None,
5296 user: None,
5297 env: vec![],
5298 secrets: vec![],
5299 volumes: vec![],
5300 resources: ResourceLimits {
5301 memory_mb: 256,
5302 cpu_millis: 250,
5303 ephemeral_storage_mb: 128,
5304 },
5305 depends_on: vec![],
5306 healthcheck: None,
5307 restart_policy: RestartPolicy::Always,
5308 archetype: None,
5309 stop_policy: StopPolicy {
5310 signal: 15,
5311 grace_period: workload_spec::Millis::from_secs(10),
5312 },
5313 expose: ExposeSpec {
5314 mesh: MeshExpose {
5315 identity: MeshIdent(name.into()),
5316 ports: vec![4325],
5317 allow_from: vec![],
5318 },
5319 public: None,
5320 operator: None,
5321 },
5322 tenant: TenantId::singleton(),
5323 namespace: NamespaceId::singleton(),
5324 labels: Default::default(),
5325 annotations: Default::default(),
5326 }
5327 }
5328
5329 /// R568-T7. Workloads must load from the R215+ tree.
5330 ///
5331 /// Before the fix this function tested, `CloudConfig::load` read workloads
5332 /// ONLY from the pre-R215 `.yah/cloud/workloads/` — which R222-B1 emptied —
5333 /// so in any modern camp `cfg.workload(name)` returned `None` for every
5334 /// name and the entire `yah cloud workload …` surface was unreachable. The
5335 /// CLI's own error text has said `.yah/infra/workloads/` throughout, so the
5336 /// bug read as "you must have typoed the filename".
5337 ///
5338 /// Note the fixture writes NO legacy `.yah/cloud/` dir at all: that is the
5339 /// shape of a real post-R215 camp, and it is exactly the shape the old code
5340 /// could not serve.
5341 #[test]
5342 fn workloads_load_from_the_infra_tree() {
5343 let tmp = tempfile::TempDir::new().unwrap();
5344 let root = tmp.path();
5345 let dir = crate::paths::workloads_dir(root);
5346 std::fs::create_dir_all(&dir).unwrap();
5347 std::fs::write(
5348 dir.join("yah-cloud-admin.toml"),
5349 toml::to_string_pretty(&minimal_spec("yah-cloud-admin", 1)).unwrap(),
5350 )
5351 .unwrap();
5352
5353 let cfg = CloudConfig::load(root).unwrap();
5354 assert_eq!(cfg.workloads.len(), 1);
5355 assert_eq!(
5356 cfg.workload("yah-cloud-admin").unwrap().spec.replicas,
5357 1,
5358 "a workload declared under .yah/infra/workloads/ must be resolvable by name"
5359 );
5360 }
5361
5362 /// A camp mid-migration can have both trees. R215+ wins on a name
5363 /// collision — same precedence the machine loader applies — so moving a
5364 /// declaration into `.yah/infra/workloads/` takes effect immediately
5365 /// instead of being silently shadowed by the copy left behind.
5366 #[test]
5367 fn infra_workload_shadows_the_legacy_copy_of_the_same_name() {
5368 let tmp = tempfile::TempDir::new().unwrap();
5369 let root = tmp.path();
5370
5371 let legacy = make_legacy_cloud_dir(root);
5372 std::fs::create_dir_all(legacy.join("workloads")).unwrap();
5373 std::fs::write(
5374 legacy.join("workloads/shared.toml"),
5375 toml::to_string_pretty(&minimal_spec("shared", 9)).unwrap(),
5376 )
5377 .unwrap();
5378 // Legacy-only name, to prove the old tree is still read rather than
5379 // replaced wholesale.
5380 std::fs::write(
5381 legacy.join("workloads/legacy-only.toml"),
5382 toml::to_string_pretty(&minimal_spec("legacy-only", 3)).unwrap(),
5383 )
5384 .unwrap();
5385
5386 let infra = crate::paths::workloads_dir(root);
5387 std::fs::create_dir_all(&infra).unwrap();
5388 std::fs::write(
5389 infra.join("shared.toml"),
5390 toml::to_string_pretty(&minimal_spec("shared", 1)).unwrap(),
5391 )
5392 .unwrap();
5393
5394 let cfg = CloudConfig::load(root).unwrap();
5395 assert_eq!(cfg.workloads.len(), 2, "one `shared`, plus `legacy-only`");
5396 assert_eq!(
5397 cfg.workload("shared").unwrap().spec.replicas,
5398 1,
5399 "the .yah/infra/ copy must win over the legacy one"
5400 );
5401 assert_eq!(cfg.workload("legacy-only").unwrap().spec.replicas, 3);
5402 }
5403
5404 #[test]
5405 fn workload_loader_rejects_bad_spec() {
5406 use workload_spec::{
5407 ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
5408 RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
5409 };
5410
5411 let tmp = tempfile::TempDir::new().unwrap();
5412 let root = tmp.path();
5413 let cloud_dir = make_legacy_cloud_dir(root);
5414 std::fs::create_dir_all(cloud_dir.join("workloads")).unwrap();
5415
5416 // Construct a spec that round-trips through TOML but fails shape
5417 // validation: replicas = 200 is above the max of 100.
5418 let mut spec = WorkloadSpec {
5419 schema_version: SchemaVersion::V1,
5420 name: "asset-registry".into(),
5421 image: ImageRef {
5422 registry: "ghcr.io".into(),
5423 repository: "test/app".into(),
5424 tag: "v1".into(),
5425 digest: workload_spec::testing::test_digest(),
5426 },
5427 tier: TierTag("tenant".into()),
5428 replicas: 200, // ← invalid: exceeds max 100
5429 command: None,
5430 entrypoint: None,
5431 workdir: None,
5432 user: None,
5433 env: vec![],
5434 secrets: vec![],
5435 volumes: vec![],
5436 resources: ResourceLimits {
5437 memory_mb: 256,
5438 cpu_millis: 512,
5439 ephemeral_storage_mb: 512,
5440 },
5441 depends_on: vec![],
5442 healthcheck: None,
5443 restart_policy: RestartPolicy::Always,
5444 archetype: None,
5445 stop_policy: StopPolicy {
5446 signal: 15,
5447 grace_period: workload_spec::Millis::from_secs(10),
5448 },
5449 expose: ExposeSpec {
5450 mesh: MeshExpose {
5451 identity: MeshIdent("asset-registry.pdx".into()),
5452 ports: vec![8080],
5453 allow_from: vec![],
5454 },
5455 public: None,
5456 operator: None,
5457 },
5458 tenant: TenantId::singleton(),
5459 namespace: NamespaceId::singleton(),
5460 labels: Default::default(),
5461 annotations: Default::default(),
5462 };
5463
5464 let toml_str = toml::to_string_pretty(&spec).unwrap();
5465 std::fs::write(cloud_dir.join("workloads/bad.toml"), &toml_str).unwrap();
5466
5467 let result = CloudConfig::load(root);
5468 assert!(
5469 result.is_err(),
5470 "loading a WorkloadSpec with replicas=200 should return Err"
5471 );
5472 let msg = result.unwrap_err().to_string();
5473 assert!(
5474 msg.contains("shape validation")
5475 || msg.contains("Replicas")
5476 || msg.contains("replicas"),
5477 "error should mention shape validation or replicas field, got: {msg}"
5478 );
5479
5480 // The `spec` binding is only used for the write — suppress warning.
5481 let _ = &mut spec;
5482 }
5483
5484 #[test]
5485 fn workload_config_save_round_trip() {
5486 use workload_spec::{
5487 ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
5488 RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
5489 };
5490
5491 let tmp = tempfile::TempDir::new().unwrap();
5492 let root = tmp.path();
5493
5494 let spec = WorkloadSpec {
5495 schema_version: SchemaVersion::V1,
5496 name: "signing-service".into(),
5497 image: ImageRef {
5498 registry: "ghcr.io".into(),
5499 repository: "noisetable/signing".into(),
5500 tag: "v2.0.0".into(),
5501 digest: workload_spec::testing::test_digest(),
5502 },
5503 tier: TierTag("private".into()),
5504 replicas: 2,
5505 command: None,
5506 entrypoint: None,
5507 workdir: None,
5508 user: None,
5509 env: vec![],
5510 secrets: vec![],
5511 volumes: vec![],
5512 resources: ResourceLimits {
5513 memory_mb: 128,
5514 cpu_millis: 256,
5515 ephemeral_storage_mb: 256,
5516 },
5517 depends_on: vec![],
5518 healthcheck: None,
5519 restart_policy: RestartPolicy::Always,
5520 archetype: None,
5521 stop_policy: StopPolicy {
5522 signal: 15,
5523 grace_period: workload_spec::Millis::from_secs(5),
5524 },
5525 expose: ExposeSpec {
5526 mesh: MeshExpose {
5527 identity: MeshIdent("signing.pdx".into()),
5528 ports: vec![9090],
5529 allow_from: vec![],
5530 },
5531 public: None,
5532 operator: None,
5533 },
5534 tenant: TenantId::singleton(),
5535 namespace: NamespaceId::singleton(),
5536 labels: Default::default(),
5537 annotations: Default::default(),
5538 };
5539
5540 let wc = WorkloadConfig { spec };
5541 let cloud_dir = make_legacy_cloud_dir(root);
5542 wc.save(&cloud_dir).unwrap();
5543
5544 let loaded = CloudConfig::load(root).unwrap();
5545 assert_eq!(loaded.workloads.len(), 1);
5546 assert_eq!(loaded.workloads[0].spec.name, "signing-service");
5547 assert_eq!(loaded.workloads[0].spec.replicas, 2);
5548 }
5549
5550 #[test]
5551 fn machine_save_write_back_fingerprint() {
5552 let tmp = tempfile::TempDir::new().unwrap();
5553 let root = tmp.path();
5554
5555 let mut machine = MachineConfig {
5556 name: "test-pdx-1".into(),
5557 provider: "hetzner".into(),
5558 location: Some("pdx".into()),
5559 server_type: Some("cpx22".into()),
5560 hosts_mirrors: vec![],
5561 mesh_tags: vec![],
5562 region: None,
5563 zone: None,
5564 arch: None,
5565 bucket: None,
5566 vendor: None,
5567 nickname: None,
5568 legacy_hostkey_fingerprint: None,
5569 registration: Default::default(),
5570 ssh_keys: vec![],
5571 cloudflared: None,
5572 hosts_operator_bridge: false,
5573 connect: None,
5574 allocatable: None,
5575 taints: vec![],
5576 sovereign_group: None,
5577 sovereign_role: None,
5578 };
5579 machine.save(root).unwrap();
5580
5581 // Simulate A4: write back the hostkey fingerprint after provision.
5582 // R707-T1: registration is the write target; the accessor is the read.
5583 machine.registration.hostkey_fingerprint = Some("SHA256:abc123".into());
5584 machine.save(root).unwrap();
5585
5586 let reloaded: Vec<MachineConfig> = load_dir(root.join("machines")).unwrap();
5587 assert_eq!(reloaded.len(), 1);
5588 assert_eq!(reloaded[0].hostkey_fingerprint(), Some("SHA256:abc123"));
5589 }
5590
5591 // ─── New-shape (R222 B2) parse tests ────────────────────────────────────
5592 //
5593 // These mirror the Phase-A manifests committed under `.yah/services/` and
5594 // `.yah/infra/providers/`. Keeping the test strings inline (rather than
5595 // reading the on-disk files) so the loader stays runnable in any workdir
5596 // and so accidental edits to the on-disk files don't silently change
5597 // schema expectations.
5598
5599 #[test]
5600 fn provider_cloudflare_round_trips() {
5601 let src = r#"
5602schema_version = 1
5603id = "cloudflare"
5604kind = "cloudflare"
5605credentials = "keystore://cloudflare/yah"
5606default_zone = "yah.dev"
5607"#;
5608 let cfg: ProviderConfig = toml::from_str(src).unwrap();
5609 assert_eq!(cfg.id, "cloudflare");
5610 assert_eq!(cfg.kind, Provider::Cloudflare);
5611 assert_eq!(
5612 cfg.credentials.as_deref(),
5613 Some("keystore://cloudflare/yah")
5614 );
5615 assert_eq!(
5616 cfg.fields.get("default_zone").and_then(|v| v.as_str()),
5617 Some("yah.dev"),
5618 );
5619 let back = toml::to_string(&cfg).unwrap();
5620 let again: ProviderConfig = toml::from_str(&back).unwrap();
5621 assert_eq!(again.id, cfg.id);
5622 assert_eq!(again.kind, cfg.kind);
5623 }
5624
5625 #[test]
5626 fn provider_hetzner_round_trips() {
5627 let src = r#"
5628schema_version = 1
5629id = "hetzner"
5630kind = "hetzner"
5631credentials = "keystore://hetzner/yah"
5632default_location = "pdx"
5633default_server_type = "cpx11"
5634ssh_keys = []
5635"#;
5636 let cfg: ProviderConfig = toml::from_str(src).unwrap();
5637 assert_eq!(cfg.kind, Provider::Hetzner);
5638 assert_eq!(
5639 cfg.fields.get("default_location").and_then(|v| v.as_str()),
5640 Some("pdx"),
5641 );
5642 assert!(
5643 cfg.fields
5644 .get("ssh_keys")
5645 .map(|v| v.as_array().unwrap().is_empty())
5646 .unwrap_or(false),
5647 "ssh_keys must round-trip as empty array, got {:?}",
5648 cfg.fields.get("ssh_keys"),
5649 );
5650 }
5651
5652 #[test]
5653 fn provider_orbstack_local_container_round_trips() {
5654 let src = r#"
5655schema_version = 1
5656id = "orbstack"
5657kind = "local-container"
5658runtime = "auto"
5659
5660[discovery]
5661orbstack = "~/.orbstack/run/docker.sock"
5662colima = "~/.colima/default/docker.sock"
5663docker = "/var/run/docker.sock"
5664"#;
5665 let cfg: ProviderConfig = toml::from_str(src).unwrap();
5666 assert_eq!(cfg.kind, Provider::LocalContainer);
5667 assert_eq!(
5668 cfg.fields.get("runtime").and_then(|v| v.as_str()),
5669 Some("auto"),
5670 );
5671 let discovery = cfg
5672 .fields
5673 .get("discovery")
5674 .and_then(|v| v.as_table())
5675 .expect("discovery table");
5676 assert!(discovery.contains_key("orbstack"));
5677 assert!(discovery.contains_key("colima"));
5678 assert!(discovery.contains_key("docker"));
5679 }
5680
5681 #[test]
5682 fn provider_unknown_kind_fails() {
5683 let src = r#"
5684schema_version = 1
5685id = "made-up"
5686kind = "fly-io"
5687"#;
5688 let err = toml::from_str::<ProviderConfig>(src).unwrap_err();
5689 let msg = err.to_string();
5690 assert!(
5691 msg.contains("kind") || msg.contains("variant"),
5692 "unknown provider kind should surface as a serde error, got: {msg}"
5693 );
5694 }
5695
5696 #[test]
5697 fn service_dev_yah_round_trips() {
5698 let src = r#"
5699schema_version = 1
5700name = "dev-yah"
5701domain = "yah.dev"
5702
5703[[components]]
5704id = "site"
5705kind = "mesofact-static"
5706path = "app/yah/web"
5707role = "static"
5708"#;
5709 let cfg: ServiceConfig = toml::from_str(src).unwrap();
5710 assert_eq!(cfg.name, "dev-yah");
5711 assert_eq!(cfg.domain, "yah.dev");
5712 assert_eq!(cfg.components.len(), 1);
5713 let c = &cfg.components[0];
5714 assert_eq!(c.id, "site");
5715 assert_eq!(c.kind, "mesofact-static");
5716 assert_eq!(c.path, "app/yah/web");
5717 assert_eq!(c.role, "static");
5718 assert!(c.publishes.is_none());
5719
5720 let back = toml::to_string(&cfg).unwrap();
5721 let again: ServiceConfig = toml::from_str(&back).unwrap();
5722 assert_eq!(again.name, cfg.name);
5723 assert_eq!(again.components[0].kind, c.kind);
5724 }
5725
5726 #[test]
5727 fn mirror_prod_cloudflare_reference_parses() {
5728 let src = r#"
5729schema_version = 1
5730shape = "single-machine"
5731
5732[providers.static]
5733use = "cloudflare"
5734bucket = "yah-dev"
5735zone = "yah.dev"
5736dns = { record = "@", type = "CNAME" }
5737"#;
5738 let cfg: MirrorConfig = toml::from_str(src).unwrap();
5739 assert_eq!(cfg.shape, MirrorShape::SingleMachine);
5740 let slot = cfg.providers.get("static").expect("static slot");
5741 assert_eq!(slot.provider_id(), Some("cloudflare"));
5742 assert!(slot.inline_kind().is_none());
5743 if let MirrorProviderSlot::Reference { fields, .. } = slot {
5744 assert_eq!(
5745 fields.get("bucket").and_then(|v| v.as_str()),
5746 Some("yah-dev")
5747 );
5748 assert_eq!(fields.get("zone").and_then(|v| v.as_str()), Some("yah.dev"));
5749 let dns = fields
5750 .get("dns")
5751 .and_then(|v| v.as_table())
5752 .expect("dns table");
5753 assert_eq!(dns.get("record").and_then(|v| v.as_str()), Some("@"));
5754 assert_eq!(dns.get("type").and_then(|v| v.as_str()), Some("CNAME"));
5755 } else {
5756 panic!("expected Reference slot");
5757 }
5758 }
5759
5760 #[test]
5761 fn mirror_local_inline_static_and_orbstack_compute_parse() {
5762 let src = r#"
5763schema_version = 1
5764shape = "local"
5765
5766[providers.static]
5767kind = "local-static"
5768port = 4321
5769artifact_dir = ".yah/infra/state/local/static"
5770
5771[providers.compute]
5772use = "orbstack"
5773"#;
5774 let cfg: MirrorConfig = toml::from_str(src).unwrap();
5775 assert_eq!(cfg.shape, MirrorShape::Local);
5776
5777 let static_slot = cfg.providers.get("static").expect("static slot");
5778 assert_eq!(static_slot.inline_kind(), Some(Provider::LocalStatic));
5779 assert!(static_slot.provider_id().is_none());
5780 if let MirrorProviderSlot::Inline { fields, .. } = static_slot {
5781 assert_eq!(fields.get("port").and_then(|v| v.as_integer()), Some(4321));
5782 assert_eq!(
5783 fields.get("artifact_dir").and_then(|v| v.as_str()),
5784 Some(".yah/infra/state/local/static"),
5785 );
5786 } else {
5787 panic!("expected Inline slot for static");
5788 }
5789
5790 let compute_slot = cfg.providers.get("compute").expect("compute slot");
5791 assert_eq!(compute_slot.provider_id(), Some("orbstack"));
5792 }
5793
5794 #[test]
5795 fn mirror_pond_miniflare_minio_parse() {
5796 // pond-tier mirror: miniflare-container + minio, both inline.
5797 // T1 just needs these inline kinds to parse — the reconciler dispatch
5798 // arrives in R256-T3.
5799 let src = r#"
5800schema_version = 1
5801shape = "local"
5802
5803[providers.static]
5804kind = "miniflare-container"
5805port = 4322
5806bucket = "yah-dev"
5807
5808[providers.object_store]
5809kind = "minio-container"
5810api_port = 9000
5811console_port = 9001
5812bucket = "yah-dev"
5813"#;
5814 let cfg: MirrorConfig = toml::from_str(src).unwrap();
5815 assert_eq!(cfg.shape, MirrorShape::Local);
5816
5817 let static_slot = cfg.providers.get("static").expect("static slot");
5818 assert_eq!(
5819 static_slot.inline_kind(),
5820 Some(Provider::MiniflareContainer)
5821 );
5822 if let MirrorProviderSlot::Inline { fields, .. } = static_slot {
5823 assert_eq!(fields.get("port").and_then(|v| v.as_integer()), Some(4322));
5824 assert_eq!(
5825 fields.get("bucket").and_then(|v| v.as_str()),
5826 Some("yah-dev")
5827 );
5828 } else {
5829 panic!("expected Inline slot for miniflare-container static");
5830 }
5831
5832 let object_store_slot = cfg
5833 .providers
5834 .get("object_store")
5835 .expect("object_store slot");
5836 assert_eq!(
5837 object_store_slot.inline_kind(),
5838 Some(Provider::MinioContainer)
5839 );
5840 if let MirrorProviderSlot::Inline { fields, .. } = object_store_slot {
5841 assert_eq!(
5842 fields.get("api_port").and_then(|v| v.as_integer()),
5843 Some(9000)
5844 );
5845 assert_eq!(
5846 fields.get("console_port").and_then(|v| v.as_integer()),
5847 Some(9001)
5848 );
5849 assert_eq!(
5850 fields.get("bucket").and_then(|v| v.as_str()),
5851 Some("yah-dev")
5852 );
5853 } else {
5854 panic!("expected Inline slot for minio-container object_store");
5855 }
5856 }
5857
5858 #[test]
5859 fn provider_miniflare_container_kind_round_trips() {
5860 // Inline-only kind; never declared as a standalone provider file but
5861 // the enum round-trip is still exercised through ProviderConfig because
5862 // schemars/serde share the variant table.
5863 let cfg = MirrorProviderSlot::Inline {
5864 kind: Provider::MiniflareContainer,
5865 fields: BTreeMap::new(),
5866 };
5867 let s = toml::to_string(&cfg).unwrap();
5868 assert!(
5869 s.contains("kind = \"miniflare-container\""),
5870 "kebab-case wire form expected, got: {s}"
5871 );
5872 let back: MirrorProviderSlot = toml::from_str(&s).unwrap();
5873 assert_eq!(back.inline_kind(), Some(Provider::MiniflareContainer));
5874 }
5875
5876 #[test]
5877 fn provider_minio_container_kind_round_trips() {
5878 let cfg = MirrorProviderSlot::Inline {
5879 kind: Provider::MinioContainer,
5880 fields: BTreeMap::new(),
5881 };
5882 let s = toml::to_string(&cfg).unwrap();
5883 assert!(
5884 s.contains("kind = \"minio-container\""),
5885 "kebab-case wire form expected, got: {s}"
5886 );
5887 let back: MirrorProviderSlot = toml::from_str(&s).unwrap();
5888 assert_eq!(back.inline_kind(), Some(Provider::MinioContainer));
5889 }
5890
5891 #[test]
5892 fn mirror_compute_slot_with_machine_reference_parses() {
5893 // The on-disk prod.toml has a commented-out compute slot; this test
5894 // covers the form Phase B will need once yubaba is provisioned.
5895 let src = r#"
5896schema_version = 1
5897shape = "single-machine"
5898
5899[providers.compute]
5900use = "hetzner"
5901machine = "yah-cloud-1"
5902"#;
5903 let cfg: MirrorConfig = toml::from_str(src).unwrap();
5904 let slot = cfg.providers.get("compute").expect("compute slot");
5905 assert_eq!(slot.provider_id(), Some("hetzner"));
5906 if let MirrorProviderSlot::Reference { fields, .. } = slot {
5907 assert_eq!(
5908 fields.get("machine").and_then(|v| v.as_str()),
5909 Some("yah-cloud-1"),
5910 );
5911 }
5912 }
5913
5914 #[test]
5915 fn machine_yah_cloud_1_round_trips_with_existing_shape() {
5916 // The current machine TOML predates B2 — MachineConfig hasn't been
5917 // reshaped yet. This locks the expected shape so we notice if B3
5918 // accidentally regresses it.
5919 let src = r#"
5920name = "yah-cloud-1"
5921provider = "hetzner"
5922location = "pdx"
5923server_type = "cpx11"
5924hosts_mirrors = []
5925mesh_tags = ["tag:tier-scratch", "tag:primary-yah"]
5926ssh_keys = [111513970, 111525493]
5927"#;
5928 let cfg: MachineConfig = toml::from_str(src).unwrap();
5929 assert_eq!(cfg.name, "yah-cloud-1");
5930 assert_eq!(cfg.provider, "hetzner");
5931 assert_eq!(cfg.ssh_keys.len(), 2);
5932 }
5933
5934 #[test]
5935 fn static_node_omits_location_server_type_and_carries_connect() {
5936 // BYO Phase-0: a `static` node we brought up over SSH has no provider
5937 // DC code or SKU; it declares reach in `[connect]` instead. Must load.
5938 let src = r#"
5939name = "us-south-001"
5940provider = "static"
5941region = "us-south"
5942mesh_tags = ["tag:cloud-runner", "tag:voter-candidate"]
5943
5944[connect]
5945address = "45.32.194.254"
5946ssh = "root@45.32.194.254"
5947yubaba = "http://127.0.0.1:7443"
5948arch = "x86_64"
5949"#;
5950 let cfg: MachineConfig = toml::from_str(src).unwrap();
5951 assert_eq!(cfg.provider, "static");
5952 assert!(cfg.location.is_none());
5953 assert!(cfg.server_type.is_none());
5954 assert_eq!(cfg.location(), ""); // accessor defaults empty
5955 let c = cfg.connect.as_ref().expect("connect block");
5956 assert_eq!(c.ssh, "root@45.32.194.254");
5957 // Loopback is a *declared* reach placeholder, so it stays in [connect]
5958 // verbatim and composes straight through (R707-T1).
5959 assert_eq!(c.yubaba.as_deref(), Some("http://127.0.0.1:7443"));
5960 assert_eq!(cfg.yubaba_url().as_deref(), Some("http://127.0.0.1:7443"));
5961 assert_eq!(cfg.mesh_ipv4(), None);
5962 // Static providers have no driver, so validate() is a no-op pass.
5963 assert!(!provider_has_machine_driver(&cfg.provider));
5964 cfg.validate().unwrap();
5965 }
5966
5967 // ─── R707-T1: declaration / registration split ──────────────────────────
5968
5969 /// The pre-split shape — top-level `hostkey_fingerprint`, mesh IP baked
5970 /// into `[connect].yubaba` — must keep parsing, and must read back through
5971 /// the accessors identically. Every machine TOML in the fleet was written
5972 /// this way, and other camps' inventories still are.
5973 #[test]
5974 fn legacy_shape_still_parses_and_reads_through_accessors() {
5975 let src = r#"
5976name = "us-west-001"
5977provider = "static"
5978region = "us-west"
5979arch = "x86_64"
5980mesh_tags = ["tag:cloud-runner"]
5981hostkey_fingerprint = "SHA256:dmpq"
5982
5983[connect]
5984address = "15.204.89.240"
5985ssh = "debian@15.204.89.240"
5986yubaba = "http://100.64.0.1:7443"
5987"#;
5988 let cfg: MachineConfig = toml::from_str(src).unwrap();
5989 assert_eq!(cfg.hostkey_fingerprint(), Some("SHA256:dmpq"));
5990 assert_eq!(cfg.mesh_ipv4(), Some("100.64.0.1"));
5991 assert_eq!(cfg.yubaba_url().as_deref(), Some("http://100.64.0.1:7443"));
5992 }
5993
5994 /// The post-split shape reads identically to the legacy one above — same
5995 /// three accessor answers from a file that separates the two halves. This
5996 /// is the "unchanged in meaning" guarantee the fleet migration rests on.
5997 #[test]
5998 fn split_shape_is_equivalent_to_legacy_shape() {
5999 let legacy = r#"
6000name = "m"
6001provider = "static"
6002mesh_tags = []
6003hostkey_fingerprint = "SHA256:dmpq"
6004
6005[connect]
6006address = "15.204.89.240"
6007ssh = "debian@15.204.89.240"
6008yubaba = "http://100.64.0.1:7443"
6009"#;
6010 let split = r#"
6011name = "m"
6012provider = "static"
6013mesh_tags = []
6014
6015[connect]
6016address = "15.204.89.240"
6017ssh = "debian@15.204.89.240"
6018
6019[registration]
6020hostkey_fingerprint = "SHA256:dmpq"
6021mesh_ipv4 = "100.64.0.1"
6022"#;
6023 let old: MachineConfig = toml::from_str(legacy).unwrap();
6024 let new: MachineConfig = toml::from_str(split).unwrap();
6025 assert_eq!(old.hostkey_fingerprint(), new.hostkey_fingerprint());
6026 assert_eq!(old.mesh_ipv4(), new.mesh_ipv4());
6027 assert_eq!(old.yubaba_url(), new.yubaba_url());
6028 }
6029
6030 /// A non-default `[connect].yubaba_port` is declared reach and composes
6031 /// with the observed mesh address rather than being pinned into a URL.
6032 #[test]
6033 fn declared_port_composes_with_observed_mesh_address() {
6034 let src = r#"
6035name = "m"
6036provider = "static"
6037mesh_tags = []
6038
6039[connect]
6040address = "10.0.0.1"
6041ssh = "yah@10.0.0.1"
6042yubaba_port = 9443
6043
6044[registration]
6045mesh_ipv4 = "100.64.0.9"
6046"#;
6047 let cfg: MachineConfig = toml::from_str(src).unwrap();
6048 assert_eq!(cfg.connect.as_ref().unwrap().yubaba_port(), 9443);
6049 assert_eq!(cfg.yubaba_url().as_deref(), Some("http://100.64.0.9:9443"));
6050 }
6051
6052 /// R605-T10 inverts R707-T6 for the private-literal case, and this is the
6053 /// node it was inverted for: us-west-014's shape, mesh-joined AND declaring
6054 /// a LAN `[connect].yubaba`. R707-T6 made the literal win outright so
6055 /// `rollout::yubaba::membership_to_nodes` could match the dev group's
6056 /// LAN-addressed raft membership — which fused identity into reach and made
6057 /// every automated dial go to an address only bldg-2506 can route.
6058 /// `lan_endpoint()` now serves that match, so the mesh address wins the
6059 /// dial and the literal is inert.
6060 #[test]
6061 fn a_private_literal_loses_to_the_registered_mesh_address() {
6062 let src = r#"
6063name = "us-west-014"
6064provider = "static"
6065mesh_tags = []
6066
6067[connect]
6068address = "192.168.10.14"
6069ssh = "yah@192.168.10.14"
6070yubaba = "http://192.168.10.14:7443"
6071
6072[registration]
6073mesh_ipv4 = "100.64.0.6"
6074"#;
6075 let cfg: MachineConfig = toml::from_str(src).unwrap();
6076 assert_eq!(cfg.mesh_ipv4(), Some("100.64.0.6"), "still mesh-joined");
6077 assert_eq!(
6078 cfg.yubaba_url().as_deref(),
6079 Some("http://100.64.0.6:7443"),
6080 "automation dials the mesh, never the LAN literal"
6081 );
6082 assert_eq!(
6083 cfg.lan_endpoint().as_deref(),
6084 Some("192.168.10.14:7443"),
6085 "the LAN address is still recorded — as identity, not as reach"
6086 );
6087 }
6088
6089 /// The refusal R605-T10 asks for: a node whose ONLY declared reach is a LAN
6090 /// literal is unresolvable, and says so by name rather than returning a URL
6091 /// that will time out. us-west-011's shape before this ticket.
6092 #[test]
6093 fn a_lan_only_node_refuses_with_a_named_reason() {
6094 let src = r#"
6095name = "us-west-011"
6096provider = "static"
6097mesh_tags = []
6098
6099[connect]
6100address = "192.168.10.11"
6101ssh = "yah@192.168.10.11"
6102yubaba = "http://192.168.10.11:7443"
6103"#;
6104 let cfg: MachineConfig = toml::from_str(src).unwrap();
6105 assert_eq!(cfg.yubaba_url(), None);
6106 let err = cfg.reach().unwrap_err();
6107 assert!(err.contains("us-west-011"), "{err}");
6108 assert!(err.contains("192.168.10.11"), "{err}");
6109 assert!(err.contains("mesh_ipv4"), "{err}");
6110 }
6111
6112 /// The loopback placeholder is a genuine declaration ("reach me through the
6113 /// SSH tunnel"), not a LAN literal — 127/8 is not RFC1918. It must keep
6114 /// resolving verbatim; `hub::coordinator::is_loopback_url` is what judges it
6115 /// downstream.
6116 #[test]
6117 fn a_loopback_placeholder_still_resolves_verbatim() {
6118 let src = r#"
6119name = "m"
6120provider = "static"
6121mesh_tags = []
6122
6123[connect]
6124address = "192.168.10.99"
6125ssh = "yah@192.168.10.99"
6126yubaba = "http://127.0.0.1:7443"
6127"#;
6128 let cfg: MachineConfig = toml::from_str(src).unwrap();
6129 assert_eq!(cfg.yubaba_url().as_deref(), Some("http://127.0.0.1:7443"));
6130 }
6131
6132 #[test]
6133 fn private_ranges_are_exactly_rfc1918() {
6134 for lan in [
6135 "http://192.168.10.11:7443",
6136 "http://10.0.0.5:7443",
6137 "http://172.16.4.1:7443",
6138 ] {
6139 assert!(private_ipv4_from_url(lan).is_some(), "{lan}");
6140 }
6141 for not_lan in [
6142 "http://100.64.0.6:7443", // mesh
6143 "http://127.0.0.1:7443", // loopback
6144 "http://172.32.0.1:7443", // just past 172.16/12
6145 "http://45.32.194.254:80", // public
6146 "http://us-west-001:7443", // name, not a literal
6147 ] {
6148 assert!(private_ipv4_from_url(not_lan).is_none(), "{not_lan}");
6149 }
6150 }
6151
6152 /// `normalize` migrates in place: the legacy fingerprint moves into
6153 /// `[registration]`, the mesh IP is lifted out of the URL, and the derived
6154 /// `[connect].yubaba` is cleared so the two halves cannot drift.
6155 #[test]
6156 fn normalize_migrates_legacy_fields_and_is_idempotent() {
6157 let src = r#"
6158name = "m"
6159provider = "static"
6160mesh_tags = []
6161hostkey_fingerprint = "SHA256:dmpq"
6162
6163[connect]
6164address = "15.204.89.240"
6165ssh = "debian@15.204.89.240"
6166yubaba = "http://100.64.0.1:7443"
6167"#;
6168 let mut cfg: MachineConfig = toml::from_str(src).unwrap();
6169 cfg.normalize();
6170 assert!(cfg.legacy_hostkey_fingerprint.is_none());
6171 assert_eq!(
6172 cfg.registration.hostkey_fingerprint.as_deref(),
6173 Some("SHA256:dmpq")
6174 );
6175 assert_eq!(cfg.registration.mesh_ipv4.as_deref(), Some("100.64.0.1"));
6176 assert!(cfg.connect.as_ref().unwrap().yubaba.is_none());
6177 // Accessors still answer the same, and re-running changes nothing.
6178 assert_eq!(cfg.yubaba_url().as_deref(), Some("http://100.64.0.1:7443"));
6179 let once = format!("{cfg:?}");
6180 cfg.normalize();
6181 assert_eq!(once, format!("{cfg:?}"));
6182 }
6183
6184 /// A loopback `[connect].yubaba` is a declaration ("no mesh address yet —
6185 /// reach me through the SSH tunnel"), not a stale observation, so
6186 /// `normalize` must leave it alone. us-west-003/011/013 depend on this.
6187 #[test]
6188 fn normalize_leaves_pre_mesh_loopback_declaration_intact() {
6189 let src = r#"
6190name = "m"
6191provider = "static"
6192mesh_tags = []
6193
6194[connect]
6195address = "192.168.10.11"
6196ssh = "yah@192.168.10.11"
6197yubaba = "http://127.0.0.1:7443"
6198"#;
6199 let mut cfg: MachineConfig = toml::from_str(src).unwrap();
6200 cfg.normalize();
6201 assert_eq!(
6202 cfg.connect.as_ref().unwrap().yubaba.as_deref(),
6203 Some("http://127.0.0.1:7443")
6204 );
6205 assert!(cfg.registration.is_empty());
6206 assert_eq!(cfg.mesh_ipv4(), None);
6207 }
6208
6209 /// `save` normalizes, so a legacy file that round-trips through the writer
6210 /// comes back on the split shape with nothing lost — the property that
6211 /// keeps `yah cloud machine attach` from re-emitting the old layout.
6212 #[test]
6213 fn save_writes_the_split_shape_from_a_legacy_config() {
6214 let tmp = tempfile::TempDir::new().unwrap();
6215 let root = tmp.path();
6216 let src = r#"
6217name = "m"
6218provider = "static"
6219mesh_tags = []
6220hostkey_fingerprint = "SHA256:dmpq"
6221
6222[connect]
6223address = "15.204.89.240"
6224ssh = "debian@15.204.89.240"
6225yubaba = "http://100.64.0.1:7443"
6226"#;
6227 let cfg: MachineConfig = toml::from_str(src).unwrap();
6228 cfg.save(root).unwrap();
6229
6230 let written = std::fs::read_to_string(root.join("machines/m.toml")).unwrap();
6231 let reg_at = written
6232 .find("[registration]")
6233 .unwrap_or_else(|| panic!("no [registration] table: {written}"));
6234 let fp_at = written
6235 .find("hostkey_fingerprint")
6236 .unwrap_or_else(|| panic!("fingerprint dropped: {written}"));
6237 assert!(
6238 fp_at > reg_at,
6239 "legacy top-level field must not be re-emitted: {written}"
6240 );
6241 assert!(
6242 !written.contains("yubaba ="),
6243 "derived URL must not be re-emitted alongside mesh_ipv4: {written}"
6244 );
6245
6246 let reloaded: MachineConfig = toml::from_str(&written).unwrap();
6247 assert_eq!(reloaded.hostkey_fingerprint(), Some("SHA256:dmpq"));
6248 assert_eq!(
6249 reloaded.yubaba_url().as_deref(),
6250 Some("http://100.64.0.1:7443")
6251 );
6252 }
6253
6254 /// `[registration]` is omitted entirely for a machine nothing has been
6255 /// observed about — a scaffolded declaration stays clean.
6256 #[test]
6257 fn empty_registration_is_omitted_on_serialize() {
6258 let src = r#"
6259name = "m"
6260provider = "static"
6261mesh_tags = []
6262"#;
6263 let cfg: MachineConfig = toml::from_str(src).unwrap();
6264 assert!(cfg.registration.is_empty());
6265 let out = toml::to_string_pretty(&cfg).unwrap();
6266 assert!(!out.contains("[registration]"), "{out}");
6267 }
6268
6269 #[test]
6270 fn driver_provider_without_location_fails_validate() {
6271 // A driver-backed provider (hetzner/vultr) still MUST carry location +
6272 // server_type — the driver can't create a server without them. The
6273 // contract moved from load-time (required field) to provision-time
6274 // (validate), so the TOML loads but validate() rejects it.
6275 let src = r#"
6276name = "us-west-001"
6277provider = "hetzner"
6278mesh_tags = []
6279"#;
6280 let cfg: MachineConfig = toml::from_str(src).unwrap();
6281 assert!(provider_has_machine_driver(&cfg.provider));
6282 let err = cfg.validate().unwrap_err().to_string();
6283 assert!(
6284 err.contains("location"),
6285 "expected location complaint: {err}"
6286 );
6287 }
6288
6289 /// Helper for the new-tree integration tests below: lay out
6290 /// `<workspace>/.yah/{infra,services}/` with `dev-yah` + its mirrors and
6291 /// the three Phase-A providers (cloudflare, hetzner, orbstack).
6292 fn make_new_tree_with_dev_yah(root: &std::path::Path) {
6293 let infra = root.join(".yah").join("infra");
6294 let providers = infra.join("providers");
6295 std::fs::create_dir_all(&providers).unwrap();
6296 std::fs::write(
6297 providers.join("cloudflare.toml"),
6298 r#"schema_version = 1
6299id = "cloudflare"
6300kind = "cloudflare"
6301credentials = "keystore://cloudflare/yah"
6302default_zone = "yah.dev"
6303"#,
6304 )
6305 .unwrap();
6306 std::fs::write(
6307 providers.join("hetzner.toml"),
6308 r#"schema_version = 1
6309id = "hetzner"
6310kind = "hetzner"
6311credentials = "keystore://hetzner/yah"
6312default_location = "pdx"
6313default_server_type = "cpx11"
6314ssh_keys = []
6315"#,
6316 )
6317 .unwrap();
6318 std::fs::write(
6319 providers.join("orbstack.toml"),
6320 r#"schema_version = 1
6321id = "orbstack"
6322kind = "local-container"
6323runtime = "auto"
6324
6325[discovery]
6326orbstack = "~/.orbstack/run/docker.sock"
6327"#,
6328 )
6329 .unwrap();
6330
6331 let svc = root.join(".yah").join("services").join("dev-yah");
6332 std::fs::create_dir_all(svc.join("mirrors")).unwrap();
6333 std::fs::write(
6334 svc.join("service.toml"),
6335 r#"schema_version = 1
6336name = "dev-yah"
6337domain = "yah.dev"
6338
6339[[components]]
6340id = "site"
6341kind = "mesofact-static"
6342path = "app/yah/web"
6343role = "static"
6344"#,
6345 )
6346 .unwrap();
6347 std::fs::write(
6348 svc.join("mirrors/prod.toml"),
6349 r#"schema_version = 1
6350shape = "single-machine"
6351
6352[providers.static]
6353use = "cloudflare"
6354bucket = "yah-dev"
6355zone = "yah.dev"
6356"#,
6357 )
6358 .unwrap();
6359 std::fs::write(
6360 svc.join("mirrors/local.toml"),
6361 r#"schema_version = 1
6362shape = "local"
6363
6364[providers.static]
6365kind = "local-static"
6366port = 4321
6367
6368[providers.compute]
6369use = "orbstack"
6370"#,
6371 )
6372 .unwrap();
6373 }
6374
6375 #[test]
6376 fn cloud_config_load_new_tree_populates_providers_and_services() {
6377 let tmp = tempfile::TempDir::new().unwrap();
6378 let root = tmp.path();
6379 make_new_tree_with_dev_yah(root);
6380
6381 let cfg = CloudConfig::load(root).unwrap();
6382 assert_eq!(cfg.providers.len(), 3, "three providers loaded");
6383 assert!(cfg.provider("cloudflare").is_some());
6384 assert!(cfg.provider("hetzner").is_some());
6385 assert!(cfg.provider("orbstack").is_some());
6386
6387 let dev = cfg.service("dev-yah").expect("dev-yah service");
6388 assert_eq!(dev.service.domain, "yah.dev");
6389 assert_eq!(dev.service.components.len(), 1);
6390 assert_eq!(dev.mirrors.len(), 2);
6391 // Legacy file stems "prod" and "local" are normalised to canonical tier names.
6392 assert!(dev.mirrors.contains_key("cloud"), "prod.toml → cloud tier");
6393 assert!(dev.mirrors.contains_key("dev"), "local.toml → dev tier");
6394 assert_eq!(dev.mirrors["cloud"].shape, MirrorShape::SingleMachine);
6395 assert_eq!(dev.mirrors["dev"].shape, MirrorShape::Local);
6396
6397 // Legacy fields stay empty when no .yah/cloud/ exists.
6398 assert!(cfg.legacy_mirrors.is_empty());
6399 assert!(cfg.legacy_services.is_empty());
6400 assert!(cfg.workloads.is_empty());
6401 }
6402
6403 #[test]
6404 fn cloud_config_cross_ref_fails_on_missing_provider() {
6405 // Mirror references a provider id that doesn't exist.
6406 let tmp = tempfile::TempDir::new().unwrap();
6407 let root = tmp.path();
6408 let svc = root.join(".yah").join("services").join("dev-yah");
6409 std::fs::create_dir_all(svc.join("mirrors")).unwrap();
6410 std::fs::write(
6411 svc.join("service.toml"),
6412 "schema_version = 1\nname = \"dev-yah\"\ndomain = \"yah.dev\"\n",
6413 )
6414 .unwrap();
6415 std::fs::write(
6416 svc.join("mirrors/prod.toml"),
6417 "schema_version = 1\nshape = \"single-machine\"\n\n[providers.static]\nuse = \"fly-io\"\n",
6418 ).unwrap();
6419
6420 let err = CloudConfig::load(root).unwrap_err();
6421 let msg = err.to_string();
6422 assert!(
6423 msg.contains("fly-io"),
6424 "error should name the missing provider id, got: {msg}"
6425 );
6426 assert!(
6427 msg.contains("providers/fly-io.toml") || msg.contains("no such provider"),
6428 "error should hint at remedy, got: {msg}"
6429 );
6430 }
6431
6432 #[test]
6433 fn cloud_config_cross_ref_fails_on_missing_provider_named_by_an_ingress_edge() {
6434 // R845: the edge's own `use` is a provider reference like any other, so
6435 // a typo has to fail here rather than at the Cloudflare arm of apply.
6436 let tmp = tempfile::TempDir::new().unwrap();
6437 let root = tmp.path();
6438 let svc = root.join(".yah").join("services").join("dev-yah");
6439 std::fs::create_dir_all(svc.join("mirrors")).unwrap();
6440 std::fs::write(
6441 svc.join("service.toml"),
6442 "schema_version = 1\nname = \"dev-yah\"\ndomain = \"yah.dev\"\n",
6443 )
6444 .unwrap();
6445 std::fs::write(
6446 svc.join("mirrors/prod.toml"),
6447 "schema_version = 1\nshape = \"single-machine\"\n\n\
6448 [providers.compute]\nkind = \"static\"\nmachine = \"borrowed-01\"\n\
6449 zone = \"a.yah.dev\"\nport = 8080\n\n\
6450 [[ingress]]\nprovider = \"cloudflare-tunnel\"\nuse = \"cloudflar\"\n",
6451 )
6452 .unwrap();
6453
6454 let msg = CloudConfig::load(root).unwrap_err().to_string();
6455 assert!(
6456 msg.contains("ingress[0].use") && msg.contains("cloudflar"),
6457 "error should name the edge and the typo'd id, got: {msg}"
6458 );
6459 }
6460
6461 #[test]
6462 fn cloud_config_cross_ref_passes_on_inline_only_mirror() {
6463 // Inline `kind = "local-static"` doesn't require an infra provider.
6464 let tmp = tempfile::TempDir::new().unwrap();
6465 let root = tmp.path();
6466 let svc = root.join(".yah").join("services").join("local-only");
6467 std::fs::create_dir_all(svc.join("mirrors")).unwrap();
6468 std::fs::write(
6469 svc.join("service.toml"),
6470 "schema_version = 1\nname = \"local-only\"\ndomain = \"local.test\"\n",
6471 )
6472 .unwrap();
6473 std::fs::write(
6474 svc.join("mirrors/local.toml"),
6475 "schema_version = 1\nshape = \"local\"\n\n[providers.static]\nkind = \"local-static\"\nport = 8080\n",
6476 ).unwrap();
6477
6478 // Should load fine: no `use=` references, no providers required.
6479 let cfg = CloudConfig::load(root).unwrap();
6480 assert!(cfg.service("local-only").is_some());
6481 }
6482
6483 #[test]
6484 fn cloud_config_load_coexists_legacy_and_new_trees() {
6485 // Both trees present — both fields populated independently.
6486 let tmp = tempfile::TempDir::new().unwrap();
6487 let root = tmp.path();
6488 make_new_tree_with_dev_yah(root);
6489
6490 let cloud_dir = make_legacy_cloud_dir(root);
6491 std::fs::create_dir_all(cloud_dir.join("mirrors")).unwrap();
6492 std::fs::write(
6493 cloud_dir.join("mirrors/noisetable.toml"),
6494 "camp = \"noisetable\"\nregions = [\"pdx\"]\nworkloads = []\n",
6495 )
6496 .unwrap();
6497
6498 let cfg = CloudConfig::load(root).unwrap();
6499 assert_eq!(cfg.providers.len(), 3);
6500 assert!(cfg.service("dev-yah").is_some());
6501 assert_eq!(cfg.legacy_mirrors.len(), 1);
6502 assert!(cfg.legacy_mirror("noisetable").is_some());
6503 }
6504
6505 #[test]
6506 fn web_workload_round_trips() {
6507 // app/yah/web/workload.toml is parsed as a WorkloadSpec via the
6508 // workload-spec crate. The minimum-viable manifest here exercises
6509 // schema_version + kind + build fields.
6510 //
6511 // The on-disk file uses the abbreviated v1 form (kind + build); the
6512 // full WorkloadSpec is verbose, so this test asserts the new
6513 // mesofact-static abbreviated form parses as raw TOML (B3 will plumb
6514 // it through WorkloadSpec proper).
6515 // `routes` above [build] — it is a top-level field, and TOML would
6516 // scope it into that table if written below the header (R658-B1).
6517 let src = r#"
6518schema_version = 1
6519kind = "mesofact-static"
6520
6521routes = "./routes.ts"
6522
6523[build]
6524command = "bun run build"
6525out_dir = "dist"
6526"#;
6527 let v: toml::Value = toml::from_str(src).unwrap();
6528 assert_eq!(
6529 v.get("schema_version").and_then(|x| x.as_integer()),
6530 Some(1)
6531 );
6532 assert_eq!(
6533 v.get("kind").and_then(|x| x.as_str()),
6534 Some("mesofact-static")
6535 );
6536 let build = v
6537 .get("build")
6538 .and_then(|x| x.as_table())
6539 .expect("build table");
6540 assert_eq!(
6541 build.get("command").and_then(|x| x.as_str()),
6542 Some("bun run build")
6543 );
6544 assert_eq!(build.get("out_dir").and_then(|x| x.as_str()), Some("dist"));
6545 }
6546
6547 // ─── Canonical CRUD: ServiceConfig/MirrorConfig save + delete (R323-F1) ──
6548
6549 #[test]
6550 fn service_config_save_creates_canonical_toml_and_round_trips() {
6551 let tmp = tempfile::TempDir::new().unwrap();
6552 let root = tmp.path();
6553
6554 let svc = ServiceConfig {
6555 schema_version: 1,
6556 name: "dev-yah".into(),
6557 domain: "yah.dev".into(),
6558 db: DbCatalog::default(),
6559 components: vec![ServiceComponent {
6560 mount: None,
6561 id: "site".into(),
6562 kind: "mesofact-static".into(),
6563 path: "app/yah/web".into(),
6564 role: "static".into(),
6565 publishes: Some("static".into()),
6566 wave: 0,
6567 git: None,
6568 }],
6569 };
6570 svc.save(root).unwrap();
6571
6572 // Landed at the canonical path.
6573 let path = crate::paths::service_toml(root, "dev-yah");
6574 assert!(
6575 path.exists(),
6576 "service.toml should exist at {}",
6577 path.display()
6578 );
6579
6580 // Reloads through the full CloudConfig loader (no mirrors yet).
6581 let cfg = CloudConfig::load(root).unwrap();
6582 let loaded = cfg.service("dev-yah").expect("dev-yah service");
6583 assert_eq!(loaded.service.domain, "yah.dev");
6584 assert_eq!(loaded.service.components.len(), 1);
6585 assert_eq!(
6586 loaded.service.components[0].publishes.as_deref(),
6587 Some("static")
6588 );
6589 assert!(loaded.mirrors.is_empty());
6590 }
6591
6592 #[test]
6593 fn service_config_save_overwrites_in_place() {
6594 let tmp = tempfile::TempDir::new().unwrap();
6595 let root = tmp.path();
6596
6597 let mut svc = ServiceConfig {
6598 schema_version: 1,
6599 name: "dev-yah".into(),
6600 domain: "yah.dev".into(),
6601 components: vec![],
6602 db: DbCatalog::default(),
6603 };
6604 svc.save(root).unwrap();
6605 svc.domain = "yah.example".into();
6606 svc.save(root).unwrap();
6607
6608 let cfg = CloudConfig::load(root).unwrap();
6609 assert_eq!(
6610 cfg.service("dev-yah").unwrap().service.domain,
6611 "yah.example"
6612 );
6613 }
6614
6615 #[test]
6616 fn mirror_config_save_round_trips_reference_and_inline_slots() {
6617 let tmp = tempfile::TempDir::new().unwrap();
6618 let root = tmp.path();
6619
6620 // A service must exist so the loader walks the mirrors/ dir.
6621 ServiceConfig {
6622 schema_version: 1,
6623 name: "dev-yah".into(),
6624 domain: "yah.dev".into(),
6625 components: vec![],
6626 db: DbCatalog::default(),
6627 }
6628 .save(root)
6629 .unwrap();
6630
6631 // The cloudflare provider the reference slot points at must resolve,
6632 // or CloudConfig::load's cross-ref check rejects the tree.
6633 let providers = crate::paths::providers_dir(root);
6634 std::fs::create_dir_all(&providers).unwrap();
6635 std::fs::write(
6636 providers.join("cloudflare.toml"),
6637 "schema_version = 1\nid = \"cloudflare\"\nkind = \"cloudflare\"\n",
6638 )
6639 .unwrap();
6640
6641 let mut providers_map = BTreeMap::new();
6642 providers_map.insert(
6643 "static".to_string(),
6644 MirrorProviderSlot::Reference {
6645 provider_id: "cloudflare".into(),
6646 fields: {
6647 let mut f = BTreeMap::new();
6648 f.insert("bucket".to_string(), toml::Value::String("yah-dev".into()));
6649 f
6650 },
6651 },
6652 );
6653 providers_map.insert(
6654 "compute".to_string(),
6655 MirrorProviderSlot::Inline {
6656 kind: Provider::LocalStatic,
6657 fields: {
6658 let mut f = BTreeMap::new();
6659 f.insert("port".to_string(), toml::Value::Integer(4321));
6660 f
6661 },
6662 },
6663 );
6664 let mirror = MirrorConfig {
6665 schema_version: 1,
6666 shape: MirrorShape::SingleMachine,
6667 providers: providers_map,
6668 ingress: Default::default(),
6669 ingress_machines: Vec::new(),
6670 drivers: Default::default(),
6671 asset_aliases: Default::default(),
6672 };
6673 // Save with canonical name; legacy "prod" is normalised to "cloud" on load.
6674 mirror.save(root, "dev-yah", "cloud").unwrap();
6675
6676 let path = crate::paths::service_mirror_toml(root, "dev-yah", "cloud");
6677 assert!(
6678 path.exists(),
6679 "mirror toml should exist at {}",
6680 path.display()
6681 );
6682
6683 let cfg = CloudConfig::load(root).unwrap();
6684 let loaded = &cfg.service("dev-yah").unwrap().mirrors["cloud"];
6685 assert_eq!(loaded.shape, MirrorShape::SingleMachine);
6686 assert_eq!(loaded.providers["static"].provider_id(), Some("cloudflare"));
6687 assert_eq!(
6688 loaded.providers["compute"].inline_kind(),
6689 Some(Provider::LocalStatic)
6690 );
6691 }
6692
6693 #[test]
6694 fn service_delete_removes_dir_and_mirrors() {
6695 let tmp = tempfile::TempDir::new().unwrap();
6696 let root = tmp.path();
6697
6698 let svc = ServiceConfig {
6699 schema_version: 1,
6700 name: "dev-yah".into(),
6701 domain: "yah.dev".into(),
6702 components: vec![],
6703 db: DbCatalog::default(),
6704 };
6705 svc.save(root).unwrap();
6706 MirrorConfig {
6707 schema_version: 1,
6708 shape: MirrorShape::Local,
6709 providers: BTreeMap::new(),
6710 ingress: Default::default(),
6711 ingress_machines: Vec::new(),
6712 drivers: Default::default(),
6713 asset_aliases: Default::default(),
6714 }
6715 .save(root, "dev-yah", "local")
6716 .unwrap();
6717
6718 assert!(
6719 ServiceConfig::delete(root, "dev-yah").unwrap(),
6720 "first delete reports true"
6721 );
6722 assert!(!crate::paths::service_dir(root, "dev-yah").exists());
6723 // Idempotent: deleting again is a no-op that reports false.
6724 assert!(!ServiceConfig::delete(root, "dev-yah").unwrap());
6725
6726 let cfg = CloudConfig::load(root).unwrap();
6727 assert!(cfg.service("dev-yah").is_none());
6728 }
6729
6730 #[test]
6731 fn mirror_delete_leaves_other_mirrors_and_service_intact() {
6732 let tmp = tempfile::TempDir::new().unwrap();
6733 let root = tmp.path();
6734
6735 ServiceConfig {
6736 schema_version: 1,
6737 name: "dev-yah".into(),
6738 domain: "yah.dev".into(),
6739 components: vec![],
6740 db: DbCatalog::default(),
6741 }
6742 .save(root)
6743 .unwrap();
6744 for env in ["prod", "local"] {
6745 MirrorConfig {
6746 schema_version: 1,
6747 shape: MirrorShape::Local,
6748 providers: BTreeMap::new(),
6749 ingress: Default::default(),
6750 ingress_machines: Vec::new(),
6751 drivers: Default::default(),
6752 asset_aliases: Default::default(),
6753 }
6754 .save(root, "dev-yah", env)
6755 .unwrap();
6756 }
6757
6758 assert!(MirrorConfig::delete(root, "dev-yah", "prod").unwrap());
6759 assert!(!MirrorConfig::delete(root, "dev-yah", "prod").unwrap());
6760
6761 let cfg = CloudConfig::load(root).unwrap();
6762 let svc = cfg
6763 .service("dev-yah")
6764 .expect("service survives mirror delete");
6765 // Legacy file stems are normalised on load: "prod" → "cloud", "local" → "dev".
6766 assert!(!svc.mirrors.contains_key("cloud"));
6767 assert!(svc.mirrors.contains_key("dev"));
6768 }
6769
6770 // ─── DomainConfig (R347-F2) ────────────────────────────────────────────
6771
6772 fn write_marketing_service(root: &Path) {
6773 let svc = ServiceConfig {
6774 schema_version: 1,
6775 name: "yah-marketing".into(),
6776 domain: "yah.dev".into(),
6777 db: DbCatalog::default(),
6778 components: vec![ServiceComponent {
6779 mount: None,
6780 id: "site".into(),
6781 kind: "mesofact-static".into(),
6782 path: "app/yah/web".into(),
6783 role: "static".into(),
6784 publishes: None,
6785 wave: 0,
6786 git: None,
6787 }],
6788 };
6789 svc.save(root).unwrap();
6790 }
6791
6792 #[test]
6793 fn round_trip_domain_with_each_route_mode() {
6794 let dom = DomainConfig {
6795 schema_version: 1,
6796 name: "yah-dev".into(),
6797 domain: "yah.dev".into(),
6798 front_door: FrontDoor::Worker,
6799 cdn_bucket: "yah-dev".into(),
6800 worker_bundle_path: Some(".yah/workers/yah-dev/".into()),
6801 routes: vec![
6802 DomainRoute {
6803 headers: Default::default(),
6804 path: "/".into(),
6805 mode: RouteMode::Static {
6806 component: "yah-marketing/site".into(),
6807 },
6808 },
6809 DomainRoute {
6810 headers: Default::default(),
6811 path: "/dashboard/api/*".into(),
6812 mode: RouteMode::Backend {
6813 component: "yah-dashboard/api".into(),
6814 origin: "https://api.dashboard.yah.dev".into(),
6815 },
6816 },
6817 DomainRoute {
6818 headers: Default::default(),
6819 path: "/old".into(),
6820 mode: RouteMode::Redirect {
6821 target: "https://yah.dev/blog".into(),
6822 status: 308,
6823 },
6824 },
6825 ],
6826 };
6827 let s = toml::to_string(&dom).unwrap();
6828 let back: DomainConfig = toml::from_str(&s).unwrap();
6829 assert_eq!(back.name, "yah-dev");
6830 assert_eq!(back.routes.len(), 3);
6831 assert!(matches!(back.routes[0].mode, RouteMode::Static { .. }));
6832 assert!(matches!(back.routes[1].mode, RouteMode::Backend { .. }));
6833 assert!(matches!(back.routes[2].mode, RouteMode::Redirect { .. }));
6834 }
6835
6836 #[test]
6837 fn redirect_status_defaults_to_308() {
6838 let src = r#"
6839schema_version = 1
6840name = "yah-dev"
6841domain = "yah.dev"
6842front_door = "worker"
6843cdn_bucket = "yah-dev"
6844
6845[[routes]]
6846path = "/old"
6847mode = "redirect"
6848target = "https://yah.dev/blog"
6849"#;
6850 let dom: DomainConfig = toml::from_str(src).unwrap();
6851 let RouteMode::Redirect { status, .. } = &dom.routes[0].mode else {
6852 panic!("expected redirect");
6853 };
6854 assert_eq!(*status, 308);
6855 }
6856
6857 #[test]
6858 fn missing_domains_dir_is_empty() {
6859 let tmp = tempfile::TempDir::new().unwrap();
6860 let cfg = CloudConfig::load(tmp.path()).unwrap();
6861 assert!(cfg.domains.is_empty());
6862 }
6863
6864 #[test]
6865 fn save_reload_roundtrip() {
6866 let tmp = tempfile::TempDir::new().unwrap();
6867 let root = tmp.path();
6868 write_marketing_service(root);
6869
6870 let dom = DomainConfig {
6871 schema_version: 1,
6872 name: "yah-dev".into(),
6873 domain: "yah.dev".into(),
6874 front_door: FrontDoor::Worker,
6875 cdn_bucket: "yah-dev".into(),
6876 worker_bundle_path: None,
6877 routes: vec![DomainRoute {
6878 headers: Default::default(),
6879 path: "/".into(),
6880 mode: RouteMode::Static {
6881 component: "yah-marketing/site".into(),
6882 },
6883 }],
6884 };
6885 dom.save(root).unwrap();
6886
6887 let cfg = CloudConfig::load(root).unwrap();
6888 let loaded = cfg.domain("yah-dev").expect("yah-dev domain");
6889 assert_eq!(loaded.domain, "yah.dev");
6890 assert_eq!(loaded.routes.len(), 1);
6891 }
6892
6893 #[test]
6894 fn delete_returns_false_when_absent() {
6895 let tmp = tempfile::TempDir::new().unwrap();
6896 assert!(!DomainConfig::delete(tmp.path(), "no-such-domain").unwrap());
6897 }
6898
6899 #[test]
6900 fn delete_returns_true_first_time() {
6901 let tmp = tempfile::TempDir::new().unwrap();
6902 let root = tmp.path();
6903 let dom = DomainConfig {
6904 schema_version: 1,
6905 name: "yah-dev".into(),
6906 domain: "yah.dev".into(),
6907 front_door: FrontDoor::BucketDirect,
6908 cdn_bucket: "yah-dev".into(),
6909 worker_bundle_path: None,
6910 routes: vec![],
6911 };
6912 dom.save(root).unwrap();
6913 assert!(DomainConfig::delete(root, "yah-dev").unwrap());
6914 assert!(!DomainConfig::delete(root, "yah-dev").unwrap());
6915 }
6916
6917 // ---- R594-F12: front-door discriminator ------------------------------
6918
6919 /// Write a raw domain manifest so the tests exercise the deserialize +
6920 /// validate path, not a hand-built struct that skipped serde.
6921 fn write_domain_toml(root: &Path, stem: &str, body: &str) {
6922 let dir = root.join(".yah").join("domains");
6923 std::fs::create_dir_all(&dir).unwrap();
6924 std::fs::write(dir.join(format!("{stem}.toml")), body).unwrap();
6925 }
6926
6927 #[test]
6928 fn front_door_is_required() {
6929 let tmp = tempfile::TempDir::new().unwrap();
6930 let root = tmp.path();
6931 write_marketing_service(root);
6932 write_domain_toml(
6933 root,
6934 "yah-dev",
6935 r#"
6936schema_version = 1
6937name = "yah-dev"
6938domain = "yah.dev"
6939cdn_bucket = "yah-dev"
6940[[routes]]
6941path = "/*"
6942mode = "static"
6943component = "yah-marketing/site"
6944"#,
6945 );
6946 let err = CloudConfig::load(root).unwrap_err().to_string();
6947 // serde's own missing-field message; the point is that omitting the
6948 // discriminator is not a silently-defaulted state.
6949 assert!(err.contains("yah-dev.toml"), "{err}");
6950 }
6951
6952 #[test]
6953 fn bucket_direct_with_routes_is_rejected() {
6954 let tmp = tempfile::TempDir::new().unwrap();
6955 let root = tmp.path();
6956 write_marketing_service(root);
6957 write_domain_toml(
6958 root,
6959 "cdn-yah-dev",
6960 r#"
6961schema_version = 1
6962name = "cdn-yah-dev"
6963domain = "cdn.yah.dev"
6964front_door = "bucket-direct"
6965cdn_bucket = "yah-dev"
6966[[routes]]
6967path = "/docs/*"
6968mode = "static"
6969component = "yah-marketing/site"
6970"#,
6971 );
6972 let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
6973 assert!(err.contains("front_door"), "{err}");
6974 assert!(err.contains("/docs/*"), "{err}");
6975 }
6976
6977 #[test]
6978 fn bucket_direct_with_worker_bundle_path_is_rejected() {
6979 let tmp = tempfile::TempDir::new().unwrap();
6980 let root = tmp.path();
6981 write_domain_toml(
6982 root,
6983 "cdn-yah-dev",
6984 r#"
6985schema_version = 1
6986name = "cdn-yah-dev"
6987domain = "cdn.yah.dev"
6988front_door = "bucket-direct"
6989cdn_bucket = "yah-dev"
6990worker_bundle_path = ".yah/workers/cdn-yah-dev/"
6991"#,
6992 );
6993 let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
6994 assert!(err.contains("worker_bundle_path"), "{err}");
6995 }
6996
6997 // ── R746: per-route response headers + component mounts ──────────────────
6998
6999 /// A two-component service: `site` at the root, `app` mounted at `/app`
7000 /// with isolation headers on its route. This is the noisetable.com shape
7001 /// the primitive was built for.
7002 fn write_two_component_service(root: &Path) {
7003 let svc = ServiceConfig {
7004 schema_version: 1,
7005 name: "yah-marketing".into(),
7006 domain: "yah.dev".into(),
7007 db: DbCatalog::default(),
7008 components: vec![
7009 ServiceComponent {
7010 mount: None,
7011 id: "site".into(),
7012 kind: "mesofact-static".into(),
7013 path: "app/yah/web".into(),
7014 role: "static".into(),
7015 publishes: None,
7016 wave: 0,
7017 git: None,
7018 },
7019 ServiceComponent {
7020 mount: Some("/app".into()),
7021 id: "app".into(),
7022 kind: "mesofact-static".into(),
7023 path: "app/browser".into(),
7024 role: "static".into(),
7025 publishes: None,
7026 wave: 0,
7027 git: None,
7028 },
7029 ],
7030 };
7031 svc.save(root).unwrap();
7032 }
7033
7034 const MOUNTED_DOMAIN: &str = r#"
7035schema_version = 1
7036name = "yah-dev"
7037domain = "yah.dev"
7038front_door = "worker"
7039cdn_bucket = "yah-dev"
7040
7041[[routes]]
7042path = "/app/*"
7043mode = "static"
7044component = "yah-marketing/app"
7045headers = { "Cross-Origin-Opener-Policy" = "same-origin", "Cross-Origin-Embedder-Policy" = "require-corp" }
7046
7047[[routes]]
7048path = "/*"
7049mode = "static"
7050component = "yah-marketing/site"
7051"#;
7052
7053 #[test]
7054 fn a_mounted_component_routed_at_its_mount_loads() {
7055 let tmp = tempfile::TempDir::new().unwrap();
7056 let root = tmp.path();
7057 write_two_component_service(root);
7058 write_domain_toml(root, "yah-dev", MOUNTED_DOMAIN);
7059 let cfg = CloudConfig::load(root).unwrap();
7060 let dom = cfg.domain("yah-dev").unwrap();
7061 assert_eq!(dom.routes.len(), 2);
7062 assert_eq!(
7063 dom.routes[0].headers.get("Cross-Origin-Opener-Policy").map(String::as_str),
7064 Some("same-origin")
7065 );
7066 assert!(dom.routes[1].headers.is_empty());
7067 }
7068
7069 /// The header table reaches the Worker in MANIFEST order with headerless
7070 /// routes dropped. Order is the whole contract — the front door applies the
7071 /// first match, so `/app/*` before `/*` is what isolates the app without
7072 /// isolating the marketing site.
7073 #[test]
7074 fn route_headers_json_preserves_order_and_drops_headerless_routes() {
7075 let tmp = tempfile::TempDir::new().unwrap();
7076 let root = tmp.path();
7077 write_two_component_service(root);
7078 write_domain_toml(root, "yah-dev", MOUNTED_DOMAIN);
7079 let cfg = CloudConfig::load(root).unwrap();
7080 let json = cfg.domain("yah-dev").unwrap().route_headers_json();
7081
7082 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
7083 let rules = parsed.as_array().unwrap();
7084 assert_eq!(rules.len(), 1, "the headerless catch-all is dropped: {json}");
7085 assert_eq!(rules[0]["path"], "/app/*");
7086 assert_eq!(rules[0]["headers"]["Cross-Origin-Embedder-Policy"], "require-corp");
7087 }
7088
7089 #[test]
7090 fn route_headers_json_is_an_empty_array_when_nothing_declares_headers() {
7091 let tmp = tempfile::TempDir::new().unwrap();
7092 let root = tmp.path();
7093 write_marketing_service(root);
7094 write_domain_toml(
7095 root,
7096 "yah-dev",
7097 r#"
7098schema_version = 1
7099name = "yah-dev"
7100domain = "yah.dev"
7101front_door = "worker"
7102cdn_bucket = "yah-dev"
7103
7104[[routes]]
7105path = "/*"
7106mode = "static"
7107component = "yah-marketing/site"
7108"#,
7109 );
7110 let cfg = CloudConfig::load(root).unwrap();
7111 assert_eq!(cfg.domain("yah-dev").unwrap().route_headers_json(), "[]");
7112 }
7113
7114 /// The reconciler's own entry point: given a workspace root and a service
7115 /// name, produce the binding value. `"[]"` when nothing routes the service.
7116 #[test]
7117 fn route_headers_for_service_reads_the_workspace_domains() {
7118 let tmp = tempfile::TempDir::new().unwrap();
7119 let root = tmp.path();
7120 write_two_component_service(root);
7121 write_domain_toml(root, "yah-dev", MOUNTED_DOMAIN);
7122 assert!(route_headers_for_service(root, "yah-marketing")
7123 .unwrap()
7124 .contains("require-corp"));
7125 assert_eq!(route_headers_for_service(root, "some-other-svc").unwrap(), "[]");
7126 }
7127
7128 // ---- R749-T5: a broken table fails the DEPLOY, not the edge -----------
7129
7130 /// The manifest's `headers` map is hand-written TOML, so a header name with
7131 /// spaces in it is one keystroke away — and it survives serialization into
7132 /// a structurally-valid table that neither front door can apply. Fail at
7133 /// load, naming the domain, the route and the header, instead of shipping a
7134 /// binding the Worker throws on and an origin that refuses to boot.
7135 #[test]
7136 fn a_route_header_name_that_is_not_a_header_name_fails_the_load() {
7137 let tmp = tempfile::TempDir::new().unwrap();
7138 let root = tmp.path();
7139 write_marketing_service(root);
7140 write_domain_toml(
7141 root,
7142 "yah-dev",
7143 r#"
7144schema_version = 1
7145name = "yah-dev"
7146domain = "yah.dev"
7147front_door = "worker"
7148cdn_bucket = "yah-dev"
7149
7150[[routes]]
7151path = "/*"
7152mode = "static"
7153component = "yah-marketing/site"
7154headers = { "Cross Origin Opener Policy" = "same-origin" }
7155"#,
7156 );
7157 let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
7158 assert!(err.contains("yah-dev"), "{err}");
7159 assert!(err.contains("/*"), "{err}");
7160 assert!(err.contains("Cross Origin Opener Policy"), "{err}");
7161 assert!(err.contains("not a valid HTTP header name"), "{err}");
7162 }
7163
7164 /// A newline in a value is header injection if it ever reached the wire, so
7165 /// both doors reject it and so does this.
7166 #[test]
7167 fn a_route_header_value_that_is_not_a_header_value_fails_the_load() {
7168 let tmp = tempfile::TempDir::new().unwrap();
7169 let root = tmp.path();
7170 write_marketing_service(root);
7171 write_domain_toml(
7172 root,
7173 "yah-dev",
7174 r#"
7175schema_version = 1
7176name = "yah-dev"
7177domain = "yah.dev"
7178front_door = "worker"
7179cdn_bucket = "yah-dev"
7180
7181[[routes]]
7182path = "/*"
7183mode = "static"
7184component = "yah-marketing/site"
7185headers = { "X-Frame-Options" = "DENY\nSet-Cookie: pwned=1" }
7186"#,
7187 );
7188 let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
7189 assert!(err.contains("X-Frame-Options"), "{err}");
7190 assert!(err.contains("not a valid HTTP header value"), "{err}");
7191 }
7192
7193 /// The invariant this gate exists to hold: everything `route_headers_json`
7194 /// emits is applicable. A headerless route contributes no rule, so its path
7195 /// is not the table's business — only rules that ship are checked.
7196 #[test]
7197 fn a_headerless_route_is_not_subject_to_the_route_header_gate() {
7198 let tmp = tempfile::TempDir::new().unwrap();
7199 let root = tmp.path();
7200 write_two_component_service(root);
7201 write_domain_toml(root, "yah-dev", MOUNTED_DOMAIN);
7202 let cfg = CloudConfig::load(root).unwrap();
7203 cfg.domain("yah-dev")
7204 .unwrap()
7205 .validate_route_headers()
7206 .unwrap();
7207 }
7208
7209 /// A `bucket-direct` domain has no front door to set headers on, so it must
7210 /// not be picked up as a service's header source.
7211 #[test]
7212 fn route_headers_ignores_domains_that_are_not_route_driven() {
7213 let doms: BTreeMap<String, DomainConfig> = [(
7214 "cdn".to_string(),
7215 DomainConfig {
7216 schema_version: 1,
7217 name: "cdn".into(),
7218 domain: "cdn.yah.dev".into(),
7219 front_door: FrontDoor::BucketDirect,
7220 cdn_bucket: "yah-dev".into(),
7221 worker_bundle_path: None,
7222 routes: vec![],
7223 },
7224 )]
7225 .into_iter()
7226 .collect();
7227 assert!(domain_serving_service(&doms, "yah-marketing").is_none());
7228 }
7229
7230 #[test]
7231 fn a_mount_that_disagrees_with_its_route_path_is_rejected() {
7232 let tmp = tempfile::TempDir::new().unwrap();
7233 let root = tmp.path();
7234 write_two_component_service(root);
7235 write_domain_toml(
7236 root,
7237 "yah-dev",
7238 r#"
7239schema_version = 1
7240name = "yah-dev"
7241domain = "yah.dev"
7242front_door = "worker"
7243cdn_bucket = "yah-dev"
7244
7245[[routes]]
7246path = "/studio/*"
7247mode = "static"
7248component = "yah-marketing/app"
7249"#,
7250 );
7251 let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
7252 assert!(err.contains("mount = \"/app\""), "{err}");
7253 assert!(err.contains("/studio/*"), "{err}");
7254 }
7255
7256 /// The other direction: routing an unmounted component under a sub-path
7257 /// points requests at a prefix nothing published to.
7258 #[test]
7259 fn routing_an_unmounted_component_under_a_subpath_is_rejected() {
7260 let tmp = tempfile::TempDir::new().unwrap();
7261 let root = tmp.path();
7262 write_marketing_service(root);
7263 write_domain_toml(
7264 root,
7265 "yah-dev",
7266 r#"
7267schema_version = 1
7268name = "yah-dev"
7269domain = "yah.dev"
7270front_door = "worker"
7271cdn_bucket = "yah-dev"
7272
7273[[routes]]
7274path = "/docs/*"
7275mode = "static"
7276component = "yah-marketing/site"
7277"#,
7278 );
7279 let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
7280 assert!(err.contains("no `mount`"), "{err}");
7281 assert!(err.contains("/docs"), "{err}");
7282 }
7283
7284 #[test]
7285 fn mount_and_route_prefix_normalization_agree() {
7286 for m in ["/app", "app", "app/", "/app/"] {
7287 assert_eq!(normalize_mount(m), "app", "mount {m:?}");
7288 }
7289 assert_eq!(normalize_mount("/"), "");
7290 assert_eq!(route_path_prefix("/*"), "");
7291 assert_eq!(route_path_prefix("/app/*"), "app");
7292 assert_eq!(route_path_prefix("/app"), "app");
7293 assert_eq!(route_path_prefix("/"), "");
7294 }
7295
7296 #[test]
7297 fn worker_with_no_routes_is_rejected() {
7298 let tmp = tempfile::TempDir::new().unwrap();
7299 let root = tmp.path();
7300 write_domain_toml(
7301 root,
7302 "yah-dev",
7303 r#"
7304schema_version = 1
7305name = "yah-dev"
7306domain = "yah.dev"
7307front_door = "worker"
7308cdn_bucket = "yah-dev"
7309"#,
7310 );
7311 let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
7312 assert!(err.contains("front_door = \"worker\""), "{err}");
7313 assert!(err.contains("404"), "{err}");
7314 }
7315
7316 #[test]
7317 fn passway_with_no_routes_is_rejected_too() {
7318 let tmp = tempfile::TempDir::new().unwrap();
7319 let root = tmp.path();
7320 write_domain_toml(
7321 root,
7322 "yah-dev",
7323 r#"
7324schema_version = 1
7325name = "yah-dev"
7326domain = "yah.dev"
7327front_door = "passway"
7328cdn_bucket = "yah-dev"
7329"#,
7330 );
7331 let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
7332 assert!(err.contains("front_door = \"passway\""), "{err}");
7333 }
7334
7335 #[test]
7336 fn bucket_direct_without_routes_loads() {
7337 let tmp = tempfile::TempDir::new().unwrap();
7338 let root = tmp.path();
7339 // Exactly the shape .yah/domains/cdn-yah-dev.toml ships (W175: a pure
7340 // asset tier deliberately has no Worker behaviours).
7341 write_domain_toml(
7342 root,
7343 "cdn-yah-dev",
7344 r#"
7345schema_version = 1
7346name = "cdn-yah-dev"
7347domain = "cdn.yah.dev"
7348front_door = "bucket-direct"
7349cdn_bucket = "yah-dev"
7350"#,
7351 );
7352 let cfg = CloudConfig::load(root).unwrap();
7353 let dom = cfg.domain("cdn-yah-dev").expect("cdn-yah-dev domain");
7354 assert_eq!(dom.front_door, FrontDoor::BucketDirect);
7355 assert!(!dom.front_door.is_route_driven());
7356 }
7357
7358 #[test]
7359 fn front_door_round_trips_through_save() {
7360 let tmp = tempfile::TempDir::new().unwrap();
7361 let root = tmp.path();
7362 write_marketing_service(root);
7363 let dom = DomainConfig {
7364 schema_version: 1,
7365 name: "yah-dev".into(),
7366 domain: "yah.dev".into(),
7367 front_door: FrontDoor::Passway,
7368 cdn_bucket: "yah-dev".into(),
7369 worker_bundle_path: None,
7370 routes: vec![DomainRoute {
7371 headers: Default::default(),
7372 path: "/*".into(),
7373 mode: RouteMode::Static {
7374 component: "yah-marketing/site".into(),
7375 },
7376 }],
7377 };
7378 dom.save(root).unwrap();
7379 let cfg = CloudConfig::load(root).unwrap();
7380 assert_eq!(
7381 cfg.domain("yah-dev").unwrap().front_door,
7382 FrontDoor::Passway
7383 );
7384 }
7385
7386 // The four manifests this repo actually ships are asserted in
7387 // `tests/live_workspace_smoke.rs` — that's the only place with a
7388 // depth-agnostic path to the live `.yah/` tree and a skip path for the
7389 // standalone mirror checkout.
7390
7391 #[test]
7392 fn cross_ref_bails_on_missing_service() {
7393 let tmp = tempfile::TempDir::new().unwrap();
7394 let root = tmp.path();
7395 // No services declared at all — component ref must fail to resolve.
7396 let dom = DomainConfig {
7397 schema_version: 1,
7398 name: "yah-dev".into(),
7399 domain: "yah.dev".into(),
7400 front_door: FrontDoor::Worker,
7401 cdn_bucket: "yah-dev".into(),
7402 worker_bundle_path: None,
7403 routes: vec![DomainRoute {
7404 headers: Default::default(),
7405 path: "/".into(),
7406 mode: RouteMode::Static {
7407 component: "yah-marketing/site".into(),
7408 },
7409 }],
7410 };
7411 dom.save(root).unwrap();
7412
7413 let err = CloudConfig::load(root).unwrap_err();
7414 let msg = format!("{err:#}");
7415 assert!(msg.contains("no such service"), "got: {msg}");
7416 assert!(msg.contains("yah-marketing"), "got: {msg}");
7417 }
7418
7419 #[test]
7420 fn cross_ref_bails_on_missing_component() {
7421 let tmp = tempfile::TempDir::new().unwrap();
7422 let root = tmp.path();
7423 write_marketing_service(root); // has component id "site", not "elsewhere"
7424
7425 let dom = DomainConfig {
7426 schema_version: 1,
7427 name: "yah-dev".into(),
7428 domain: "yah.dev".into(),
7429 front_door: FrontDoor::Worker,
7430 cdn_bucket: "yah-dev".into(),
7431 worker_bundle_path: None,
7432 routes: vec![DomainRoute {
7433 headers: Default::default(),
7434 path: "/".into(),
7435 mode: RouteMode::Static {
7436 component: "yah-marketing/elsewhere".into(),
7437 },
7438 }],
7439 };
7440 dom.save(root).unwrap();
7441
7442 let err = CloudConfig::load(root).unwrap_err();
7443 let msg = format!("{err:#}");
7444 assert!(msg.contains("no component with id"), "got: {msg}");
7445 assert!(msg.contains("elsewhere"), "got: {msg}");
7446 }
7447
7448 #[test]
7449 fn cross_ref_bails_on_malformed_ref() {
7450 let tmp = tempfile::TempDir::new().unwrap();
7451 let root = tmp.path();
7452 write_marketing_service(root);
7453
7454 let dom = DomainConfig {
7455 schema_version: 1,
7456 name: "yah-dev".into(),
7457 domain: "yah.dev".into(),
7458 front_door: FrontDoor::Worker,
7459 cdn_bucket: "yah-dev".into(),
7460 worker_bundle_path: None,
7461 routes: vec![DomainRoute {
7462 headers: Default::default(),
7463 path: "/".into(),
7464 mode: RouteMode::Static {
7465 component: "no-slash-here".into(),
7466 },
7467 }],
7468 };
7469 dom.save(root).unwrap();
7470
7471 let err = CloudConfig::load(root).unwrap_err();
7472 let msg = format!("{err:#}");
7473 assert!(msg.contains("expected"), "got: {msg}");
7474 }
7475
7476 #[test]
7477 fn redirect_routes_skip_component_validation() {
7478 let tmp = tempfile::TempDir::new().unwrap();
7479 let root = tmp.path();
7480 // No services at all — redirect must still load cleanly because it
7481 // references nothing.
7482 let dom = DomainConfig {
7483 schema_version: 1,
7484 name: "yah-dev".into(),
7485 domain: "yah.dev".into(),
7486 front_door: FrontDoor::Worker,
7487 cdn_bucket: "yah-dev".into(),
7488 worker_bundle_path: None,
7489 routes: vec![DomainRoute {
7490 headers: Default::default(),
7491 path: "/old".into(),
7492 mode: RouteMode::Redirect {
7493 target: "https://yah.dev/blog".into(),
7494 status: 308,
7495 },
7496 }],
7497 };
7498 dom.save(root).unwrap();
7499
7500 let cfg = CloudConfig::load(root).unwrap();
7501 assert!(cfg.domain("yah-dev").is_some());
7502 }
7503
7504 #[test]
7505 fn name_must_match_file_stem() {
7506 let tmp = tempfile::TempDir::new().unwrap();
7507 let root = tmp.path();
7508 // Hand-write a file whose stem disagrees with its `name`.
7509 let dir = root.join(".yah").join("domains");
7510 std::fs::create_dir_all(&dir).unwrap();
7511 std::fs::write(
7512 dir.join("yah-dev.toml"),
7513 r#"schema_version = 1
7514name = "different-name"
7515domain = "yah.dev"
7516front_door = "bucket-direct"
7517cdn_bucket = "yah-dev"
7518"#,
7519 )
7520 .unwrap();
7521
7522 let err = CloudConfig::load(root).unwrap_err();
7523 let msg = format!("{err:#}");
7524 assert!(msg.contains("must match the file stem"), "got: {msg}");
7525 }
7526
7527 #[test]
7528 fn net_alias_tier_subdomain_manifest_loads_and_cross_refs() {
7529 // R561-F2: a per-tenant subdomain manifest on the net.yah.dev wildcard
7530 // alias tier is just a DomainConfig whose `domain` is `<name>.net.yah.dev`
7531 // and whose static route cross-refs the tenant's service component.
7532 // This is exactly the shape .yah/domains/scrabcake-net-yah-dev.toml ships.
7533 let tmp = tempfile::TempDir::new().unwrap();
7534 let root = tmp.path();
7535 write_marketing_service(root); // service "yah-marketing", component "site"
7536
7537 let dom = DomainConfig {
7538 schema_version: 1,
7539 name: "tenant-net-yah-dev".into(),
7540 domain: "tenant.net.yah.dev".into(),
7541 front_door: FrontDoor::Worker,
7542 cdn_bucket: "net-yah-dev".into(), // shared per-tier bucket
7543 worker_bundle_path: None,
7544 routes: vec![DomainRoute {
7545 headers: Default::default(),
7546 path: "/*".into(),
7547 mode: RouteMode::Static {
7548 component: "yah-marketing/site".into(),
7549 },
7550 }],
7551 };
7552 dom.save(root).unwrap();
7553
7554 let cfg = CloudConfig::load(root).unwrap();
7555 let dom = cfg
7556 .domain("tenant-net-yah-dev")
7557 .expect("net-tier subdomain manifest should load");
7558 assert_eq!(dom.domain, "tenant.net.yah.dev");
7559 assert_eq!(dom.cdn_bucket, "net-yah-dev");
7560 }
7561
7562 // ─── R572-F3: NodeAllocatable + taints ──────────────────────────────────
7563
7564 #[test]
7565 fn machine_allocatable_round_trips() {
7566 let toml_src = r#"
7567name = "us-west-001"
7568provider = "static"
7569mesh_tags = ["tag:cloud-runner"]
7570[allocatable]
7571memory_mb = 3800
7572cpu_millis = 2000
7573"#;
7574 let m: MachineConfig = toml::from_str(toml_src).unwrap();
7575 let a = m.allocatable.as_ref().expect("allocatable should parse");
7576 assert_eq!(a.memory_mb, 3800);
7577 assert_eq!(a.cpu_millis, 2000);
7578
7579 let s = toml::to_string(&m).unwrap();
7580 let back: MachineConfig = toml::from_str(&s).unwrap();
7581 let a2 = back.allocatable.as_ref().unwrap();
7582 assert_eq!(a2.memory_mb, 3800);
7583 assert_eq!(a2.cpu_millis, 2000);
7584 }
7585
7586 #[test]
7587 fn machine_taints_round_trips() {
7588 let toml_src = r#"
7589name = "us-south-001"
7590provider = "static"
7591mesh_tags = ["tag:cloud-runner"]
7592taints = ["no-appliance"]
7593"#;
7594 let m: MachineConfig = toml::from_str(toml_src).unwrap();
7595 assert_eq!(m.taints, vec!["no-appliance"]);
7596
7597 let s = toml::to_string(&m).unwrap();
7598 let back: MachineConfig = toml::from_str(&s).unwrap();
7599 assert_eq!(back.taints, vec!["no-appliance"]);
7600 }
7601
7602 #[test]
7603 fn machine_allocatable_absent_is_none() {
7604 let toml_src = "name = \"node\"\nprovider = \"static\"\nmesh_tags = []\n";
7605 let m: MachineConfig = toml::from_str(toml_src).unwrap();
7606 assert!(m.allocatable.is_none());
7607 assert!(m.taints.is_empty());
7608 }
7609
7610 #[test]
7611 fn machine_allocatable_skipped_when_none() {
7612 let m = make_machine("node", vec![]);
7613 let s = toml::to_string(&m).unwrap();
7614 assert!(
7615 !s.contains("allocatable"),
7616 "None allocatable must be omitted: {s}"
7617 );
7618 assert!(!s.contains("taints"), "empty taints must be omitted: {s}");
7619 }
7620
7621 #[test]
7622 fn machine_multiple_taints_round_trip() {
7623 let toml_src = r#"
7624name = "quarantined"
7625provider = "static"
7626mesh_tags = ["tag:build-worker"]
7627taints = ["no-server", "no-appliance", "no-job"]
7628"#;
7629 let m: MachineConfig = toml::from_str(toml_src).unwrap();
7630 assert_eq!(m.taints.len(), 3);
7631 assert!(m.taints.contains(&"no-server".to_string()));
7632 assert!(m.taints.contains(&"no-appliance".to_string()));
7633 assert!(m.taints.contains(&"no-job".to_string()));
7634 // R742-T4: every key here is one the scheduler reads. This fixture
7635 // used to carry `no-voter`, which none of them is.
7636 assert!(m.inert_taints().is_empty());
7637 }
7638
7639 // ─── R742-T4 (W305): inert-taint classification ─────────────────────────
7640
7641 #[test]
7642 fn every_archetype_repel_key_is_live() {
7643 for arch in LifecycleArchetype::ALL {
7644 let key = format!("no-{}", arch.taint_key());
7645 assert_eq!(
7646 taint_effect(&key),
7647 TaintEffect::Repels(arch),
7648 "{key} must repel {arch:?}"
7649 );
7650 }
7651 }
7652
7653 #[test]
7654 fn public_ip_is_an_affinity_key_not_an_inert_one() {
7655 assert_eq!(
7656 taint_effect(workload_spec::PUBLIC_IP_TAINT),
7657 TaintEffect::Attracts
7658 );
7659 }
7660
7661 #[test]
7662 fn a_free_form_taint_is_inert_and_says_so() {
7663 // W305's headline example: `taints = ["qa"]` parsed clean and did
7664 // nothing. Environment is not expressible as a taint.
7665 assert_eq!(taint_effect("qa"), TaintEffect::Inert);
7666 // And the one that actually cost fleet state: `no-voter` reads as an
7667 // exclusion and excludes nothing — "voter" is not an archetype.
7668 assert_eq!(taint_effect("no-voter"), TaintEffect::Inert);
7669 // A near-miss on a real key is inert too, not silently forgiven.
7670 assert_eq!(taint_effect("no-servers"), TaintEffect::Inert);
7671
7672 let m = make_machine_with_capacity(
7673 "dev-pi",
7674 8192,
7675 4000,
7676 vec!["no-appliance", "no-voter", "qa"],
7677 );
7678 assert_eq!(m.inert_taints(), vec!["no-voter", "qa"]);
7679 }
7680
7681 #[test]
7682 fn an_inert_taint_changes_no_placement_decision() {
7683 // The reason this is a lint and not a behaviour change: the guard's
7684 // whole premise is that these keys are invisible to `matches`.
7685 let clean = make_machine_with_capacity("n", 8192, 4000, vec![]);
7686 let noisy = make_machine_with_capacity("n", 8192, 4000, vec!["no-voter", "qa"]);
7687 for arch in LifecycleArchetype::ALL {
7688 let req = RequiredSpec {
7689 repel_archetype: Some(arch),
7690 ..Default::default()
7691 };
7692 assert_eq!(req.matches(&clean), req.matches(&noisy));
7693 }
7694 }
7695
7696 #[test]
7697 fn live_taint_keys_lists_the_whole_legal_vocabulary() {
7698 assert_eq!(
7699 live_taint_keys(),
7700 vec!["no-appliance", "no-job", "no-server", "public-ip"]
7701 );
7702 }
7703
7704 // ─── R742-F1 (W305): sovereign groups ───────────────────────────────────
7705
7706 /// A machine in `group`, with the role left unwritten — which is the state
7707 /// of every machine TOML that predates R605-F12 and resolves to `voter`.
7708 fn in_group(name: &str, group: Option<&str>) -> MachineConfig {
7709 MachineConfig {
7710 sovereign_group: group.map(String::from),
7711 ..make_machine(name, vec![])
7712 }
7713 }
7714
7715 /// A machine in `group` with its quorum eligibility stated (R605-F12).
7716 fn in_group_as(name: &str, group: &str, role: SovereignRole) -> MachineConfig {
7717 MachineConfig {
7718 sovereign_group: Some(group.to_string()),
7719 sovereign_role: Some(role),
7720 ..make_machine(name, vec![])
7721 }
7722 }
7723
7724 #[test]
7725 fn a_join_within_one_sovereign_group_is_permitted() {
7726 assert_eq!(
7727 judge_join(
7728 &in_group("us-west-013", Some("dev")),
7729 &in_group("us-west-011", Some("dev")),
7730 ),
7731 JoinVerdict::Permit
7732 );
7733 }
7734
7735 /// The case the field exists for: before it, the only thing standing
7736 /// between a dev Pi and the prod quorum was a comment in a TOML.
7737 #[test]
7738 fn a_cross_group_join_is_refused_naming_both_groups() {
7739 let verdict = judge_join(
7740 &in_group("us-west-011", Some("dev")),
7741 &in_group("us-west-001", Some("prod")),
7742 );
7743 let JoinVerdict::Refuse(msg) = verdict else {
7744 panic!("a dev node joining prod must be refused: {verdict:?}");
7745 };
7746 // A refusal that does not name what it saw is one the operator has to
7747 // go and reconstruct, so it gets worked around instead of fixed.
7748 assert!(msg.contains("us-west-011") && msg.contains("us-west-001"), "{msg}");
7749 assert!(msg.contains("dev") && msg.contains("prod"), "{msg}");
7750 }
7751
7752 /// `None` is a declaration ("standalone, in no group"), not a gap — so
7753 /// growing prod with an unstamped box is a cross-group join too, and the
7754 /// refusal has to say which file makes it legal.
7755 #[test]
7756 fn an_undeclared_node_cannot_join_a_declared_group() {
7757 let verdict = judge_join(
7758 &in_group("us-west-002", None),
7759 &in_group("us-west-001", Some("prod")),
7760 );
7761 let JoinVerdict::Refuse(msg) = verdict else {
7762 panic!("an unstamped node joining prod must be refused: {verdict:?}");
7763 };
7764 assert!(
7765 msg.contains(".yah/infra/machines/us-west-002.toml"),
7766 "the refusal must name the file to stamp: {msg}"
7767 );
7768 }
7769
7770 #[test]
7771 fn a_declared_node_cannot_join_a_standalone_target() {
7772 // us-west-003 is `mode: standalone` on purpose; it is not a group of
7773 // one waiting to be grown.
7774 let verdict = judge_join(
7775 &in_group("us-west-001", Some("prod")),
7776 &in_group("us-west-003", None),
7777 );
7778 assert!(matches!(verdict, JoinVerdict::Refuse(msg) if msg.contains("us-west-003")));
7779 }
7780
7781 #[test]
7782 fn two_undeclared_nodes_cannot_form_an_undeclared_group() {
7783 let verdict = judge_join(
7784 &in_group("us-west-002", None),
7785 &in_group("us-west-015", None),
7786 );
7787 assert!(
7788 matches!(&verdict, JoinVerdict::Refuse(msg) if msg.contains("us-west-002")
7789 && msg.contains("us-west-015")),
7790 "forming a group nobody declared must be refused, naming both: {verdict:?}"
7791 );
7792 }
7793
7794 // ─── R605-F12: the voting axis ──────────────────────────────────────────
7795
7796 /// The whole ticket in one assertion. us-west-003 is a member of prod —
7797 /// same secrets, same upgrade cadence, same destruction — and must never
7798 /// hold a prod raft seat. Before the role axis, the only thing refusing it
7799 /// was its *absent* group stamp, so writing down the truth above would have
7800 /// removed the guard.
7801 #[test]
7802 fn a_non_voting_member_is_refused_into_its_own_group() {
7803 let verdict = judge_join(
7804 &in_group_as("us-west-003", "prod", SovereignRole::NonVoter),
7805 &in_group_as("us-west-001", "prod", SovereignRole::Voter),
7806 );
7807 let JoinVerdict::Refuse(msg) = verdict else {
7808 panic!("a non-voting prod member must not join the prod quorum: {verdict:?}");
7809 };
7810 assert!(msg.contains("us-west-003") && msg.contains("NON-VOTING"), "{msg}");
7811 // The refusal must not blame the group: both sides say "prod", and a
7812 // cross-group message here would read as a bug in the check itself.
7813 assert!(!msg.contains("cross-group"), "{msg}");
7814 assert!(
7815 msg.contains(".yah/infra/machines/us-west-003.toml"),
7816 "the refusal must name the file that decides it: {msg}"
7817 );
7818 }
7819
7820 /// Read from the other end: a box declared non-voting has no quorum seat to
7821 /// be grown, so it cannot be a join target either.
7822 #[test]
7823 fn a_non_voting_target_has_no_quorum_to_grow() {
7824 let verdict = judge_join(
7825 &in_group_as("us-west-001", "prod", SovereignRole::Voter),
7826 &in_group_as("us-west-003", "prod", SovereignRole::NonVoter),
7827 );
7828 assert!(
7829 matches!(&verdict, JoinVerdict::Refuse(msg) if msg.contains("the target")
7830 && msg.contains("us-west-003")),
7831 "{verdict:?}"
7832 );
7833 }
7834
7835 /// A non-voter joining a *standalone* target is refused for two reasons at
7836 /// once, and the message must pick the one whose fix would actually work.
7837 /// Naming the role here would send the operator to flip `sovereign_role`
7838 /// and come back to the same refusal.
7839 #[test]
7840 fn a_refusal_names_the_group_when_fixing_the_role_would_not_help() {
7841 let verdict = judge_join(
7842 &in_group_as("us-west-003", "prod", SovereignRole::NonVoter),
7843 &in_group("us-west-002", None),
7844 );
7845 let JoinVerdict::Refuse(msg) = verdict else {
7846 panic!("a standalone target has no group to join: {verdict:?}");
7847 };
7848 assert!(
7849 msg.contains(".yah/infra/machines/us-west-002.toml"),
7850 "the refusal must point at the target's missing group stamp: {msg}"
7851 );
7852 assert!(!msg.contains("NON-VOTING"), "{msg}");
7853 }
7854
7855 /// The back-compat seam, pinned: the six nodes stamped before R605-F12
7856 /// write no role, and an absent role means what declaring a group has
7857 /// always meant. If this flips, the live prod and dev quorums stop being
7858 /// growable on a config the operator never edited.
7859 #[test]
7860 fn an_unwritten_role_still_joins_its_group() {
7861 let joiner = in_group("us-west-013", Some("dev"));
7862 assert_eq!(joiner.sovereign_role, None);
7863 assert_eq!(
7864 judge_join(&joiner, &in_group("us-west-011", Some("dev"))),
7865 JoinVerdict::Permit
7866 );
7867 assert_eq!(
7868 judge_join(
7869 &joiner,
7870 &in_group_as("us-west-011", "dev", SovereignRole::Voter)
7871 ),
7872 JoinVerdict::Permit
7873 );
7874 }
7875
7876 /// A non-voting member is still a *member*, and the two claims must not be
7877 /// conflated: `sovereign_membership()` reports the group either way, so a
7878 /// consumer asking "is this box in prod's blast radius" gets yes.
7879 #[test]
7880 fn a_non_voter_is_still_in_the_group_it_names() {
7881 let m = in_group_as("us-west-003", "prod", SovereignRole::NonVoter);
7882 assert_eq!(m.sovereign_membership().group, Some("prod"));
7883 assert!(!m.sovereign_membership().role.is_voter());
7884
7885 // …and the group-membership query the fleet reads is unaffected by it.
7886 let cfg = make_empty_cfg(vec![
7887 m,
7888 in_group_as("us-west-001", "prod", SovereignRole::Voter),
7889 ]);
7890 assert_eq!(
7891 cfg.machines_in_group("prod")
7892 .iter()
7893 .map(|m| m.name.as_str())
7894 .collect::<Vec<_>>(),
7895 vec!["us-west-003", "us-west-001"]
7896 );
7897 }
7898
7899 /// The role travels through TOML in one spelling, and an absent one stays
7900 /// absent on the way back out — otherwise every machine file would grow a
7901 /// `sovereign_role = "voter"` line the operator never wrote, and the
7902 /// unroled-member lint would have nothing left to find.
7903 #[test]
7904 fn sovereign_role_round_trips_and_is_omitted_when_unwritten() {
7905 let m: MachineConfig = toml::from_str(
7906 r#"
7907name = "us-west-003"
7908provider = "static"
7909region = "us-west"
7910arch = "x86_64"
7911mesh_tags = []
7912sovereign_group = "prod"
7913sovereign_role = "non-voter"
7914"#,
7915 )
7916 .unwrap();
7917 assert_eq!(m.sovereign_role, Some(SovereignRole::NonVoter));
7918 assert!(toml::to_string(&m)
7919 .unwrap()
7920 .contains(r#"sovereign_role = "non-voter""#));
7921
7922 let unwritten = MachineConfig {
7923 sovereign_role: None,
7924 ..m
7925 };
7926 assert!(!toml::to_string(&unwritten)
7927 .unwrap()
7928 .contains("sovereign_role"));
7929 }
7930
7931 /// The invariant the ticket is most explicit about: a sovereign group is a
7932 /// blast radius, not a filter. If this ever fails, `matches` has grown an
7933 /// axis it must not have and dev-mode workloads have silently become
7934 /// unschedulable on the dev group.
7935 #[test]
7936 fn sovereign_group_is_not_a_placement_input() {
7937 let standalone = in_group("n", None);
7938 let grouped = in_group("n", Some("dev"));
7939 let other = in_group("n", Some("prod"));
7940
7941 for spec in [
7942 RequiredSpec::default(),
7943 RequiredSpec {
7944 regions: vec!["us-west".into()],
7945 ..Default::default()
7946 },
7947 RequiredSpec {
7948 repel_archetype: Some(LifecycleArchetype::Appliance),
7949 ..Default::default()
7950 },
7951 ] {
7952 let baseline = spec.matches(&standalone);
7953 assert_eq!(spec.matches(&grouped), baseline);
7954 assert_eq!(spec.matches(&other), baseline);
7955 }
7956 }
7957
7958 // ─── R742-F3 (W305): group → machine set, and group-scoped admission ────
7959
7960 /// The primitive `migrate --to` needs and `rollout plan` still lacks
7961 /// (W314 gap 1): a group exists only as the set of machines naming it, so
7962 /// membership has to be derived rather than declared anywhere.
7963 #[test]
7964 fn machines_in_group_derives_membership_from_the_declarations() {
7965 let cfg = make_empty_cfg(vec![
7966 in_group("us-west-001", Some("prod")),
7967 in_group("us-west-011", Some("dev")),
7968 in_group("us-west-013", Some("dev")),
7969 in_group("us-west-002", None),
7970 ]);
7971
7972 let dev: Vec<&str> = cfg
7973 .machines_in_group("dev")
7974 .iter()
7975 .map(|m| m.name.as_str())
7976 .collect();
7977 assert_eq!(dev, vec!["us-west-011", "us-west-013"]);
7978 assert_eq!(cfg.machines_in_group("prod").len(), 1);
7979
7980 // Standalone is "in no group", not "in a group called none" — so an
7981 // unstamped box is never swept into a migration target.
7982 assert!(cfg.machines_in_group("").is_empty());
7983 assert!(cfg.machines_in_group("staging").is_empty());
7984 }
7985
7986 #[test]
7987 fn declared_sovereign_groups_is_the_vocabulary_a_bad_target_is_named_against() {
7988 let cfg = make_empty_cfg(vec![
7989 in_group("a", Some("prod")),
7990 in_group("b", Some("dev")),
7991 in_group("c", Some("prod")),
7992 in_group("d", None),
7993 ]);
7994 // Sorted + deduped, and standalone contributes nothing.
7995 assert_eq!(cfg.declared_sovereign_groups(), vec!["dev", "prod"]);
7996 assert!(make_empty_cfg(vec![in_group("a", None)])
7997 .declared_sovereign_groups()
7998 .is_empty());
7999 }
8000
8001 /// Group-scoped admission must be the SAME predicate as unscoped
8002 /// admission, only over fewer candidates. If it ever diverges, `migrate`
8003 /// becomes a way to place a workload somewhere `yah cloud apply` would
8004 /// refuse — which is exactly the silent routing-around W305 exists to stop.
8005 #[test]
8006 fn admit_workload_in_group_narrows_candidates_without_changing_the_predicate() {
8007 let mut prod = in_group("us-west-001", Some("prod"));
8008 prod.mesh_tags = vec!["tag:cloud-runner".into()];
8009 let mut dev_repels = in_group("us-west-011", Some("dev"));
8010 dev_repels.taints = vec!["no-appliance".into()];
8011 let mut dev_ok = in_group("us-west-013", Some("dev"));
8012 dev_ok.mesh_tags = vec!["tag:cloud-runner".into()];
8013
8014 let cfg = make_empty_cfg(vec![prod, dev_repels, dev_ok]);
8015
8016 let mut ws = ws_with_selector(None);
8017 ws.archetype = Some(LifecycleArchetype::Appliance);
8018
8019 // Unscoped picks the first match in declaration order.
8020 assert_eq!(cfg.admit_workload(&ws).unwrap().name, "us-west-001");
8021 // Scoped skips the repelling dev node and lands on the other one —
8022 // the taint is honoured, not bypassed.
8023 assert_eq!(
8024 cfg.admit_workload_in_group(&ws, "dev").unwrap().name,
8025 "us-west-013"
8026 );
8027 }
8028
8029 #[test]
8030 fn admit_workload_in_group_distinguishes_an_empty_group_from_a_repelling_one() {
8031 let mut dev = in_group("us-west-011", Some("dev"));
8032 dev.taints = vec!["no-appliance".into()];
8033 let cfg = make_empty_cfg(vec![in_group("us-west-001", Some("prod")), dev]);
8034
8035 let mut ws = ws_with_selector(None);
8036 ws.archetype = Some(LifecycleArchetype::Appliance);
8037
8038 // A group nobody declares names the legal vocabulary, because a typo
8039 // is the realistic cause and "no candidates" would send the operator
8040 // hunting for a placement problem that does not exist.
8041 let missing = cfg.admit_workload_in_group(&ws, "stagng").unwrap_err().to_string();
8042 assert!(missing.contains("no machine declares"), "{missing}");
8043 assert!(missing.contains("dev") && missing.contains("prod"), "{missing}");
8044
8045 // A group that exists but refuses names the machines it tried.
8046 let repelled = cfg.admit_workload_in_group(&ws, "dev").unwrap_err().to_string();
8047 assert!(repelled.contains("us-west-011"), "{repelled}");
8048 }
8049
8050 #[test]
8051 fn sovereign_group_round_trips_and_is_omitted_when_standalone() {
8052 let src = r#"
8053name = "us-west-011"
8054provider = "static"
8055mesh_tags = []
8056sovereign_group = "dev"
8057"#;
8058 let m: MachineConfig = toml::from_str(src).unwrap();
8059 assert_eq!(m.sovereign_group.as_deref(), Some("dev"));
8060 assert!(toml::to_string(&m).unwrap().contains("sovereign_group"));
8061
8062 // A machine that predates the field parses as standalone and does not
8063 // grow the key back on write.
8064 let legacy: MachineConfig =
8065 toml::from_str("name = \"us-west-002\"\nprovider = \"static\"\nmesh_tags = []\n")
8066 .unwrap();
8067 assert_eq!(legacy.sovereign_group, None);
8068 assert!(!toml::to_string(&legacy).unwrap().contains("sovereign_group"));
8069 }
8070
8071 // ─── R572-F5: capacity floor + absolute (untolerable) taints ────────────
8072
8073 fn make_machine_with_capacity(
8074 name: &str,
8075 memory_mb: u32,
8076 cpu_millis: u32,
8077 taints: Vec<&str>,
8078 ) -> MachineConfig {
8079 MachineConfig {
8080 allocatable: Some(NodeAllocatable {
8081 memory_mb,
8082 cpu_millis,
8083 }),
8084 taints: taints.into_iter().map(String::from).collect(),
8085 ..make_machine(name, vec![])
8086 }
8087 }
8088
8089 fn server_spec(memory_mb: u32, cpu_millis: u32) -> WorkloadSpec {
8090 use workload_spec::{ImageRef, LifecycleArchetype, ResourceLimits, TierTag};
8091 let mut ws = WorkloadSpec::for_forge(
8092 "f5-test",
8093 ImageRef {
8094 registry: "localhost".into(),
8095 repository: "test".into(),
8096 tag: "latest".into(),
8097 digest: workload_spec::testing::test_digest(),
8098 },
8099 TierTag("infra".into()),
8100 vec![],
8101 );
8102 ws.archetype = Some(LifecycleArchetype::Server);
8103 ws.resources = ResourceLimits {
8104 memory_mb,
8105 cpu_millis,
8106 ephemeral_storage_mb: 0,
8107 };
8108 // These are SERVER specs that borrow `for_forge` as a constructor
8109 // shortcut, so drop the forge memory request it stamps on — otherwise
8110 // every spec here silently requests the forge default instead of the
8111 // `memory_mb` the caller passed, and the capacity-floor tests below
8112 // stop testing their own argument. A server workload declares no
8113 // request, which is the documented fall-back-to-`resources.memory_mb`
8114 // path (`WorkloadSpec::memory_request_mb`).
8115 ws.annotations
8116 .remove(workload_spec::MEMORY_REQUEST_ANNOTATION);
8117 ws
8118 }
8119
8120 fn appliance_spec_ws(memory_mb: u32, cpu_millis: u32) -> WorkloadSpec {
8121 use workload_spec::LifecycleArchetype;
8122 let mut ws = server_spec(memory_mb, cpu_millis);
8123 ws.archetype = Some(LifecycleArchetype::Appliance);
8124 ws
8125 }
8126
8127 #[test]
8128 fn capacity_floor_rejects_undersized_node() {
8129 let cfg = make_empty_cfg(vec![make_machine_with_capacity("small", 256, 500, vec![])]);
8130 let ws = server_spec(512, 1000); // demands more than available
8131 assert!(cfg.admit_workload(&ws).is_err());
8132 }
8133
8134 #[test]
8135 fn capacity_floor_accepts_exact_fit() {
8136 let cfg = make_empty_cfg(vec![make_machine_with_capacity("exact", 512, 1000, vec![])]);
8137 let ws = server_spec(512, 1000);
8138 assert_eq!(cfg.admit_workload(&ws).unwrap().name, "exact");
8139 }
8140
8141 #[test]
8142 fn capacity_floor_passes_when_allocatable_absent() {
8143 // A machine with no allocatable block skips the capacity check (no data).
8144 let cfg = make_empty_cfg(vec![make_machine("no-alloc", vec![])]);
8145 let ws = server_spec(99999, 99999); // would exceed any real node
8146 assert_eq!(cfg.admit_workload(&ws).unwrap().name, "no-alloc");
8147 }
8148
8149 #[test]
8150 fn taint_repulsion_blocks_appliance_on_no_appliance_node() {
8151 let cfg = make_empty_cfg(vec![make_machine_with_capacity(
8152 "south",
8153 1024,
8154 2000,
8155 vec!["no-appliance"],
8156 )]);
8157 let ws = appliance_spec_ws(256, 500);
8158 assert!(
8159 cfg.admit_workload(&ws).is_err(),
8160 "appliance must be repelled by no-appliance taint"
8161 );
8162 }
8163
8164 #[test]
8165 fn taint_repulsion_allows_server_on_no_appliance_node() {
8166 // "no-appliance" only repels Appliance workloads; servers are unaffected.
8167 let cfg = make_empty_cfg(vec![make_machine_with_capacity(
8168 "south",
8169 1024,
8170 2000,
8171 vec!["no-appliance"],
8172 )]);
8173 let ws = server_spec(256, 500);
8174 assert_eq!(cfg.admit_workload(&ws).unwrap().name, "south");
8175 }
8176
8177 #[test]
8178 fn taint_repulsion_job_not_blocked_by_no_server() {
8179 use workload_spec::LifecycleArchetype;
8180 let cfg = make_empty_cfg(vec![make_machine_with_capacity(
8181 "build-box",
8182 8192,
8183 4000,
8184 vec!["no-server", "no-appliance"],
8185 )]);
8186 let mut ws = server_spec(256, 500);
8187 ws.archetype = Some(LifecycleArchetype::Job);
8188 // Job only repelled by "no-job"; "no-server" and "no-appliance" don't affect it.
8189 assert_eq!(cfg.admit_workload(&ws).unwrap().name, "build-box");
8190 }
8191
8192 #[test]
8193 fn requires_taint_affinity_blocks_placement_without_it() {
8194 use workload_spec::{LifecycleArchetype, PUBLIC_IP_TAINT, REQUIRES_TAINT_ANNOTATION};
8195 // Simulate the passway ingress appliance: requires "public-ip" taint.
8196 let mut ws = appliance_spec_ws(256, 512);
8197 ws.archetype = Some(LifecycleArchetype::Appliance);
8198 ws.annotations
8199 .insert(REQUIRES_TAINT_ANNOTATION.into(), PUBLIC_IP_TAINT.into());
8200
8201 // Node without the taint: rejected.
8202 let cfg = make_empty_cfg(vec![make_machine_with_capacity(
8203 "no-pip",
8204 2048,
8205 2000,
8206 vec![],
8207 )]);
8208 assert!(cfg.admit_workload(&ws).is_err());
8209
8210 // Node with the taint: accepted.
8211 let cfg = make_empty_cfg(vec![make_machine_with_capacity(
8212 "pub-node",
8213 2048,
8214 2000,
8215 vec!["public-ip"],
8216 )]);
8217 assert_eq!(cfg.admit_workload(&ws).unwrap().name, "pub-node");
8218 }
8219
8220 #[test]
8221 fn w244_fleet_scenario_appliance_rejected_from_south_and_west002() {
8222 // Full W244 fleet table scenario:
8223 // us-west-001/east-001: no taints, large capacity → appliance lands here
8224 // us-south-001: no-appliance taint → appliance rejected
8225 // us-west-002: no-server, no-appliance → appliance rejected
8226 let cfg = make_empty_cfg(vec![
8227 make_machine_with_capacity("us-south-001", 512, 1000, vec!["no-appliance"]),
8228 make_machine_with_capacity(
8229 "us-west-002",
8230 16384,
8231 8000,
8232 vec!["no-server", "no-appliance"],
8233 ),
8234 make_machine_with_capacity("us-west-001", 4096, 4000, vec![]),
8235 ]);
8236 let ws = appliance_spec_ws(256, 500);
8237 // Skips south (no-appliance) and west-002 (no-appliance), lands on west-001.
8238 assert_eq!(cfg.admit_workload(&ws).unwrap().name, "us-west-001");
8239 }
8240
8241 #[test]
8242 fn w244_fleet_scenario_job_lands_on_west002_first() {
8243 use workload_spec::LifecycleArchetype;
8244 // Jobs should prefer (or at least land on) the job-only box.
8245 let cfg = make_empty_cfg(vec![
8246 make_machine_with_capacity("us-west-001", 4096, 4000, vec![]),
8247 make_machine_with_capacity(
8248 "us-west-002",
8249 16384,
8250 8000,
8251 vec!["no-server", "no-appliance"],
8252 ),
8253 ]);
8254 let mut ws = server_spec(256, 500);
8255 ws.archetype = Some(LifecycleArchetype::Job);
8256 // No fleet node declares `no-job`, so a Job is repelled by nothing;
8257 // west-001 comes first in declaration order (greedy, no preference),
8258 // which is the expected tie-break. Note this is *absence of a repel
8259 // key*, not toleration — no workload can tolerate a taint (W305).
8260 let picked = cfg.admit_workload(&ws).unwrap();
8261 // Both are eligible.
8262 assert!(
8263 picked.name == "us-west-001" || picked.name == "us-west-002",
8264 "job must land on an eligible node, got {}",
8265 picked.name
8266 );
8267 }
8268
8269 #[test]
8270 fn r569_f4_macos_node_taints_keep_cloud_critical_off_but_admit_build_jobs() {
8271 use workload_spec::LifecycleArchetype;
8272 // R569-F4: the headless M2 (us-west-015) joins the fleet as a
8273 // build-worker but must never take cloud-critical load. It carries the
8274 // same repel set as the x86 build-worker (`no-server, no-appliance` —
8275 // see .yah/infra/machines/us-west-015.toml). This pins that intent:
8276 // with a plain cloud node available beside the Mac, every
8277 // cloud-critical archetype lands on the cloud node and never the Mac;
8278 // build Jobs (the Mac's actual purpose) remain eligible on it.
8279 //
8280 // R742-T4: `no-voter` used to sit in this set and in the TOML. It was
8281 // never read here — there is no "voter" workload archetype — and
8282 // R569-F3's learner-only join is what actually keeps the box out of
8283 // quorum. It is now rejected by `yah cloud validate` as inert.
8284 let mac_taints = vec!["no-server", "no-appliance"];
8285 let fleet = || {
8286 make_empty_cfg(vec![
8287 make_machine_with_capacity("us-west-015", 24576, 8000, mac_taints.clone()),
8288 make_machine_with_capacity("us-west-001", 4096, 4000, vec![]),
8289 ])
8290 };
8291
8292 // A cloud-critical Server workload is repelled from the Mac and lands
8293 // on the untainted cloud node.
8294 let cfg = fleet();
8295 assert_eq!(
8296 cfg.admit_workload(&server_spec(256, 500)).unwrap().name,
8297 "us-west-001",
8298 "a Server workload must never land on the no-server Mac node"
8299 );
8300
8301 // Same for an Appliance (pinned/stateful cloud-critical) workload.
8302 let cfg = fleet();
8303 assert_eq!(
8304 cfg.admit_workload(&appliance_spec_ws(256, 500))
8305 .unwrap()
8306 .name,
8307 "us-west-001",
8308 "an Appliance workload must never land on the no-appliance Mac node"
8309 );
8310
8311 // Sharpest repulsion proof: with ONLY the Mac in the fleet, a
8312 // cloud-critical Server workload is rejected outright — the taint keeps
8313 // it off even when that means nowhere to run.
8314 let mac_only = make_empty_cfg(vec![make_machine_with_capacity(
8315 "us-west-015",
8316 24576,
8317 8000,
8318 mac_taints.clone(),
8319 )]);
8320 assert!(
8321 mac_only.admit_workload(&server_spec(256, 500)).is_err(),
8322 "a Server workload must be repelled from a Mac-only fleet, not admitted"
8323 );
8324
8325 // But the Mac's real job — build/forge workloads — IS admitted on it:
8326 // it tolerates every fleet taint (there is no `no-job`).
8327 let mut job = server_spec(256, 500);
8328 job.archetype = Some(LifecycleArchetype::Job);
8329 assert_eq!(
8330 mac_only.admit_workload(&job).unwrap().name,
8331 "us-west-015",
8332 "a build Job must still be admitted on the Mac build-worker"
8333 );
8334 }
8335
8336 // ─── R615-F1: linked infra sources (`.yah/infra/sources.toml`) ─────────
8337
8338 #[test]
8339 fn sources_load_is_empty_when_the_file_is_absent() {
8340 // "Every camp without linked infra has none" — which today is every
8341 // camp — must not be an error.
8342 let tmp = tempfile::TempDir::new().unwrap();
8343 let cfg = SourcesConfig::load(tmp.path()).unwrap();
8344 assert_eq!(cfg, SourcesConfig::default());
8345 assert!(cfg.source.is_empty());
8346 assert_eq!(cfg.schema_version, 1);
8347 }
8348
8349 #[test]
8350 fn sources_parses_a_path_kind_exactly_like_w274s_example() {
8351 let tmp = tempfile::TempDir::new().unwrap();
8352 std::fs::write(
8353 tmp.path().join("sources.toml"),
8354 r#"
8355schema_version = 1
8356
8357[[source]]
8358owner = "yah"
8359kind = "path"
8360path = "../yah"
8361mode = "read-only"
8362"#,
8363 )
8364 .unwrap();
8365 let cfg = SourcesConfig::load(tmp.path()).unwrap();
8366 assert_eq!(cfg.source.len(), 1);
8367 let s = &cfg.source[0];
8368 assert_eq!(s.owner, "yah");
8369 assert_eq!(s.mode, SourceMode::ReadOnly);
8370 assert!(s.select.is_empty());
8371 match &s.kind {
8372 InfraSourceKind::Path { path } => assert_eq!(path, "../yah"),
8373 other => panic!("expected Path, got {other:?}"),
8374 }
8375 }
8376
8377 #[test]
8378 fn sources_parses_a_git_kind_reusing_gitsource_verbatim() {
8379 let tmp = tempfile::TempDir::new().unwrap();
8380 std::fs::write(
8381 tmp.path().join("sources.toml"),
8382 r#"
8383schema_version = 1
8384
8385[[source]]
8386owner = "yah"
8387kind = "git"
8388repo = "git@github.com:yah-ai/infra.git"
8389ref = "main"
8390subdir = "infra"
8391select = ["tag:cloud-runner"]
8392mode = "read-only"
8393"#,
8394 )
8395 .unwrap();
8396 let cfg = SourcesConfig::load(tmp.path()).unwrap();
8397 assert_eq!(cfg.source.len(), 1);
8398 let s = &cfg.source[0];
8399 assert_eq!(s.select, vec!["tag:cloud-runner".to_string()]);
8400 match &s.kind {
8401 InfraSourceKind::Git(git) => {
8402 assert_eq!(git.repo, "git@github.com:yah-ai/infra.git");
8403 assert_eq!(git.r#ref, "main");
8404 assert_eq!(git.subdir.as_deref(), Some("infra"));
8405 }
8406 other => panic!("expected Git, got {other:?}"),
8407 }
8408 }
8409
8410 #[test]
8411 fn sources_mode_defaults_to_read_only_and_manage_is_explicit() {
8412 let tmp = tempfile::TempDir::new().unwrap();
8413 std::fs::write(
8414 tmp.path().join("sources.toml"),
8415 r#"
8416schema_version = 1
8417
8418[[source]]
8419owner = "a"
8420kind = "path"
8421path = "../a"
8422
8423[[source]]
8424owner = "b"
8425kind = "path"
8426path = "../b"
8427mode = "manage"
8428"#,
8429 )
8430 .unwrap();
8431 let cfg = SourcesConfig::load(tmp.path()).unwrap();
8432 assert_eq!(cfg.source[0].mode, SourceMode::ReadOnly, "omitted mode = read-only");
8433 assert_eq!(cfg.source[1].mode, SourceMode::Manage);
8434 }
8435
8436 #[test]
8437 fn sources_preserves_declaration_order() {
8438 // Overlay order matters (R615-F2) when two sources name the same
8439 // machine — the list must round-trip in file order, not be reordered
8440 // by owner or kind.
8441 let tmp = tempfile::TempDir::new().unwrap();
8442 std::fs::write(
8443 tmp.path().join("sources.toml"),
8444 r#"
8445schema_version = 1
8446
8447[[source]]
8448owner = "second"
8449kind = "path"
8450path = "../second"
8451
8452[[source]]
8453owner = "first"
8454kind = "path"
8455path = "../first"
8456"#,
8457 )
8458 .unwrap();
8459 let cfg = SourcesConfig::load(tmp.path()).unwrap();
8460 let owners: Vec<&str> = cfg.source.iter().map(|s| s.owner.as_str()).collect();
8461 assert_eq!(owners, vec!["second", "first"]);
8462 }
8463
8464 #[test]
8465 fn sources_round_trips_through_serialize() {
8466 let cfg = SourcesConfig {
8467 schema_version: 1,
8468 source: vec![
8469 InfraSource {
8470 owner: "yah".into(),
8471 kind: InfraSourceKind::Path {
8472 path: "../yah".into(),
8473 },
8474 mode: SourceMode::ReadOnly,
8475 select: vec![],
8476 },
8477 InfraSource {
8478 owner: "yah".into(),
8479 kind: InfraSourceKind::Git(GitSource {
8480 repo: "git@github.com:yah-ai/infra.git".into(),
8481 r#ref: "main".into(),
8482 subdir: Some("infra".into()),
8483 }),
8484 mode: SourceMode::Manage,
8485 select: vec!["tag:cloud-runner".into()],
8486 },
8487 ],
8488 };
8489 let toml_str = toml::to_string_pretty(&cfg).unwrap();
8490 let reloaded: SourcesConfig = toml::from_str(&toml_str).unwrap();
8491 assert_eq!(reloaded, cfg, "round-trip through TOML must be lossless:\n{toml_str}");
8492 }
8493
8494 // ─── R615-F2: overlay loader in CloudConfig::load ───────────────────────
8495
8496 fn write_min_machine(dir: &Path, name: &str, extra_toml: &str) {
8497 std::fs::create_dir_all(dir).unwrap();
8498 // `extra_toml` supplies `mesh_tags` when the caller cares about it;
8499 // otherwise default to the empty list. Never hardcode `mesh_tags`
8500 // here as well as in `extra_toml` -- TOML rejects a duplicate key.
8501 let mesh_tags = if extra_toml.contains("mesh_tags") {
8502 String::new()
8503 } else {
8504 "mesh_tags = []\n".to_string()
8505 };
8506 std::fs::write(
8507 dir.join(format!("{name}.toml")),
8508 format!("name = \"{name}\"\nprovider = \"static\"\n{mesh_tags}{extra_toml}"),
8509 )
8510 .unwrap();
8511 }
8512
8513 fn write_min_provider(dir: &Path, id: &str) {
8514 std::fs::create_dir_all(dir).unwrap();
8515 std::fs::write(
8516 dir.join(format!("{id}.toml")),
8517 format!("schema_version = 1\nid = \"{id}\"\nkind = \"static\"\n"),
8518 )
8519 .unwrap();
8520 }
8521
8522 fn write_sources_toml(camp_root: &Path, body: &str) {
8523 let dir = camp_root.join(".yah/infra");
8524 std::fs::create_dir_all(&dir).unwrap();
8525 std::fs::write(dir.join("sources.toml"), body).unwrap();
8526 }
8527
8528 #[test]
8529 fn load_with_no_sources_toml_is_unchanged() {
8530 let tmp = tempfile::TempDir::new().unwrap();
8531 write_min_machine(&tmp.path().join(".yah/infra/machines"), "local-1", "");
8532 let cfg = CloudConfig::load(tmp.path()).unwrap();
8533 assert_eq!(cfg.machines.len(), 1);
8534 assert!(cfg.machine_origins.is_empty());
8535 assert!(cfg.provider_origins.is_empty());
8536 }
8537
8538 #[test]
8539 fn path_source_overlays_machines_and_providers_tagged_with_origin() {
8540 let camp = tempfile::TempDir::new().unwrap();
8541 let other = tempfile::TempDir::new().unwrap();
8542 write_min_machine(&other.path().join(".yah/infra/machines"), "borrowed-1", "");
8543 write_min_provider(&other.path().join(".yah/infra/providers"), "borrowed-provider");
8544 write_sources_toml(
8545 camp.path(),
8546 &format!(
8547 "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
8548 other.path().display()
8549 ),
8550 );
8551
8552 let cfg = CloudConfig::load(camp.path()).unwrap();
8553 assert_eq!(cfg.machines.len(), 1);
8554 assert_eq!(cfg.machines[0].name, "borrowed-1");
8555 assert_eq!(cfg.providers.len(), 1);
8556 assert_eq!(cfg.providers[0].id, "borrowed-provider");
8557
8558 let origin = cfg.machine_origins.get("borrowed-1").expect("origin recorded");
8559 assert_eq!(origin.owner, "other");
8560 assert_eq!(origin.mode, SourceMode::ReadOnly);
8561 assert!(origin.source.starts_with("path:"));
8562 assert_eq!(
8563 cfg.provider_origins.get("borrowed-provider").unwrap().owner,
8564 "other"
8565 );
8566 }
8567
8568 #[test]
8569 fn camp_local_wins_on_name_collision_and_carries_no_origin() {
8570 let camp = tempfile::TempDir::new().unwrap();
8571 let other = tempfile::TempDir::new().unwrap();
8572 // Both declare a machine named "shared" -- camp-local's copy must win,
8573 // and it must never gain an origin tag.
8574 write_min_machine(&camp.path().join(".yah/infra/machines"), "shared", "");
8575 write_min_machine(
8576 &other.path().join(".yah/infra/machines"),
8577 "shared",
8578 "nickname = \"the borrowed one\"\n",
8579 );
8580 write_sources_toml(
8581 camp.path(),
8582 &format!(
8583 "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
8584 other.path().display()
8585 ),
8586 );
8587
8588 let cfg = CloudConfig::load(camp.path()).unwrap();
8589 assert_eq!(cfg.machines.len(), 1, "the name collides, so exactly one entry");
8590 assert_eq!(cfg.machines[0].nickname, None, "camp-local's copy, not the borrowed one");
8591 assert!(
8592 !cfg.machine_origins.contains_key("shared"),
8593 "camp-local entries never carry an origin tag"
8594 );
8595 }
8596
8597 #[test]
8598 fn an_earlier_source_wins_over_a_later_one_on_collision() {
8599 let camp = tempfile::TempDir::new().unwrap();
8600 let first = tempfile::TempDir::new().unwrap();
8601 let second = tempfile::TempDir::new().unwrap();
8602 write_min_machine(&first.path().join(".yah/infra/machines"), "dup", "");
8603 write_min_machine(&second.path().join(".yah/infra/machines"), "dup", "");
8604 write_sources_toml(
8605 camp.path(),
8606 &format!(
8607 "schema_version = 1\n\n[[source]]\nowner = \"first\"\nkind = \"path\"\npath = \"{}\"\n\n[[source]]\nowner = \"second\"\nkind = \"path\"\npath = \"{}\"\n",
8608 first.path().display(),
8609 second.path().display()
8610 ),
8611 );
8612
8613 let cfg = CloudConfig::load(camp.path()).unwrap();
8614 assert_eq!(cfg.machines.len(), 1);
8615 assert_eq!(cfg.machine_origins.get("dup").unwrap().owner, "first");
8616 }
8617
8618 #[test]
8619 fn select_filters_borrowed_machines_by_name_or_mesh_tag() {
8620 let camp = tempfile::TempDir::new().unwrap();
8621 let other = tempfile::TempDir::new().unwrap();
8622 write_min_machine(&other.path().join(".yah/infra/machines"), "runner-1", "mesh_tags = [\"tag:cloud-runner\"]\n");
8623 write_min_machine(&other.path().join(".yah/infra/machines"), "excluded-1", "");
8624 write_sources_toml(
8625 camp.path(),
8626 &format!(
8627 "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\nselect = [\"tag:cloud-runner\"]\n",
8628 other.path().display()
8629 ),
8630 );
8631
8632 let cfg = CloudConfig::load(camp.path()).unwrap();
8633 assert_eq!(cfg.machines.len(), 1);
8634 assert_eq!(cfg.machines[0].name, "runner-1");
8635 }
8636
8637 #[test]
8638 fn one_unparseable_foreign_machine_does_not_sink_the_rest_of_the_directory_or_the_load() {
8639 let camp = tempfile::TempDir::new().unwrap();
8640 let other = tempfile::TempDir::new().unwrap();
8641 let dir = other.path().join(".yah/infra/machines");
8642 write_min_machine(&dir, "good", "");
8643 // Schema-skew gotcha: a foreign machine this binary's MachineConfig
8644 // can't parse at all (not just an unknown field -- MachineConfig has
8645 // no deny_unknown_fields, so this has to fail on a TYPE, not a name).
8646 std::fs::write(dir.join("bad.toml"), "name = 1\nprovider = 2\n").unwrap();
8647 write_sources_toml(
8648 camp.path(),
8649 &format!(
8650 "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
8651 other.path().display()
8652 ),
8653 );
8654
8655 // Must not error at all -- camp-local load must never fail because a
8656 // source it doesn't own has one bad file.
8657 let cfg = CloudConfig::load(camp.path()).unwrap();
8658 assert_eq!(cfg.machines.len(), 1, "the good entry still loads");
8659 assert_eq!(cfg.machines[0].name, "good");
8660 }
8661
8662 #[test]
8663 fn an_unsynced_git_source_overlays_nothing_and_is_not_an_error() {
8664 // No `yah infra sync` (R615-T3) has ever run, so the cache dir this
8665 // resolves to doesn't exist. Must be silent, not fatal.
8666 let camp = tempfile::TempDir::new().unwrap();
8667 write_sources_toml(
8668 camp.path(),
8669 "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"git\"\nrepo = \"git@github.com:yah-ai/infra.git\"\nref = \"main\"\n",
8670 );
8671 let cfg = CloudConfig::load(camp.path()).unwrap();
8672 assert!(cfg.machines.is_empty());
8673 assert!(cfg.machine_origins.is_empty());
8674 }
8675
8676 #[test]
8677 fn a_synced_git_source_reads_from_the_cache_dir_not_the_repo_path() {
8678 // No `subdir` declared -- the checkout ROOT is the infra root.
8679 let camp = tempfile::TempDir::new().unwrap();
8680 let cache = crate::paths::infra_source_cache_dir(camp.path(), "yah");
8681 write_min_machine(&cache.join("machines"), "synced-1", "");
8682 write_sources_toml(
8683 camp.path(),
8684 "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"git\"\nrepo = \"git@github.com:yah-ai/infra.git\"\nref = \"main\"\n",
8685 );
8686 let cfg = CloudConfig::load(camp.path()).unwrap();
8687 assert_eq!(cfg.machines.len(), 1);
8688 assert_eq!(cfg.machines[0].name, "synced-1");
8689 assert!(cfg.machine_origins.get("synced-1").unwrap().source.starts_with("git:"));
8690 }
8691
8692 #[test]
8693 fn a_git_sources_subdir_is_honoured_like_the_component_case() {
8694 // W274's own example declares `subdir = "infra"` for a monorepo whose
8695 // registry lives under a subdirectory of the clone rather than at its
8696 // root -- prove `infra_root` actually reads it, not just `.subdir` on
8697 // GitSource parsing (R615-F1 already covers that half).
8698 let camp = tempfile::TempDir::new().unwrap();
8699 let cache = crate::paths::infra_source_cache_dir(camp.path(), "yah");
8700 write_min_machine(&cache.join("infra").join("machines"), "subdir-1", "");
8701 // Also plant a decoy at the checkout root to prove the root itself is
8702 // NOT read when a subdir is declared.
8703 write_min_machine(&cache.join("machines"), "root-decoy", "");
8704 write_sources_toml(
8705 camp.path(),
8706 "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"git\"\nrepo = \"git@github.com:yah-ai/infra.git\"\nref = \"main\"\nsubdir = \"infra\"\n",
8707 );
8708 let cfg = CloudConfig::load(camp.path()).unwrap();
8709 assert_eq!(cfg.machines.len(), 1);
8710 assert_eq!(cfg.machines[0].name, "subdir-1");
8711 }
8712
8713 #[test]
8714 fn load_from_config_dir_never_applies_sources_overlay() {
8715 // R615-F2's explicit decision: multi-root sibling trees don't inherit
8716 // the classic .yah/infra/sources.toml. Prove it rather than assert it
8717 // silently -- a sources.toml sitting at workspace_root/.yah/infra/
8718 // must NOT leak into a load_from_config_dir call even though both
8719 // share the same workspace_root.
8720 let camp = tempfile::TempDir::new().unwrap();
8721 let other = tempfile::TempDir::new().unwrap();
8722 write_min_machine(&other.path().join(".yah/infra/machines"), "borrowed-1", "");
8723 write_sources_toml(
8724 camp.path(),
8725 &format!(
8726 "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
8727 other.path().display()
8728 ),
8729 );
8730 let sibling_config_dir = camp.path().join(".noisetable");
8731 std::fs::create_dir_all(&sibling_config_dir).unwrap();
8732
8733 let cfg = CloudConfig::load_from_config_dir(&sibling_config_dir, camp.path()).unwrap();
8734 assert!(cfg.machines.is_empty(), "sources.toml must not apply here");
8735 assert!(cfg.machine_origins.is_empty());
8736 }
8737
8738 // ─── R615-T5: `inherit_machines` retirement — cutover proof ────────────
8739
8740 /// The successor to R615-T5's parity proof. That earlier pair of tests
8741 /// asserted the legacy `[infra].inherit_machines` redirect and an
8742 /// equivalent `kind = "path"` source resolved the same machine set, and
8743 /// that the two coexisted without duplicating rows. Both claims were about
8744 /// a mechanism that no longer exists, so they retired with it — what has
8745 /// to hold *now* is the other half of the same guarantee: a camp that
8746 /// declares only `sources.toml` resolves the shared root exactly as the
8747 /// redirect used to, and a stale `inherit_machines` key left behind in
8748 /// `camp.toml` changes nothing.
8749 ///
8750 /// That stale-key case is not hypothetical: it is precisely the state a
8751 /// camp is in between the code cutover and someone tidying its
8752 /// `camp.toml`, and a silent re-resolution there would double-count the
8753 /// borrowed nodes or hide their origin badge.
8754 #[test]
8755 fn a_stale_inherit_machines_key_does_not_change_what_sources_toml_resolves() {
8756 let shared = tempfile::TempDir::new().unwrap();
8757 write_min_machine(&shared.path().join(".yah/infra/machines"), "shared-node-1", "");
8758 write_min_machine(&shared.path().join(".yah/infra/machines"), "shared-node-2", "");
8759
8760 let sources_toml = format!(
8761 "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"path\"\npath = \"{}\"\nmode = \"read-only\"\n",
8762 shared.path().display()
8763 );
8764
8765 // Camp A: migrated cleanly — sources.toml only.
8766 let clean = tempfile::TempDir::new().unwrap();
8767 write_sources_toml(clean.path(), &sources_toml);
8768
8769 // Camp B: mid-migration — same source, plus the retired key still
8770 // sitting in camp.toml pointing at the same root.
8771 let stale = tempfile::TempDir::new().unwrap();
8772 std::fs::create_dir_all(stale.path().join(".yah")).unwrap();
8773 std::fs::write(
8774 stale.path().join(".yah/camp.toml"),
8775 format!(
8776 "[infra]\ninherit_machines = \"{}\"\n",
8777 shared.path().display()
8778 ),
8779 )
8780 .unwrap();
8781 write_sources_toml(stale.path(), &sources_toml);
8782
8783 let via_clean = CloudConfig::load(clean.path()).unwrap();
8784 let via_stale = CloudConfig::load(stale.path()).unwrap();
8785
8786 let names = |cfg: &CloudConfig| {
8787 let mut v: Vec<String> = cfg.machines.iter().map(|m| m.name.clone()).collect();
8788 v.sort();
8789 v
8790 };
8791 assert_eq!(
8792 names(&via_clean),
8793 names(&via_stale),
8794 "a leftover inherit_machines key must be inert — the retired redirect is gone"
8795 );
8796 assert_eq!(names(&via_clean), vec!["shared-node-1", "shared-node-2"]);
8797
8798 // And both are *borrowed*, not camp-local. This is the operator-facing
8799 // win the stopgap could never deliver: under the old redirect these
8800 // resolved with no origin at all, indistinguishable from locally-owned
8801 // nodes.
8802 assert_eq!(via_clean.machine_origins.len(), 2);
8803 assert_eq!(via_stale.machine_origins.len(), 2);
8804 for origin in via_stale.machine_origins.values() {
8805 assert_eq!(origin.owner, "yah");
8806 assert_eq!(origin.mode, SourceMode::ReadOnly);
8807 }
8808 }
8809}