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, Locality, 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///
201/// @yah:ticket(R860-T5, "Model per-node native-exec capability as an admission axis (W338 §Placement consequences 3 / R858-T4 gap)")
202/// @yah:status(review)
203/// @yah:phase(P1)
204/// @yah:at(2026-09-05T18:29:19Z)
205/// @yah:assignee(agent:bundle-anthropic-ashguard)
206/// @yah:parent(R860)
207/// @yah:next("Cheapest defensible shape: express it on MachineConfig, which already has the two vocabularies — `mesh_tags: Vec<String>` (config.rs:246, superset match, already carries `arch:`/`os:`/`tag:build-worker`) and `taints: Vec<String>` (config.rs:337). A `native-exec` mesh tag required by any group member whose kind is native is a one-line admission axis in `admission_spec()`. Whichever is chosen, it must be declared in .yah/infra/machines/*.toml for the nodes that actually run kamaji with --native-exec-dir, and `check_inert_taints` (config.rs:703) lints unread taint keys dead — so a taint nobody reads will be flagged.")
208/// @yah:verify("cargo test -p cloud --lib config")
209/// @arch:see(.yah/docs/working/W338-workload-dependencies-and-appliance-composition.md)
210/// @yah:depends_on(R860-T4)
211/// @yah:gotcha("Verified 2026-09-04: native-exec capability is modelled NOWHERE in placement — `rg \"native\" oss/yubaba/crates/cloud/src/config.rs` returns zero hits, and the raft state machine models no member attributes, labels or taints at all (`rg \"taint|capabilit|labels|mesh_tag\"` over raft/{mod,store,network}.rs yields one unrelated comment at raft/store.rs:591). Native-exec is a node-local kamaji startup decision today: `--native-exec-dir` (oss/kamaji/crates/kamaji-bin/src/main.rs:152-156, :51-55) plus the `native-exec` cargo feature (kamaji-bin/src/server.rs:329-330). A node without it refuses the deploy at dispatch time and nothing upstream can see that in advance — which is exactly the deploy-time surprise W338 wants turned into a placement precondition.")
212/// @yah:handoff("NATIVE-EXEC IS NOW A PLACEMENT PRECONDITION, NOT A DISPATCH-TIME SURPRISE. New `pub const NATIVE_EXEC_MESH_TAG: &str = \"cap:native-exec\"` in oss/yubaba/crates/cloud/src/config.rs (declared just above `node_selector_mesh_tags`), and one axis in `admission_spec()` immediately after the R860-T4 group loop: if ANY member of `placement_group(ws, declared)` returns true from `WorkloadSpec::wants_native_exec()`, the tag is appended to the derived `RequiredSpec.mesh_tags` (deduped). No new field on `RequiredSpec`, no signature change anywhere, no wire or serde change — the mesh_tags axis is already an AND-ed superset check against `machine.mesh_tags` in `matches` and is already rendered by `describe`, so a refusal now reads `required.mesh_tags=[...,cap:native-exec]`.")
213/// @yah:handoff("ITEM 1 — HOW A NATIVE WORKLOAD IS DETECTED, settled by opening the type rather than guessing. There is no `kind` on `WorkloadSpec`: on the wire a native workload is still `Workload::Container(WorkloadSpec)`, and the ONLY difference is the annotation `yah.exec = native`, read through `WorkloadSpec::wants_native_exec()` (oss/yah-base/crates/workload-spec/src/lib.rs:2939; consts `NATIVE_EXEC_ANNOTATION` / `NATIVE_EXEC_VALUE` at :3402/:3407). That accessor is what the admission axis calls — matching kamaji, whose `deploy_container` checks the same marker first and routes to `deploy_native_exec` (oss/kamaji/crates/kamaji-bin/src/server.rs). The `yah.exec` key is a substrate selector with a second value, `microvm` (`wants_microvm`, same key, R605-F8), so per-node microVM capability is the obvious sibling axis and is NOT modelled here — see next-steps.")
214/// @yah:handoff("ITEM 2 — DECLARATIONS LANDED ON TWO NODES, FROM READINGS RECORDED IN-REPO, NOT INFERRED. `cap:native-exec` added to `mesh_tags` in .yah/infra/machines/us-west-001.toml and .yah/infra/machines/us-west-003.toml, each with a comment naming its evidence and its re-check condition. us-west-001: the R858 gotcha in its own header records a `ps` reading taken on the box 2026-09-05 — pid 515908 is `/usr/local/bin/kamaji --native-exec-dir /var/lib/yah/kamaji/native`, supervising headscale as a native child. us-west-003: its header's 'THE DEPLOYED KAMAJI PREDATES THE microVM BACKEND' note quotes the box's actual ExecStart, read over ssh 2026-09-01, carrying `--native-exec-dir /var/lib/yah/kamaji/native` (corroborated by .yah/docs/architecture/A043-yah-on-machine-daemons.md's @yah:verify for the same probe). Both comments say plainly that the capability lives in the systemd unit's ExecStart, not in the TOML, so it must be re-checked after any roll.")
215/// @yah:handoff("ITEM 2, THE NEGATIVES — TWO NODES ARE KNOWN NOT TO HAVE IT AND WERE DELIBERATELY LEFT UNSET. us-south-001: kamaji refused headscale there 2026-09-03 with 'native backend not configured — start kamaji with --native-exec-dir' (the R858 chain, quoted in .yah/infra/machines/us-west-001.toml and W267). I did NOT edit us-south-001.toml — it was already dirty in the working tree at the anchor SHA and @Ashguard:eclipse is live on R858, so I left it alone rather than race it; the mechanism fails closed there, which is the correct state. us-west-015 (the sole darwin builder): W254-darwin-build-nodes.md's own next-step records that its kamaji is built/started `--docker` only. I added a comment to us-west-015.toml explaining that the tag is deliberately absent, that this is the node where the axis changes an error message (a darwin build row is native by construction, so it is now refused at ELECTION naming cap:native-exec instead of reaching the box and being refused by kamaji), and the exact enable sequence: rebuild with `--features native-exec`, restart with `--native-exec-dir <dir>`, THEN add the tag. us-west-002/011/013/014 are unestablished from the repo and left unset. THE OPERATOR-FACING ANSWER: the file is `.yah/infra/machines/<node>.toml` and the key is `mesh_tags`; add the literal string `cap:native-exec` to that array, and only after the roll.")
216/// @yah:handoff("DECISIONS THE BRIEF LEFT OPEN, all recorded in doc comments at the site. (1) MESH TAG, NOT TAINT — as recommended, and the doc says why in the terms the brief asked for: mesh tags are positive capability with superset matching ('this node CAN'), which is the claim being made; a taint is repulsion and would have to be inverted to `no-native-exec` on every node LACKING the backend (declaration burden on the majority, and silently wrong for a node nobody has edited) AND taught to `taint_effect`, or `check_inert_taints` would correctly lint the key dead. (2) THE `cap:` NAMESPACE IS NEW. Live prefixes are `tag:` (operator-assigned role), `arch:`/`os:` (silicon and userland facts, emitted as requirements by qed::platform::build_worker_mesh_tags), and `tier:` which R763 RETIRED for architecture and reserved for the environment axis — so reusing any of them would have stated the wrong kind of fact. A capability the daemon was configured with is none of those. Nothing validates tag prefixes (only `check_retired_arch_tags` looks at one), so this costs no wiring. (3) COMPUTED OVER THE GROUP, not the requirer — that is literally W338's sentence ('supply = self specs must be placeable where their requirer lands'), and the second test proves it: an ordinary container requirer with a `local` edge to a native provider is pulled onto a capable node. (4) FAILS CLOSED, accepted deliberately: an undeclared node is simply not a candidate, so an undeclared fleet reports 'no node admits' at election rather than dispatching to a node that refuses. Nothing in `.yah/infra/workloads/` is native-marked today (only yah-cloud-admin.toml exists there), so the only live consumer is the qed darwin build row, where failing closed is strictly the better error.")
217/// @yah:handoff("BLAST RADIUS, MEASURED. `admission_spec` is private and its callers are unchanged: `admit_workload` / `admit_workload_candidates` / `admit_workload_in_group` (config.rs), reached from app/yah/cli/src/cloud.rs (deploy, rolling, topology analyzer), app/yah/cli/src/yubaba_client.rs `elect_node`, and cloud/src/migrate.rs. The headscale appliance path inside yubaba (headscale_appliance.rs) does NOT go through admission — it is node-internal — so nothing eclipse holds on R858 is touched by this. Files edited, in full: oss/yubaba/crates/cloud/src/config.rs; .yah/infra/machines/{us-west-001,us-west-003,us-west-015}.toml. Nothing in oss/yubaba/crates/yubaba/ was opened, and oss/kamaji/crates/kamaji-bin/src/server.rs was READ ONLY (to confirm the marker check), per @Ashguard:hydra's contention triage.")
218/// @yah:handoff("ONE SCOPE ADDITION, stated loudly rather than slipped in: `MachineConfig::mesh_tags` (config.rs:256) had NO doc comment at all — the operator-facing declaration key for four tag namespaces was undocumented. I gave it one enumerating `tag:` / `arch:`+`os:` / the new `cap:` / retired `tier:`, and noting that nothing validates the prefix (which is why the two lints exist). CONSEQUENCE TO KNOW: that field's doc is the source of the `mesh_tags` description in the GENERATED .yah/schema/machine.toml.schema.json, so it is schema-drift-affecting — see the gotcha.")
219/// @yah:handoff("Tree anchor at handoff: 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2 — the shared tree as I found it and left it. Quote this SHA rather than 'HEAD' in any revert/restore instruction; to undo a hunk, read it with `git show 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2:<path>` and put it back with Edit, never `git checkout`/`restore` (they restore whole files and would delete peers' uncommitted work).")
220/// @yah:handoff("Tree anchor at handoff: 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2 — the shared tree as I left it. Diff against it (`git diff 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
221/// @yah:next("MICROVM IS THE IDENTICAL UNMODELLED GAP, one line away. `yah.exec` is a substrate selector with a second value: `WorkloadSpec::wants_microvm()` (workload-spec/src/lib.rs, R605-F8), and kamaji constructs MicroVmRuntime only when started with `--microvm-dir` — A043's probe records that us-west-003's deployed kamaji has `--native-exec-dir` but NOT `--microvm-dir`, so a microvm-marked deploy is refused there by exactly the same dispatch-time surprise this ticket removed for native. The shape is `cap:microvm` alongside NATIVE_EXEC_MESH_TAG in the same `if` in `admission_spec`. Not done here because no node in the fleet can host one yet (R605-F14 must land a guest kernel + rootfs first), so declaring the tag anywhere today would be the wrong fact.")
222/// @yah:next("us-south-001 needs `cap:native-exec` DECIDED, not defaulted, and it is the R858 node. It is the one machine the repo positively records as LACKING the backend (kamaji refused headscale there 2026-09-03), so leaving the tag off is correct TODAY — but if R858's fix is 'give us-south-001 a native-capable kamaji' rather than 'stop moving headscale', then the roll and the tag must land together, in that order. I left .yah/infra/machines/us-south-001.toml untouched because it was already dirty at anchor 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2 and @Ashguard:eclipse is live on R858.")
223/// @yah:next("R860-T6 (`supply = \"self\"` provisioning) inherits this for free — `admission_spec` already requires the capability of the whole group, so a self-provisioned native member cannot be elected onto a node that cannot run it. What T6 must still not do is re-elect per member: reuse the node URL `elect_node` returned for the requirer, per R860-T4's handoff.")
224/// @yah:verify("BASELINE RECORDED BEFORE EDITING, at tree anchor 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2: `cargo test -p yah-cloud --lib` from oss/yubaba = 1090 passed / 0 failed / 4 ignored, exit 0 — exactly the count the brief predicted. AFTER: 1093 passed / 0 failed / 4 ignored, exit 0 (+3, exactly the three tests added). `cargo check -p yah-cloud --all-targets` exit 0 and `cargo check -p yubaba --all-targets` exit 0 (yubaba consumes cloud, so it is where any signature change would surface — there is none). Every exit code echoed explicitly via an `EXIT=$?` / `${PIPESTATUS[0]}` marker and read back, never inferred from an empty grep. The four `yah-cloud` warnings are all pre-existing and in other files (object-store r2.rs, reconciler/mesofact_static.rs unused imports, app_manifest.rs, reconciler/mod.rs non_snake_case); config.rs contributes none.")
225/// @yah:verify("NEW TESTS (config.rs `mod tests`, R860-T5 section at the end, after the R860-T4 block). (1) a_node_without_the_native_exec_capability_cannot_host_a_native_workload — a `yah.exec = native` spec is refused by a bare node with an error naming `cap:native-exec`, and admitted by a node declaring it, with both nodes in the same fleet so the choice is provably the tag. (2) a_local_edge_to_a_native_provider_makes_the_requirer_need_the_capability — an ordinary container requirer (asserted `!wants_native_exec()`) with a `local` edge to a native provider lands on the capable node, while the SAME spec without the edge still lands on the plain one, so the constraint provably comes from the group. (3) a_group_with_no_native_member_does_not_require_the_capability — the regression guard: the axis is absent from `admission_spec`'s mesh_tags and a group with a local edge between two ordinary specs still admits on a node declaring nothing. Helper `native_spec()` asserts the marker reads back through `wants_native_exec()` before the test uses it, so a typo cannot make the test pass vacuously.")
226/// @yah:verify("Machine-config lints were considered and are unaffected by construction: `check_inert_taints` reads `taints` (I touched none), and `check_retired_arch_tags` flags only the `tier:` prefix. `cap:` is a new namespace and nothing validates prefixes, so no lint fires and no lint needs teaching.")
227/// @yah:gotcha("SCHEMA DRIFT IS EXPECTED FROM THIS TICKET AND WAS ALREADY RED BEFORE IT. `.yah/schema/machine.toml.schema.json` is generated from `cloud::config` by `cargo run -p xtask -- emit-schemas`, and MachineConfig's DOC COMMENT is what the generator emits as its `description` — which means (a) my new `mesh_tags` doc changes it, and (b) so does this very handoff, because R860-T5's @yah: annotation block lives inside MachineConfig's doc at config.rs:201. That file was ALSO already dirty in the working tree at anchor 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2, before I touched anything — `scripts/check-schema-drift.sh` compares the regenerated tree against git, so it is red for any uncommitted schema edit regardless of author. Regenerate with `cargo run -p xtask -- emit-schemas` (or `scripts/check-schema-drift.sh --update`) when the root target dir is not contended; the pre-commit hook no longer does it (disabled 2026-08-15, see CLAUDE.md).")
228/// @yah:verify("SCHEMA REGENERATED IN THIS SESSION, so the drift gate is not left for the next reader: `cargo run --quiet -p xtask -- emit-schemas` exit 0, run from the repo root after the handoff was written (so it captures the annotation text too). Two files moved. `.yah/schema/machine.toml.schema.json`: MachineConfig's `description` grows by this ticket's annotation block, plus a genuinely new `mesh_tags.description` from the doc comment I added. `.yah/schema/workload.toml.schema.json`: +104 lines that are NOT mine — the `Locality` / `Requirement` / `Supply` / `WorkloadSpec.requires` types R860-T1 landed had never been emitted, so the sibling ticket's schema drift was still outstanding and my regen swept it in. Derived artifacts are not ownable (shared-tree doctrine), so this is deliberate rather than accidental; @Ashguard, whoever picks up R860-T1's review should know the schema now describes `requires`.")
229/// @yah:verify("FINAL RE-RUN AFTER THE HANDOFF ANNOTATION WAS WRITTEN INTO config.rs (the board write edits MachineConfig's doc block, so the file changed under the earlier green): `cargo test -p yah-cloud --lib` = 1093 passed / 0 failed / 4 ignored, exit 0. Unchanged. Note for anyone reading the camp build rail's skew warnings on this session: the one `SUSPECT RESULT` it emitted names `oss/yubaba/crates/cloud/src/config.rs` as modified mid-run, and that modification was MY OWN board_handoff annotation write, not a peer — the two authoritative runs (full lib test, and both cargo checks) each came back `Input closure unchanged across the whole run: no skew`.")
230/// @yah:verify("All builds were run with `CARGO_TARGET_DIR=/tmp/r860t5-target` rather than the shared oss/yubaba/target, following R860-T4's recorded gotcha — a peer (session:83093d9d) held the shared target lock for the entire session (20+ minutes of `cargo check -p yubaba --lib`). Costs one cold dep build, then every subsequent run is seconds. Worth reaching for immediately when the queue message says you are behind someone.")
231/// @yah:handoff("LEADER RE-VERIFIED (session:69b18855, independent of the courier's self-report). `cargo test -p yah-cloud --lib` from oss/yubaba: 1093 passed / 0 failed / 4 ignored, exit 0, against the 1090/0/4 baseline this relay's own T4 established — +3 = exactly its new tests. Axis confirmed by content: `NATIVE_EXEC_MESH_TAG = \"cap:native-exec\"` at config.rs:2304, appended to the derived `RequiredSpec.mesh_tags` at :2112-2114 when any `placement_group` member returns true from `WorkloadSpec::wants_native_exec()`. No new `RequiredSpec` field, no signature change, no wire change — it rides the existing AND-ed superset check, so a refusal now reads `required.mesh_tags=[...,cap:native-exec]`.")
232/// @yah:handoff("Tree anchor at handoff: 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2 — the shared tree as I left it. Diff against it (`git diff 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
233/// @yah:verify("MACHINE DECLARATIONS AUDITED FOR PROVENANCE, because a wrong capability declaration is worse than an absent one. Both are traceable to measurements ALREADY RECORDED IN-REPO, not inferred: us-west-001 from the `ps` reading at us-west-001.toml:21 (pid 517125, ppid 515908 = `/usr/local/bin/kamaji --native-exec-dir /var/lib/yah/kamaji/native`, cgroup `0::/yubaba.slice/kamaji.service/native`, 2026-09-05); us-west-003 from the actual ExecStart read over ssh 2026-09-01 at us-west-003.toml:141. us-west-015 was deliberately left WITHOUT the tag and carries enable instructions at :207-217 — unknown fails closed, which is the correct direction. No node was guessed at and nothing was probed live.")
234/// @yah:handoff("THIS TICKET MODELS THE EXACT DRIFT THAT CAUSED THE 25-HOUR MESH OUTAGE, which is worth stating because it turns an abstract W338 bullet into a measured one. us-west-001.toml:8 records the root-cause chain: on 2026-09-03T06:03:03Z leadership moved to us-south-001, which tried to deploy headscale and kamaji refused — \\\"workload requests native host execution (yah.exec=native) but no native backend is available (native backend not configured — start kamaji with --native-exec-dir)\\\" — then the systemd fallback failed too, both at WARN, and the mesh had no coordination server for 25 hours. us-west-001.toml:10 names it explicitly as \\\"a silent per-node capability drift that placement does not model\\\". After this ticket, placement models it: a group needing native exec can no longer be admitted onto a node that has not declared `cap:native-exec`.")
235/// @yah:handoff("Tree anchor at handoff: 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2 — the shared tree as I left it. Diff against it (`git diff 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
236/// @yah:verify("RE-VERIFIED AT HEAD 00ee20d1 (session:aa5e882d, 2026-09-05). `NATIVE_EXEC_MESH_TAG` present in oss/yubaba/crates/cloud/src/config.rs (declared above `node_selector_mesh_tags`, appended to the derived `RequiredSpec.mesh_tags` when any `placement_group` member wants native exec). `cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib` = 1141 passed / 0 failed / 4 ignored, exit 0.")
237/// @yah:cleanup("cap:microvm remains the identical unmodelled axis, one line from done in the same `if` in `admission_spec`. Deliberately NOT taken: no node in the fleet can host a microvm until R605-F14 lands a guest kernel + rootfs, so declaring the tag today would assert a false fact. Do it when R605-F14 lands, not before.")
238#[derive(Debug, Clone, Serialize, Deserialize)]
239#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
240pub struct MachineConfig {
241 pub name: String,
242 pub provider: String,
243 /// Who the hardware actually comes from (`"ovh"`, `"vultr"`, `"on-prem"`).
244 ///
245 /// Deliberately *not* [`provider`](Self::provider), which selects the
246 /// auto-provision driver: a box we rented by hand and brought up over SSH
247 /// is `provider = "static"` for its whole life, and writing the vendor
248 /// there instead would flip it driver-backed and make
249 /// [`validate`](Self::validate) demand `location` + `server_type` it has no
250 /// answer for. The two axes genuinely differ — vendor is who bills you,
251 /// `provider` is who yah can call an API against.
252 ///
253 /// Worth recording because vendor-scoped policy is invisible in every other
254 /// field and decides real work: outbound port 25, rDNS/PTR control, IP
255 /// reputation, egress billing. It survived only in TOML prose until now,
256 /// which made it ungreppable at exactly the moment you need it.
257 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub vendor: Option<String>,
259 /// Human label for the box (`"gamer"`, `"the GEEKOM"`). Free-form and never
260 /// matched on — [`name`](Self::name) stays the identity everywhere. This is
261 /// only so operators and agents can say which box they mean out loud.
262 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub nickname: Option<String>,
264 /// Provider DC code (e.g. Hetzner `"hil"`). **Provisioning-only**: required
265 /// iff the provider has an auto-provision driver ([`provider_has_machine_driver`]);
266 /// a BYO `static` node we brought up over SSH has no such code. Optional at
267 /// load time so static machine.tomls omit it; [`MachineConfig::validate`]
268 /// enforces presence at the right moment for driver-backed providers.
269 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub location: Option<String>,
271 /// Provider SKU/size (e.g. Hetzner `"ccx13"`). Provisioning-only, same
272 /// optionality contract as [`location`](Self::location).
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub server_type: Option<String>,
275 /// **Deprecated (R330-F16).** A machine should describe *itself* (region,
276 /// zone, provider, mesh_tags); *which* mirrors run on it is derived by the
277 /// reconciler from each mirror's `required` placement spec, not declared
278 /// here. Now optional + omitted-when-empty so new machine.tomls leave it
279 /// out. The legacy `resolve_mirror_machine` topology fallback still reads
280 /// it until yubaba's reverse-index supersedes the topology.toml path; once
281 /// that lands, this field and its readers are removed wholesale.
282 #[serde(default, skip_serializing_if = "Vec::is_empty")]
283 pub hosts_mirrors: Vec<String>,
284 /// Positive placement facts about this node, matched as a **superset**:
285 /// a workload is admitted only where every tag it requires is present, so
286 /// adding a tag can only ever make a machine match more, never fewer.
287 ///
288 /// Four namespaces are live, and they are not interchangeable:
289 /// - `tag:<role>` — a role the operator assigns (`tag:build-worker`,
290 /// `tag:qed`, `tag:cloud-runner`, `tag:mac-builder`);
291 /// - `arch:<x86|arm>` / `os:<linux|darwin>` — facts about the silicon and
292 /// userland, emitted as *requirements* by
293 /// [`qed::platform::build_worker_mesh_tags`];
294 /// - `cap:<capability>` — something the node's daemons were configured to
295 /// be able to do. Today just [`NATIVE_EXEC_MESH_TAG`] (R860-T5);
296 /// - `tier:` is **retired** for architecture (R763) and reserved for the
297 /// environment axis — [`crate::validate::check_retired_arch_tags`]
298 /// flags a machine still carrying `tier:<arch>`.
299 ///
300 /// Nothing validates the prefix, which is why the lint above exists: a tag
301 /// nobody requires is silently inert, and a *stale* one silently stops
302 /// matching and reports "no node" rather than "wrong tag".
303 pub mesh_tags: Vec<String>,
304 /// Canonical geo region label (latency axis), e.g. `"us-west"`. F16's three
305 /// topology axes are orthogonal: `region` = geo (latency), `zone` = failure
306 /// domain within a region (HA), `provider` = network/cost. `region` is
307 /// distinct from `location` (the provider's DC code, e.g. Hetzner `"hil"`):
308 /// `location` is provider-scoped, `region` is our provider-neutral label.
309 /// Optional for backward-compat; a machine without it never satisfies a
310 /// `required.regions` constraint.
311 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub region: Option<String>,
313 /// Failure-domain label within a region (HA axis), e.g. `"hil"`. For
314 /// single-DC Hetzner this typically mirrors `location`. F16 placement
315 /// matches `required.zones` against this. Optional for backward-compat.
316 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub zone: Option<String>,
318 /// Declared CPU architecture (`"x86_64"` / `"aarch64"`). A machine has
319 /// exactly one — it's a first-class property of the box, not a reach
320 /// detail and not a mesh tag. Drives the yubaba release triple. Optional
321 /// only because there's no provider API to probe it (static nodes declare
322 /// it; a driver-backed provider may leave it unset until known).
323 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub arch: Option<String>,
325 pub bucket: Option<BucketSpec>,
326 /// **Legacy location, superseded by `[registration].hostkey_fingerprint`**
327 /// (R707-T1). Still deserialized so machine TOMLs written before the split
328 /// keep parsing; never *read* directly — go through
329 /// [`MachineConfig::hostkey_fingerprint`], which prefers the registration
330 /// block. [`MachineConfig::normalize`] folds this into `registration`, and
331 /// [`MachineConfig::save`] normalizes before writing, so a load→save cycle
332 /// migrates the file rather than dropping the value.
333 #[serde(
334 rename = "hostkey_fingerprint",
335 default,
336 skip_serializing_if = "Option::is_none"
337 )]
338 pub legacy_hostkey_fingerprint: Option<String>,
339 /// Provider-side SSH-key IDs (Hetzner: from `GET /v1/ssh_keys`)
340 /// authorized for `root` at create time. Defaults to empty for
341 /// backwards-compat with existing machine declarations; an empty
342 /// list yields a Hetzner-emailed random root password (which the
343 /// driver currently discards). Populate this when you want pre-mesh
344 /// SSH access for bootstrap deploys or recovery.
345 #[serde(default, skip_serializing_if = "Vec::is_empty")]
346 pub ssh_keys: Vec<u64>,
347 /// Cloudflare Tunnel ID this machine joins (e.g. `abc123.cfargotunnel.com`).
348 /// `None` → no tunnel (mesh-only node, no public ingress).
349 /// When set, `yah cloud machine provision` reads `cloudflare-tunnel-token`
350 /// from the keys vault and injects the cloudflared install block into
351 /// cloud-init so the new machine connects to CF edge on first boot.
352 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub cloudflared: Option<String>,
354 /// Provider-issued floating/reserved IP that follows **public-ingress
355 /// ownership** onto this box — R859-F2 (W267 §Tier 1).
356 ///
357 /// The value is the provider's own identifier, opaque here and interpreted
358 /// only by the matching adapter: a Hetzner numeric floating-IP id as a
359 /// string, an OVH Additional-IP address (`"51.81.85.200"`), a Vultr
360 /// reserved-IP UUID. Same "the adapter is the boundary" convention
361 /// [`crate::envoy::floating_ip::FloatingIpAssignInput::ip_id`] documents.
362 ///
363 /// # Why it lives on the machine
364 ///
365 /// [`crate::envoy::floating_ip`] shipped the `floating_ip.*` verbs and
366 /// three provider adapters with no config anywhere saying *which* floating
367 /// IP is "the" ingress IP — the gap R594-F5 recorded and deliberately left.
368 /// This is that field, and it sits beside [`cloudflared`](Self::cloudflared)
369 /// on purpose: that is already the per-node "how the world reaches this
370 /// box" handle, and a floating IP is the sovereign-tier answer to the same
371 /// question. `[[ingress]]`'s
372 /// [`tunnel_id`](crate::config::IngressEdge::tunnel_id) is the *service*
373 /// side of ingress identity — which cohort a given service fronts through —
374 /// and a floating IP is not per-service: one IP moves between boxes, so it
375 /// cannot be partitioned by slot or hostname.
376 ///
377 /// # Absent means "no floating-IP path", never an error
378 ///
379 /// Most machines have none, and that is the normal case: mesh-only nodes,
380 /// boxes behind a Cloudflare tunnel, and every provider without a
381 /// floating-IP adapter. The effector skips such a machine cleanly rather
382 /// than refusing — see
383 /// [`plan_ingress_owner_effect`](crate::provider::floating_ip::plan_ingress_owner_effect).
384 ///
385 /// # The cohort has to agree
386 ///
387 /// Every machine that can hold the same ingress IP must declare the *same*
388 /// id: the IP is one resource that moves, so two ids inside one
389 /// [`sovereign_group`](Self::sovereign_group) means an ownership flip
390 /// silently reassigns a *different* IP than the one currently serving
391 /// traffic. `yah cloud validate` refuses that
392 /// ([`crate::validate::check_ingress_floating_ip`]) rather than leaving it
393 /// to be discovered during a failover.
394 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub ingress_floating_ip: Option<String>,
396 /// When `true`, this machine hosts operator-bridge workloads (Tailscale
397 /// operator access to mesh-internal services). `yah cloud machine provision`
398 /// will install tailscaled and run `tailscale up` during cloud-init via the
399 /// `{{OPERATOR_BRIDGE_BLOCK}}` placeholder. Defaults to `false` for
400 /// backward-compat with existing machine declarations.
401 #[serde(default)]
402 pub hosts_operator_bridge: bool,
403 /// BYO `static`-node reach descriptor. Static nodes have no provider API to
404 /// probe, so how the camp reaches them (SSH user@host + the yubaba URL,
405 /// which is loopback until the WireGuard mesh lands) is *declared* here.
406 /// `None` for driver-backed providers (Hetzner/Vultr), whose address is
407 /// resolved from the provider API / mesh at provision time.
408 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub connect: Option<ConnectSpec>,
410 /// Static node capacity (R572-F3). Declares the node's total hardware
411 /// budget; F5's scheduler subtracts committed workload requests from this
412 /// to check whether a new workload fits. Absent means unconstrained.
413 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub allocatable: Option<NodeAllocatable>,
415 /// Placement taint keys (R572-F3). **There is no toleration** — a
416 /// `no-<archetype>` taint is an absolute block, not a preference
417 /// (W305/R742-T4; the pre-2026-08-11 "repel-unless-tolerate" wording here
418 /// described an `unless` that was never built).
419 ///
420 /// A key in this list influences placement in exactly one of two ways, and
421 /// [`taint_effect`] is the authority on which:
422 ///
423 /// - **repulsion** — `"no-server"` / `"no-appliance"` / `"no-job"` reject
424 /// workloads of that [`LifecycleArchetype`] outright;
425 /// - **affinity** — a key in [`AFFINITY_TAINT_KEYS`] (today just
426 /// `"public-ip"`) that a workload names in
427 /// `yah.placement.requires-taint`, which then *requires* this node.
428 ///
429 /// Anything else is **inert**: it parses, it round-trips, and no scheduler
430 /// decision can ever read it. `yah cloud validate` rejects such keys
431 /// (`validate::check_inert_taints`) rather than letting them sit looking
432 /// load-bearing — which is how `no-voter` spent months asserting a
433 /// falsehood on three nodes. Facts about a node that are not placement
434 /// inputs belong in [`mesh_tags`](Self::mesh_tags) or a comment.
435 #[serde(default, skip_serializing_if = "Vec::is_empty")]
436 pub taints: Vec<String>,
437 /// Which consensus group this node belongs to — W305/R742-F1. `None` means
438 /// standalone: in no group at all, which is us-west-002 and us-west-015.
439 ///
440 /// Membership is not by itself quorum eligibility; that is
441 /// [`sovereign_role`](Self::sovereign_role), added by R605-F12 because
442 /// us-west-003 is in prod's blast radius *and* must never vote in it.
443 ///
444 /// **Not a placement input.** It is deliberately absent from
445 /// [`RequiredSpec::matches`], and adding it there would be a category
446 /// error: a sovereign group is a *blast radius*, not a filter. Nothing
447 /// about "which quorum does this box vote in" should decide where a
448 /// workload runs — that is what made the fleet express three unrelated
449 /// properties through one taint list and get all three wrong (W305).
450 ///
451 /// What it *is* for is refusal. [`judge_join`] answers "may this node join
452 /// that node's cluster", and the answer is no unless both declare the same
453 /// group. Before this field the only guard was a comment in three machine
454 /// TOMLs saying "never run a raft join against this box from a shell
455 /// pointed at prod" — habit, with no mechanism behind it, which is the
456 /// same class of guard W257 §8 admitted to.
457 ///
458 /// # Why `sovereign_group` and not `raft_group`
459 ///
460 /// Raft is today's mechanism (operator, 2026-08-10). A field named for the
461 /// mechanism goes stale the day the mechanism is swapped, and every
462 /// consumer that reads it inherits the lie. `sovereign` names what the
463 /// group *has* — its own authority, its own upgrade cadence, its own
464 /// destruction — which stays true under any consensus protocol.
465 ///
466 /// Note the word already appears in this tree as prose (W267's title, the
467 /// `IngressProvider::Passway` doc comment's "sovereign edge"). That is an
468 /// adjective meaning "self-hosted, not SaaS"; this is the first time it
469 /// carries structure.
470 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub sovereign_group: Option<String>,
472 /// Whether this node may hold a seat in its group's quorum — R605-F12.
473 /// Meaningless without [`sovereign_group`](Self::sovereign_group): a
474 /// standalone box has no quorum to be eligible for.
475 ///
476 /// **`None` is "not written", not a third role.** Read it through
477 /// [`sovereign_membership`](Self::sovereign_membership), which resolves the
478 /// absence to [`SovereignRole::Voter`] — what declaring a group has always
479 /// meant, so the six nodes stamped before this field keep their seats
480 /// without an edit. The distinction is kept only so
481 /// [`crate::validate::check_unroled_sovereign_members`] can tell an
482 /// operator who *chose* voter from one who never considered the question;
483 /// no join decision reads the `Option` directly.
484 ///
485 /// # Why this is not a taint
486 ///
487 /// It was, once: `no-voter` sat in [`taints`](Self::taints) on three nodes
488 /// for months, read by nothing, and R742-T4 removed it because the taint
489 /// list is a *placement* vocabulary and this is not a placement input (see
490 /// [`taint_effect`]). Nor is it a second group label. It is a modifier on
491 /// the membership this node already declares, which is why it lives beside
492 /// the group and is judged with it in one predicate,
493 /// [`workload_spec::sovereign::join_permitted`].
494 #[serde(default, skip_serializing_if = "Option::is_none")]
495 pub sovereign_role: Option<SovereignRole>,
496 /// `[registration]` — the observed half (R707-T1). Empty until the box has
497 /// been attached / mesh-joined. See [`MachineRegistration`].
498 #[serde(default, skip_serializing_if = "MachineRegistration::is_empty")]
499 pub registration: MachineRegistration,
500}
501
502/// True iff `provider` has an auto-provision driver (create/destroy via API).
503/// Driver-backed providers require `location` + `server_type`; BYO `static`
504/// nodes (brought up over SSH) do not. The cloud-vs-vps distinction the fleet
505/// cares about lives here — at the provider-capability layer — not as a
506/// separate machine type (W242 BYO Phase-0 decision).
507pub fn provider_has_machine_driver(provider: &str) -> bool {
508 matches!(provider, "hetzner" | "vultr" | "digitalocean")
509}
510
511/// Taint keys a workload may name in `yah.placement.requires-taint` to
512/// *require* a node (W305/R742-T4 affinity vocabulary).
513///
514/// This is a closed list on purpose. `WorkloadSpec::requires_taint` returns
515/// free text, but every producer in the tree is code — `passway_ingress.rs`
516/// and `cloudflared_ingress.rs`, both emitting
517/// [`workload_spec::PUBLIC_IP_TAINT`] — and no on-disk `workload.toml` sets the
518/// annotation at all. So the set of keys a node can usefully carry for
519/// affinity is knowable at compile time, which is what lets
520/// [`taint_effect`] call anything outside it inert instead of guessing.
521///
522/// **Adding an affinity key means adding it here**, in the same change that
523/// teaches a workload to require it. That coupling is the point: it makes the
524/// node side and the workload side impossible to land apart.
525pub const AFFINITY_TAINT_KEYS: &[&str] = &[workload_spec::PUBLIC_IP_TAINT];
526
527/// How a key in [`MachineConfig::taints`] can affect placement.
528///
529/// W305 finding 1: before R742-T4 nothing asked this question, so a key that
530/// no scheduler path could read — `"qa"`, `"no-voter"` — parsed, validated,
531/// and quietly did nothing. Both of the findings that cost real fleet state
532/// were invisible for exactly that reason.
533#[derive(Debug, Clone, Copy, PartialEq, Eq)]
534pub enum TaintEffect {
535 /// `"no-<archetype>"`: rejects workloads of that archetype outright. Read
536 /// by [`RequiredSpec::matches`] via `repel_archetype`.
537 Repels(LifecycleArchetype),
538 /// A key in [`AFFINITY_TAINT_KEYS`]: a workload naming it in
539 /// `yah.placement.requires-taint` is restricted to nodes carrying it.
540 Attracts,
541 /// Neither. No placement decision can read this key.
542 Inert,
543}
544
545/// Classify one node taint key. See [`TaintEffect`].
546///
547/// The repulsion half is derived from [`LifecycleArchetype::ALL`] rather than
548/// a literal list, so a fourth archetype makes `no-<its key>` live without an
549/// edit here.
550pub fn taint_effect(key: &str) -> TaintEffect {
551 if let Some(arch) = LifecycleArchetype::ALL
552 .into_iter()
553 .find(|a| key == format!("no-{}", a.taint_key()))
554 {
555 return TaintEffect::Repels(arch);
556 }
557 if AFFINITY_TAINT_KEYS.contains(&key) {
558 return TaintEffect::Attracts;
559 }
560 TaintEffect::Inert
561}
562
563/// Every key the scheduler *can* act on, sorted — for error messages that
564/// tell the operator what the legal vocabulary actually is instead of only
565/// what was wrong.
566pub fn live_taint_keys() -> Vec<String> {
567 let mut keys: Vec<String> = LifecycleArchetype::ALL
568 .into_iter()
569 .map(|a| format!("no-{}", a.taint_key()))
570 .chain(AFFINITY_TAINT_KEYS.iter().map(|k| (*k).to_string()))
571 .collect();
572 keys.sort();
573 keys
574}
575
576/// What [`judge_join`] decided about one proposed cluster join.
577///
578/// Shaped like yubaba's `PromotionVerdict` / `GeographyVerdict` and for the
579/// same reason: the rule stays unit-testable without a live cluster, and a
580/// refusal carries its reason from the place that knows it.
581#[derive(Debug, Clone, PartialEq, Eq)]
582pub enum JoinVerdict {
583 /// Both nodes declare the same sovereign group and both are voters. The
584 /// join is within one blast radius and grows a quorum both sides are
585 /// eligible for.
586 Permit,
587 /// The join is refused. Carries an operator-readable reason naming both
588 /// declared values and the file to edit — a refusal that only says
589 /// "invalid" gets worked around rather than fixed.
590 Refuse(String),
591}
592
593/// May `joiner` join the cluster `target` belongs to? — W305/R742-F1.
594///
595/// **A join is permitted iff both nodes declare the same non-`None`
596/// [`sovereign_group`](MachineConfig::sovereign_group) and both are
597/// [`SovereignRole::Voter`].** One rule, no special cases, and it makes the
598/// declaration mandatory before any quorum grows.
599///
600/// The case this exists for is two *different* declared groups: joining a dev
601/// Pi into prod is refused rather than trusted, where today the only guard is
602/// a comment saying not to do it. But an undeclared node is refused too, and
603/// that is the deliberate half — `None` means "in no group", not "unknown", so
604/// growing prod with an unstamped box is exactly as much a cross-group join as
605/// the dev case is. Failing open there would leave the operator believing a
606/// guarantee that was never evaluated, which is the reasoning
607/// `QuorumGeography::judge` already applies to untagged voters.
608///
609/// No legitimate flow pays for that strictness: prod and dev are both stamped,
610/// and us-west-002/015 are deliberately in no group at all. Adding a real
611/// member means declaring it first, which is the point.
612///
613/// # The non-voting refusal (R605-F12)
614///
615/// Same group and still refused, when either side declares
616/// [`SovereignRole::NonVoter`]. This is the case a group label alone could not
617/// express. us-west-003 is a residential-uplink build box the operator counts
618/// as part of prod — same secrets, same upgrade cadence, same destruction — and
619/// which must never hold a prod raft seat, because a home-internet partition
620/// should not be able to stall the quorum. Until R605-F12 the only thing
621/// refusing it was its *absent* stamp, so recording the operator's real intent
622/// (`sovereign_group = "prod"`) would have removed the guard. Now the intent
623/// and the guard are the same two lines.
624///
625/// Note what this is not: the refusal here is about *voting*, and it says
626/// nothing about the mesh. One mesh spans the whole fleet regardless of group
627/// or role (operator, 2026-08-19); a non-voter is reachable, schedulable and
628/// rollable like any other node.
629///
630/// This is the **camp-side** rendering of the rule. The predicate itself lives
631/// in [`workload_spec::sovereign::join_permitted`] because yubaba's
632/// `POST /raft/add-learner` gate asks the same question and cannot see this
633/// crate (there is deliberately no yubaba → cloud edge). Only the prose is
634/// duplicated, and it has to be: a refusal here names
635/// `.yah/infra/machines/<name>.toml`, while the node-side one has no machine
636/// name in hand and must also name `yubaba serve --sovereign-group`.
637///
638/// The node-side gate is *narrower* on purpose, and the difference is worth
639/// knowing when reading either: a daemon started without `--sovereign-group`
640/// has declared nothing rather than declared standalone, so yubaba resolves
641/// that unknown before it judges, and its gate is in force only once the
642/// cluster being joined declares a group. See `yubaba::sovereign_group`.
643pub fn judge_join(joiner: &MachineConfig, target: &MachineConfig) -> JoinVerdict {
644 let stamp_hint = |m: &MachineConfig| {
645 format!(
646 "declare `sovereign_group = \"<group>\"` in .yah/infra/machines/{}.toml",
647 m.name
648 )
649 };
650 let role_hint = |m: &MachineConfig| {
651 format!(
652 "set `sovereign_role = \"voter\"` in .yah/infra/machines/{}.toml",
653 m.name
654 )
655 };
656 if workload_spec::sovereign::join_permitted(
657 joiner.sovereign_membership(),
658 target.sovereign_membership(),
659 ) {
660 return JoinVerdict::Permit;
661 }
662 let (j, t) = (
663 joiner.sovereign_group.as_deref(),
664 target.sovereign_group.as_deref(),
665 );
666 // Everything below is a refusal; the only permitted shape returned above.
667 //
668 // R605-F12: when both sides name the SAME group, the role is the only thing
669 // left that can have refused, and it gets its own message. Falling through
670 // to the arms below would print "cross-group join refused: 'us-west-003' is
671 // in "prod" and 'us-west-001' is in "prod"" — a message that reads as a bug
672 // in the check rather than a decision about the fleet.
673 //
674 // Deliberately not hoisted above the group comparison. A non-voting joiner
675 // whose target is standalone is refused for *both* reasons, and naming the
676 // role there would send the operator to fix a field that would not have
677 // made the join legal anyway.
678 if let (Some(a), Some(b)) = (j, t) {
679 if a == b {
680 for (m, side, other) in [
681 (joiner, "the joiner", &target.name),
682 (target, "the target", &joiner.name),
683 ] {
684 if m.sovereign_membership().role.is_voter() {
685 continue;
686 }
687 return JoinVerdict::Refuse(format!(
688 "join refused: {side} '{}' is a NON-VOTING member of sovereign group {a:?}, \
689 the same group as '{other}'. It is inside that blast radius — same secrets, \
690 same upgrade cadence, same destruction — but declares itself ineligible for \
691 the quorum, so this is refused by declaration rather than by omission. If it \
692 should genuinely vote, {}; if it should not, this refusal is the field doing \
693 its job and the join is the thing to reconsider.",
694 m.name,
695 role_hint(m),
696 ));
697 }
698 }
699 }
700 match (j, t) {
701 (Some(a), Some(b)) => JoinVerdict::Refuse(format!(
702 "cross-group join refused: '{}' is in sovereign group {a:?} and '{}' is in {b:?}. \
703 These are separate blast radii — separate quorums, separate upgrade cadences, \
704 separately destroyable — and merging them is not something a join can undo. If \
705 the move is genuinely intended, restamp '{}' to {b:?} first and treat it as \
706 leaving its old group.",
707 joiner.name,
708 target.name,
709 joiner.name,
710 )),
711 (None, Some(b)) => JoinVerdict::Refuse(format!(
712 "join refused: '{}' declares no sovereign_group, so it is standalone — in no \
713 group — while '{}' is in {b:?}. That is a cross-group join, not an unchecked \
714 one. To make '{}' a member of {b:?}, {}.",
715 joiner.name,
716 target.name,
717 joiner.name,
718 stamp_hint(joiner),
719 )),
720 (Some(a), None) => JoinVerdict::Refuse(format!(
721 "join refused: '{}' is in sovereign group {a:?} but '{}' declares none, so the \
722 target is standalone and has no group to join. Either {}, or found the group on \
723 '{}' rather than growing it.",
724 joiner.name,
725 target.name,
726 stamp_hint(target),
727 joiner.name,
728 )),
729 (None, None) => JoinVerdict::Refuse(format!(
730 "join refused: neither '{}' nor '{}' declares a sovereign_group, so this join \
731 would form a group nobody declared and nothing could later reason about. Name \
732 the group on both boxes first: {}, and the same for '{}'.",
733 joiner.name,
734 target.name,
735 stamp_hint(joiner),
736 target.name,
737 )),
738 }
739}
740
741impl MachineConfig {
742 /// This node's declared place in a sovereign group, as the shared join rule
743 /// wants it — R605-F12.
744 ///
745 /// The one place `sovereign_role`'s `None` is resolved. Absence means
746 /// [`SovereignRole::Voter`], which is what declaring a group meant before
747 /// the role existed; resolving it here rather than at each call site is what
748 /// keeps the camp-side and node-side gates from disagreeing about a node
749 /// that never wrote the field.
750 pub fn sovereign_membership(&self) -> Membership<'_> {
751 Membership {
752 group: self.sovereign_group.as_deref(),
753 role: self.sovereign_role.unwrap_or_default(),
754 }
755 }
756
757 /// Provider DC code, or `""` when omitted (static nodes). Most readers want
758 /// a `&str`; the driver-backed provision/status paths still go through
759 /// [`validate`](Self::validate) which guarantees presence for those.
760 pub fn location(&self) -> &str {
761 self.location.as_deref().unwrap_or("")
762 }
763
764 /// Provider SKU, or `""` when omitted (static nodes).
765 pub fn server_type(&self) -> &str {
766 self.server_type.as_deref().unwrap_or("")
767 }
768
769 /// Enforce the provisioning-only-field contract: a machine whose provider
770 /// has an auto-provision driver MUST declare `location` + `server_type`
771 /// (the driver can't create a server without them). Static nodes may omit
772 /// both. Call this before any provision/diff that assumes a driver.
773 pub fn validate(&self) -> Result<()> {
774 if provider_has_machine_driver(&self.provider) {
775 if self.location.is_none() {
776 anyhow::bail!(
777 "machine '{}' (provider '{}') has an auto-provision driver but no `location`",
778 self.name,
779 self.provider
780 );
781 }
782 if self.server_type.is_none() {
783 anyhow::bail!(
784 "machine '{}' (provider '{}') has an auto-provision driver but no `server_type`",
785 self.name,
786 self.provider
787 );
788 }
789 }
790 Ok(())
791 }
792
793 /// Declared taints that no placement decision can read (W305/R742-T4).
794 ///
795 /// Deliberately **not** folded into [`validate`](Self::validate): that
796 /// guard runs on the provision/diff hot path and answers a different
797 /// question (can the driver create this server). An inert taint is a lint
798 /// — it never breaks an operation in flight, it just means the file is
799 /// asserting something the scheduler will not honour. `yah cloud validate`
800 /// is where the operator asks for that judgement; see
801 /// [`crate::validate::check_inert_taints`].
802 pub fn inert_taints(&self) -> Vec<&str> {
803 self.taints
804 .iter()
805 .filter(|t| taint_effect(t) == TaintEffect::Inert)
806 .map(String::as_str)
807 .collect()
808 }
809
810 /// Yubaba's TOFU'd hostkey fingerprint, from `[registration]` and falling
811 /// back to the pre-R707-T1 top-level field. **The only read path** — a
812 /// caller that reaches for `legacy_hostkey_fingerprint` directly sees
813 /// `None` on every migrated machine.
814 pub fn hostkey_fingerprint(&self) -> Option<&str> {
815 self.registration
816 .hostkey_fingerprint
817 .as_deref()
818 .or(self.legacy_hostkey_fingerprint.as_deref())
819 }
820
821 /// Record (or clear) the observed hostkey fingerprint. Writes
822 /// `[registration]` and drops any pre-R707-T1 top-level value, so the two
823 /// locations can never disagree after a writeback.
824 pub fn set_hostkey_fingerprint(&mut self, fingerprint: Option<String>) {
825 self.registration.hostkey_fingerprint = fingerprint;
826 self.legacy_hostkey_fingerprint = None;
827 }
828
829 /// Mesh (tailnet) IPv4 for this node, or `None` pre-mesh.
830 ///
831 /// Prefers `[registration].mesh_ipv4`; falls back to the host of a legacy
832 /// `[connect].yubaba` URL when that host is in the `100.64.0.0/10` CGNAT
833 /// range the mesh uses. A loopback placeholder (`http://127.0.0.1:7443`,
834 /// meaning "pre-mesh, reachable only through an SSH tunnel") is *not* a
835 /// mesh address and yields `None`.
836 pub fn mesh_ipv4(&self) -> Option<&str> {
837 if let Some(ip) = self.registration.mesh_ipv4.as_deref() {
838 return Some(ip);
839 }
840 let url = self.connect.as_ref()?.yubaba.as_deref()?;
841 mesh_ipv4_from_url(url)
842 }
843
844 /// Base URL for this node's yubaba, or `None` when no reach resolves.
845 ///
846 /// Thin wrapper over [`reach`](Self::reach) for the many call sites that
847 /// only branch on presence. Prefer `reach` anywhere the operator sees the
848 /// outcome — a `None` here throws away a refusal that names exactly which
849 /// address is missing.
850 pub fn yubaba_url(&self) -> Option<String> {
851 self.reach().ok()
852 }
853
854 /// The **one** address automation dials for this node — mesh-only.
855 ///
856 /// `Err` is a *named refusal*, not an absence: a node with no mesh address
857 /// is unresolvable to every automated path, and R605-T10's whole complaint
858 /// is that this used to surface as a connect timeout against an address the
859 /// caller has no route to.
860 ///
861 /// Resolution order:
862 ///
863 /// 1. A declared `[connect].yubaba` on a **private** host (10/8,
864 /// 172.16/12, 192.168/16) is **not dialed** — see below.
865 /// 2. Any other declared `[connect].yubaba` wins verbatim. That includes
866 /// the pre-mesh loopback placeholder (`http://127.0.0.1:7443`, "I have
867 /// no mesh address; reach me through the SSH tunnel to `ssh`"), which is
868 /// a genuine declaration and stays honoured.
869 /// 3. Otherwise `[registration].mesh_ipv4` composed with
870 /// `[connect].yubaba_port`.
871 ///
872 /// **Why a LAN literal loses (R605-T10, operator 2026-08-19).** The LAN
873 /// address is an emergency break-glass route, never an official one, and
874 /// automation must ALWAYS assume the caller is not on that LAN — this camp
875 /// sits on 192.168.22.0/22 with no route to the fleet's 192.168.10.0/24 at
876 /// all. Writing one into the field every resolver dials does not sit beside
877 /// the mesh route, it *overrides* it: R707-T6 made a declared literal beat
878 /// `mesh_ipv4` outright, so us-west-011 (mesh-joined, healthy) was elected
879 /// for every aarch64 build and then dialed at an address that answers only
880 /// from inside bldg-2506.
881 ///
882 /// **What R707-T6 wanted is preserved elsewhere.** Its forcing case was
883 /// identity, not reach: the dev raft group advertises LAN addrs
884 /// (`192.168.10.11:7443`, verified live off `/raft/status` 2026-08-27), and
885 /// `rollout::yubaba::membership_to_nodes` has to map those back to declared
886 /// machines. That match now runs against [`lan_endpoint`](Self::lan_endpoint),
887 /// which is composed from the break-glass `[connect].address` metadata and
888 /// is never dialed — so the two concerns the old precedence rule fused are
889 /// split, and the literal can stop squatting a dialed field.
890 ///
891 /// The LAN address itself STAYS in the machine TOML. It is useful metadata
892 /// and the manual `ssh` path is entitled to it; it is only disconnected
893 /// from every automated process.
894 pub fn reach(&self) -> Result<String, String> {
895 let Some(connect) = self.connect.as_ref() else {
896 return Err(format!(
897 "machine {:?} declares no [connect] block, so nothing knows how to reach it \
898 \u{2192} declare one, or leave it unprovisioned and out of placement",
899 self.name
900 ));
901 };
902 let mesh = || {
903 self.registration
904 .mesh_ipv4
905 .as_deref()
906 .map(|ip| format!("http://{ip}:{}", connect.yubaba_port()))
907 };
908 if let Some(literal) = &connect.yubaba {
909 let Some(lan) = private_ipv4_from_url(literal) else {
910 return Ok(literal.clone());
911 };
912 return mesh().ok_or_else(|| {
913 format!(
914 "machine {:?} is unresolvable to automation: its only declared yubaba reach \
915 is the private literal {:?} and it has no [registration].mesh_ipv4\n\
916 \u{2192} a LAN address is an emergency break-glass route, never an official \
917 one (R605-T10) — every automated path assumes the caller is NOT on {}/24\n\
918 \u{2192} mesh-join the box and record `mesh_ipv4` under [registration], then \
919 delete `[connect].yubaba` so the port composes with it",
920 self.name,
921 literal,
922 lan.rsplit_once('.').map(|(net, _)| net).unwrap_or(lan),
923 )
924 });
925 }
926 mesh().ok_or_else(|| {
927 format!(
928 "machine {:?} has no [registration].mesh_ipv4 and declares no \
929 [connect].yubaba, so no automated path can reach it\n\
930 \u{2192} mesh-join the box and record its tailnet address, or taint it out of \
931 placement — do not point `[connect].yubaba` at a LAN address (R605-T10)",
932 self.name
933 )
934 })
935 }
936
937 /// The LAN `host:port` this node's yubaba answers on, composed from the
938 /// break-glass `[connect].address` metadata plus the declared port.
939 ///
940 /// **Identity only — never dial this.** It exists so a raft membership
941 /// entry that names a node by its LAN address can be mapped back to the
942 /// declared machine (`rollout::yubaba::membership_to_nodes`) without that
943 /// address having to live in a field a resolver reads. `None` when the
944 /// machine is unprovisioned.
945 pub fn lan_endpoint(&self) -> Option<String> {
946 let connect = self.connect.as_ref()?;
947 Some(format!("{}:{}", connect.address, connect.yubaba_port()))
948 }
949
950 /// Fold the pre-R707-T1 top-level `hostkey_fingerprint` into
951 /// `[registration]`, and lift a mesh IP out of a legacy `[connect].yubaba`
952 /// URL. Idempotent; a machine already on the split shape is untouched.
953 ///
954 /// [`save`](Self::save) calls this, so writing a machine TOML migrates it
955 /// rather than round-tripping the old shape back out.
956 pub fn normalize(&mut self) {
957 if let Some(fp) = self.legacy_hostkey_fingerprint.take() {
958 self.registration.hostkey_fingerprint.get_or_insert(fp);
959 }
960 if self.registration.mesh_ipv4.is_none() {
961 if let Some(ip) = self
962 .connect
963 .as_ref()
964 .and_then(|c| c.yubaba.as_deref())
965 .and_then(mesh_ipv4_from_url)
966 .map(str::to_string)
967 {
968 self.registration.mesh_ipv4 = Some(ip);
969 // The URL was pure derivation from mesh IP + port; keep only
970 // the declared half so the two can't drift apart.
971 if let Some(c) = self.connect.as_mut() {
972 c.yubaba = None;
973 }
974 }
975 }
976 }
977
978 /// Persist to `<cloud_dir>/machines/<name>.toml`, creating the dir if needed.
979 ///
980 /// ⚠ Serializes the struct, so **operator comments in the target file are
981 /// lost**. Pre-existing behaviour, not introduced here, but it is why
982 /// registration writeback (`yah cloud machine attach`) goes through
983 /// [`crate::state::MachineState`] and the comment-preserving path in the
984 /// CLI rather than calling this on a hand-authored inventory file.
985 pub fn save(&self, cloud_dir: &Path) -> Result<()> {
986 let dir = cloud_dir.join("machines");
987 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
988 let path = dir.join(format!("{}.toml", self.name));
989 let mut normalized = self.clone();
990 normalized.normalize();
991 let s = toml::to_string_pretty(&normalized)
992 .with_context(|| format!("serializing machine {}", self.name))?;
993 std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
994 }
995}
996
997/// Host of an `http://host:port` URL iff it is a mesh (headscale) IPv4 in the
998/// `100.64.0.0/10` CGNAT range. String-level rather than URL-parsed: the
999/// inventory format is stable and this crate carries no URL dependency (same
1000/// reasoning as `fleet_metrics::extract_host` and
1001/// `hub::coordinator::is_loopback_url`).
1002fn mesh_ipv4_from_url(url: &str) -> Option<&str> {
1003 let host = ipv4_host_of(url)?;
1004 let ip: std::net::Ipv4Addr = host.parse().ok()?;
1005 let [a, b, ..] = ip.octets();
1006 // 100.64.0.0/10 ⇒ first octet 100, second octet 64..=127.
1007 (a == 100 && (64..=127).contains(&b)).then_some(host)
1008}
1009
1010/// Host of an `http://host:port` URL iff it is an **RFC1918 private** IPv4 —
1011/// `10/8`, `172.16/12`, `192.168/16`. `None` for anything else, loopback and
1012/// the `100.64/10` mesh range included: neither is a LAN literal.
1013///
1014/// The judgement R605-T10 turns on. A private literal is only ever reachable
1015/// from inside one building, so it is metadata about where the box physically
1016/// sits and never an address automation may dial — see
1017/// [`MachineConfig::reach`] and [`crate::validate::check_lan_dial_targets`].
1018pub fn private_ipv4_from_url(url: &str) -> Option<&str> {
1019 let host = ipv4_host_of(url)?;
1020 is_private_ipv4(host).then_some(host)
1021}
1022
1023/// Whether a bare host string is an RFC1918 private IPv4 literal.
1024pub fn is_private_ipv4(host: &str) -> bool {
1025 let Ok(ip) = host.parse::<std::net::Ipv4Addr>() else {
1026 return false;
1027 };
1028 ip.is_private()
1029}
1030
1031/// Bare host of a `[scheme://]host[:port][/path]` string.
1032fn ipv4_host_of(url: &str) -> Option<&str> {
1033 let after_scheme = url.split("://").nth(1).unwrap_or(url);
1034 after_scheme.split(['/', ':']).next()
1035}
1036
1037/// Declared **reach** for a BYO `static` node (no provider API). Lives under
1038/// `[connect]` in the machine TOML.
1039///
1040/// Reach only — how the camp gets to the box. *Permission* is a separate axis
1041/// that belongs to cheers' scopes (W295 §"Deliberately deferred"); the two
1042/// collapse in practice today (mesh membership grants everything) and the data
1043/// model must not fuse them, so do not add an authorization field here.
1044///
1045/// `address` and `ssh` stay whole, literal, operator-authored strings even
1046/// though their values often *look* derived. They are not: us-west-001 dials
1047/// SSH over its public IP while us-west-002 was deliberately repointed at its
1048/// tailnet IP (R608-F10) precisely because the LAN address is unreachable
1049/// off-LAN. Decomposing them into user + host and recomposing would silently
1050/// undo per-machine decisions like that one. `yubaba` is the field that *was*
1051/// derived — mesh IP plus a fixed port, rewritten by mesh-join — so that is
1052/// where R707-T1 cut.
1053#[derive(Debug, Clone, Serialize, Deserialize)]
1054#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1055pub struct ConnectSpec {
1056 /// Reachable IPv4/host for the box, e.g. `"45.32.194.254"`. Declared: which
1057 /// of a machine's several addresses the camp should use is an operator
1058 /// choice (public IP vs. LAN IP vs. tailnet IP).
1059 pub address: String,
1060 /// SSH target the camp dials for bootstrap + (pre-mesh) tunneled deploys,
1061 /// e.g. `"root@45.32.194.254"` or `"struc@100.64.0.4"`. Uses the operator's
1062 /// `~/.ssh/yah` key. Declared, whole — see the type doc.
1063 pub ssh: String,
1064 /// Port yubaba listens on. Declared reach; defaults to 7443 when omitted,
1065 /// which is every machine in the fleet today. Composed with the *observed*
1066 /// [`MachineRegistration::mesh_ipv4`] by [`MachineConfig::yubaba_url`].
1067 #[serde(default, skip_serializing_if = "Option::is_none")]
1068 pub yubaba_port: Option<u16>,
1069 /// Explicit yubaba base URL, overriding the composed form.
1070 ///
1071 /// Two live uses, both genuine declarations: a pre-mesh node saying
1072 /// `"http://127.0.0.1:7443"` — "I have no mesh address; reach me through
1073 /// the SSH tunnel to `ssh`" — and any node whose yubaba is not at
1074 /// `mesh_ipv4:port`. A URL here whose host *is* a mesh IP is the
1075 /// pre-R707-T1 shape; [`MachineConfig::normalize`] lifts it into
1076 /// `[registration].mesh_ipv4` and clears this field so the two cannot
1077 /// drift apart.
1078 #[serde(default, skip_serializing_if = "Option::is_none")]
1079 pub yubaba: Option<String>,
1080}
1081
1082/// Default yubaba listen port, used when `[connect].yubaba_port` is omitted.
1083pub const DEFAULT_YUBABA_PORT: u16 = 7443;
1084
1085impl ConnectSpec {
1086 /// Declared yubaba port, defaulting to [`DEFAULT_YUBABA_PORT`].
1087 pub fn yubaba_port(&self) -> u16 {
1088 self.yubaba_port.unwrap_or(DEFAULT_YUBABA_PORT)
1089 }
1090}
1091
1092#[derive(Debug, Clone, Serialize, Deserialize)]
1093#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1094pub struct BucketSpec {
1095 pub name: String,
1096 pub public_read: bool,
1097}
1098
1099/// Per-camp mirror declaration from `.yah/cloud/mirrors/<id>/mirror.toml`
1100/// (folder form) or the legacy `.yah/cloud/mirrors/<id>.toml` (flat form).
1101///
1102/// The folder form is preferred for new mirrors so that per-mirror secrets
1103/// and override files can sit next to `mirror.toml` without polluting the
1104/// top-level `mirrors/` directory.
1105#[derive(Debug, Clone, Serialize, Deserialize)]
1106pub struct LegacyMirrorConfig {
1107 /// Logical camp name this mirror hosts, e.g. `"yah"` or `"noisetable"`.
1108 ///
1109 /// Serialised as `camp`; accepts the legacy `rig` spelling for files that
1110 /// predate the R137 rig→camp rename (one-time migration: `sed -i ''
1111 /// 's/^rig = /camp = /' ~/.yah/cloud/mirrors/*.toml`).
1112 #[serde(rename = "camp", alias = "rig")]
1113 pub camp: String,
1114 pub regions: Vec<String>,
1115 /// Workload names deployed as part of this mirror (references `workloads/<name>.toml`).
1116 /// Renamed from `services` in R092-F1; use `yah cloud config migrate-services-to-workloads`
1117 /// on repos that still have the old `services/` layout.
1118 #[serde(alias = "services")]
1119 pub workloads: Vec<String>,
1120 /// Base domain for Cloudflare-fronted services on this mirror's machines.
1121 /// Combined with the machine's `location` to build virtual-host names:
1122 /// e.g. `cloud_domain = "cloud.noisetable.example"` on machine in location
1123 /// `pdx` → Caddyfile site address `pdx.cloud.noisetable.example`.
1124 /// Optional: if unset the Caddyfile falls back to `:port` listeners.
1125 #[serde(default, skip_serializing_if = "Option::is_none")]
1126 pub cloud_domain: Option<String>,
1127}
1128
1129/// Error from loading or validating a single workload TOML file.
1130#[derive(Debug, Error)]
1131pub enum WorkloadConfigError {
1132 #[error("reading {path}: {source}")]
1133 Io {
1134 path: String,
1135 source: std::io::Error,
1136 },
1137 #[error("parsing {path}: {source}")]
1138 Toml {
1139 path: String,
1140 source: toml::de::Error,
1141 },
1142 #[error("invalid WorkloadSpec in {path}: {source}")]
1143 Shape {
1144 path: String,
1145 source: validate::ShapeError,
1146 },
1147}
1148
1149/// A workload declaration loaded from `.yah/cloud/workloads/<name>.toml`.
1150///
1151/// Each file is the human-authored TOML serialization of a [`WorkloadSpec`].
1152/// On load, the spec is validated against the shape layer; failures surface as
1153/// a [`CloudConfigError::Workload`] with the file path and field path.
1154#[derive(Debug, Clone, Serialize, Deserialize)]
1155pub struct WorkloadConfig {
1156 /// The validated spec.
1157 #[serde(flatten)]
1158 pub spec: WorkloadSpec,
1159}
1160
1161impl WorkloadConfig {
1162 /// Persist to `<cloud_dir>/workloads/<name>.toml`, creating the dir if needed.
1163 pub fn save(&self, cloud_dir: &Path) -> Result<()> {
1164 let dir = cloud_dir.join("workloads");
1165 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
1166 let path = dir.join(format!("{}.toml", self.spec.name));
1167 let s = toml::to_string_pretty(self)
1168 .with_context(|| format!("serializing workload {}", self.spec.name))?;
1169 std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
1170 }
1171}
1172
1173/// Error surfaced by [`CloudConfig::load`] when a workload TOML fails validation.
1174#[derive(Debug, Error)]
1175pub enum CloudConfigError {
1176 #[error(transparent)]
1177 Anyhow(#[from] anyhow::Error),
1178 #[error("workload validation failed: {0}")]
1179 Workload(WorkloadConfigError),
1180}
1181
1182/// Mirror-to-machine assignment table from `.yah/cloud/topology.toml`.
1183///
1184/// Declares which logical mirror names are assigned to which machines.
1185/// This is the source-canonical placement until yubaba raft observes it
1186/// (per the migration tracker in the arch doc).
1187#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1188pub struct TopologyConfig {
1189 /// Mirror→machine assignments.
1190 #[serde(default)]
1191 pub assignments: Vec<MirrorAssignment>,
1192 /// Declared buckets, logged by `yah cloud bucket create`.
1193 /// Source-canonical until yubaba raft observes actual placement.
1194 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1195 pub buckets: Vec<BucketLogEntry>,
1196}
1197
1198impl TopologyConfig {
1199 /// Load from a `topology.toml` file, returning `Default` when absent.
1200 pub fn load(path: &Path) -> Result<Self> {
1201 if !path.exists() {
1202 return Ok(Self::default());
1203 }
1204 let s =
1205 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
1206 toml::from_str(&s).with_context(|| format!("parsing {}", path.display()))
1207 }
1208
1209 /// Persist to `topology.toml`, creating parent dirs if needed.
1210 pub fn save(&self, path: &Path) -> Result<()> {
1211 if let Some(parent) = path.parent() {
1212 std::fs::create_dir_all(parent)
1213 .with_context(|| format!("creating {}", parent.display()))?;
1214 }
1215 let s = toml::to_string_pretty(self).context("serializing topology")?;
1216 std::fs::write(path, s).with_context(|| format!("writing {}", path.display()))
1217 }
1218
1219 /// Find a declared bucket by name.
1220 pub fn bucket_by_name(&self, name: &str) -> Option<&BucketLogEntry> {
1221 self.buckets.iter().find(|b| b.name == name)
1222 }
1223
1224 /// Find a mutable declared bucket by name.
1225 pub fn bucket_by_name_mut(&mut self, name: &str) -> Option<&mut BucketLogEntry> {
1226 self.buckets.iter_mut().find(|b| b.name == name)
1227 }
1228
1229 /// Returns true if the bucket is declared as cross-machine (no owning machine).
1230 pub fn is_cross_machine_bucket(&self, name: &str) -> bool {
1231 self.buckets
1232 .iter()
1233 .any(|b| b.name == name && b.machine.is_none())
1234 }
1235}
1236
1237/// One mirror→machine placement entry in `topology.toml`.
1238#[derive(Debug, Clone, Serialize, Deserialize)]
1239pub struct MirrorAssignment {
1240 /// Logical mirror name, e.g. `"noisetable-pdx"`.
1241 pub mirror: String,
1242 /// Machine that hosts this mirror, e.g. `"noisetable-pdx-1"`.
1243 pub machine: String,
1244}
1245
1246/// A bucket declaration logged in `topology.toml` by `yah cloud bucket create`.
1247#[derive(Debug, Clone, Serialize, Deserialize)]
1248pub struct BucketLogEntry {
1249 pub name: String,
1250 /// Machine that owns this bucket. `None` marks it as cross-machine
1251 /// (no single-machine ownership; requires an explicit declaration in
1252 /// `topology.toml` before `yah cloud bucket create` will proceed without
1253 /// `--machine`).
1254 #[serde(default, skip_serializing_if = "Option::is_none")]
1255 pub machine: Option<String>,
1256 /// Logical location of the bucket, e.g. `"pdx"`.
1257 pub location: String,
1258 /// Current declared policy: `"private"` | `"public-read"` | `"signed-only"`.
1259 #[serde(default = "default_bucket_policy")]
1260 pub policy: String,
1261}
1262
1263fn default_bucket_policy() -> String {
1264 "private".to_string()
1265}
1266
1267/// Per-service config from `.yah/cloud/services/<name>.toml`.
1268///
1269/// **Deprecated.** The `services/` layout was replaced by `workloads/` in R092-F1.
1270/// Kept to allow in-place reads for repos that haven't migrated yet; use
1271/// `yah cloud config migrate-services-to-workloads` to upgrade.
1272#[derive(Debug, Clone, Serialize, Deserialize)]
1273pub struct LegacyServiceConfig {
1274 pub name: String,
1275 pub image: String,
1276 pub version: String,
1277 #[serde(default)]
1278 pub env: HashMap<String, String>,
1279 #[serde(default)]
1280 pub ports: Vec<PortMapping>,
1281 #[serde(default)]
1282 pub mesh_only: bool,
1283 /// Network interface this service binds to exclusively (e.g. `"tailscale0"`).
1284 ///
1285 /// When set the compose renderer emits `network_mode: "host"` and the
1286 /// service is NOT joined to the shared compose bridge network. The service
1287 /// process must bind its listen socket to the named interface's IP — for
1288 /// Postgres this means setting `POSTGRES_LISTEN_ADDRESSES` to the node's
1289 /// `tailscale ip --4` output at first boot. See [`crate::mesh_service`] for
1290 /// the standard pg_hba.conf snippet and ufw rules to pair with this field.
1291 #[serde(default, skip_serializing_if = "Option::is_none")]
1292 pub bind_interface: Option<String>,
1293
1294 /// Tenant this service belongs to (W206 isolation axis). Absent in the
1295 /// service TOML → [`TenantId::singleton`], keeping single-tenant machines
1296 /// on one shared compose network. When a machine hosts services from two
1297 /// or more distinct tenants, the compose renderer (R558-T2) splits them
1298 /// into per-tenant `<tenant>-<tier>` networks so cross-tenant stacks on the
1299 /// same host are not bridged together.
1300 #[serde(default = "TenantId::singleton")]
1301 pub tenant: TenantId,
1302}
1303
1304#[derive(Debug, Clone, Serialize, Deserialize)]
1305pub struct PortMapping {
1306 pub host: u16,
1307 pub container: u16,
1308}
1309
1310/// A loaded service plus its per-environment mirrors.
1311///
1312/// Wraps the `service.toml` body and the directory of `mirrors/<env>.toml`
1313/// files that project the service onto concrete infra.
1314#[derive(Debug, Clone, Serialize, Deserialize)]
1315pub struct ServiceWithMirrors {
1316 pub service: ServiceConfig,
1317 /// Mirrors keyed by environment name (file stem of `mirrors/<env>.toml`).
1318 pub mirrors: BTreeMap<String, MirrorConfig>,
1319 /// Transform recipe names keyed by component id. Populated from each
1320 /// static-asset component's `workload.toml` at load time — not stored
1321 /// in service.toml. Only present for components that declare
1322 /// `[asset.derive.transform] recipe = "..."`.
1323 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1324 pub component_transform_recipes: BTreeMap<String, String>,
1325 /// Nodes each mirror's passway front door is placed on, keyed by env —
1326 /// exactly what [`MirrorConfig::passway_machines`] returns, with the envs
1327 /// that declare no passway edge left out.
1328 ///
1329 /// Derived at load time like `component_transform_recipes` above: it is
1330 /// stored in no TOML file. It exists so that a consumer of this wire type —
1331 /// the desktop `service_list` command, and through it the Services tab's
1332 /// custom-domain panel — never reconciles the two `ingress` spellings
1333 /// itself. An env present here with an **empty** list is a passway edge
1334 /// whose placement is co-located rather than declared; see
1335 /// [`MirrorConfig::passway_machines`] for why that is a different answer
1336 /// from being absent.
1337 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1338 pub passway_machines: BTreeMap<String, Vec<String>>,
1339}
1340
1341/// All cloud config loaded from a workspace root (the parent of `.yah/`).
1342///
1343/// Reads two trees:
1344/// - `.yah/infra/` — `machines/`, `providers/`
1345/// - `.yah/services/<svc>/` — `service.toml` + `mirrors/<env>.toml`
1346///
1347/// Pre-R215 fields (`legacy_mirrors`, `legacy_services`, `workloads`,
1348/// `topology`) are still populated from `.yah/cloud/` when present so
1349/// pre-R215 callers (compose.rs, bucket commands) keep compiling — they
1350/// just see empty collections in a post-B1 workspace where the legacy
1351/// data was deleted. These fields are scheduled for removal in B3-T3.
1352#[derive(Debug)]
1353pub struct CloudConfig {
1354 /// Workspace root that was loaded — useful for path-resolving
1355 /// component references on a [`ServiceComponent`].
1356 pub workspace_root: std::path::PathBuf,
1357
1358 // ─── R215+ tree ────────────────────────────────────────────────────────
1359 /// `.yah/infra/machines/<name>.toml`
1360 pub machines: Vec<MachineConfig>,
1361 /// `.yah/infra/providers/<id>.toml`
1362 pub providers: Vec<ProviderConfig>,
1363 /// Provenance for every entry in `machines` that came from a linked
1364 /// `.yah/infra/sources.toml` source rather than this camp's own
1365 /// `.yah/infra/machines/` (R615-F2 / W274). Keyed by
1366 /// [`MachineConfig::name`]; a name absent here is camp-local. Empty from
1367 /// [`CloudConfig::load_from_config_dir`] — see its doc for why sources
1368 /// don't apply to multi-root sibling trees.
1369 pub machine_origins: BTreeMap<String, InfraOrigin>,
1370 /// Same as [`machine_origins`](Self::machine_origins), keyed by
1371 /// [`ProviderConfig::id`].
1372 pub provider_origins: BTreeMap<String, InfraOrigin>,
1373 /// `.yah/services/<svc>/` — service.toml plus mirrors/<env>.toml.
1374 pub services: BTreeMap<String, ServiceWithMirrors>,
1375 /// `.yah/domains/<name>.toml` — public-facing routing manifests
1376 /// (R347). Single file per domain; no nested per-env tree because
1377 /// domains themselves aren't projected onto infra — they describe
1378 /// how a Worker bundle ingresses requests onto services.
1379 pub domains: BTreeMap<String, DomainConfig>,
1380
1381 // ─── Pre-R215 legacy (slated for removal in B3-T3) ────────────────────
1382 /// Legacy mirrors from `.yah/cloud/mirrors/`.
1383 pub legacy_mirrors: Vec<LegacyMirrorConfig>,
1384 /// Workloads from `.yah/cloud/workloads/*.toml` (R092-F1 schema).
1385 pub workloads: Vec<WorkloadConfig>,
1386 /// Topology from `.yah/cloud/topology.toml` (mirror→machine assignments).
1387 pub topology: TopologyConfig,
1388 /// Legacy services from `.yah/cloud/services/*.toml` (pre-R092 layout).
1389 pub legacy_services: Vec<LegacyServiceConfig>,
1390}
1391
1392impl CloudConfig {
1393 /// Load all cloud config rooted at `workspace_root` (the parent of `.yah/`).
1394 ///
1395 /// Reads the R215+ tree (`.yah/infra/`, `.yah/services/<svc>/`) eagerly
1396 /// and the pre-R215 `.yah/cloud/` tree opportunistically. Returns `Err`
1397 /// immediately if any TOML fails to parse or a workload TOML fails
1398 /// shape validation; the error includes the file path and field path.
1399 ///
1400 /// Cross-ref validation runs after both trees finish loading: every
1401 /// `mirror.providers.X.use = "<id>"` must resolve to a real provider
1402 /// declared under `.yah/infra/providers/`.
1403 ///
1404 /// R844-B7 — **a missing `.yah/` is a wrong-root error, not an empty
1405 /// fleet.** Every sub-loader below tolerates a missing directory by
1406 /// returning empty, so before this check a call against the wrong
1407 /// directory produced a perfectly valid `CloudConfig` with zero machines,
1408 /// zero services and zero providers. Nothing downstream can tell that
1409 /// apart from a camp that genuinely declares nothing, so the failure
1410 /// surfaces as an operation that silently does nothing to nothing: a
1411 /// collate that renders no backends, a fanout that asks no nodes, a
1412 /// rollout that plans against an empty fleet. It was found the hard way —
1413 /// a live-fleet test in `app/yah/cli` called this with `"."`, which under
1414 /// `cargo test` is the *package* root, and passed while measuring nothing.
1415 ///
1416 /// The line is drawn at `.yah/` and only there: a workspace whose
1417 /// `.yah/infra/machines/` is absent or empty is a real, if unusual, camp
1418 /// with an empty fleet and still loads. `unknown` is not `answered with
1419 /// none`.
1420 pub fn load(workspace_root: &Path) -> Result<Self> {
1421 let yah_dir = crate::paths::yah_dir(workspace_root);
1422 if !yah_dir.is_dir() {
1423 anyhow::bail!(
1424 "not a yah workspace: no {} — expected the camp root (the parent \
1425 of `.yah/`), got {}. This is a wrong-root error, not an empty \
1426 fleet; a camp with no machines declared still has a `.yah/`.",
1427 yah_dir.display(),
1428 workspace_root.display(),
1429 );
1430 }
1431
1432 let mut providers = load_providers(&crate::paths::providers_dir(workspace_root))?;
1433 let services = load_services(&crate::paths::services_dir(workspace_root), workspace_root)?;
1434 let domains = load_domains(&crate::paths::domains_dir(workspace_root))?;
1435
1436 Self::cross_ref_validate(&providers, &services, &domains)?;
1437
1438 // Legacy `.yah/cloud/` reads — empty in post-B1 workspaces. Wrapped in
1439 // a helper so a missing tree is silent (no error, no warning).
1440 let cloud_dir = crate::paths::legacy_cloud_dir(workspace_root);
1441 let (legacy_machines, legacy_mirrors, legacy_workloads, topology, legacy_services) =
1442 if cloud_dir.exists() {
1443 (
1444 load_dir::<MachineConfig>(cloud_dir.join("machines"))?,
1445 load_mirrors(cloud_dir.join("mirrors"))?,
1446 load_workloads(cloud_dir.join("workloads"))?,
1447 load_topology(cloud_dir.join("topology.toml"))?,
1448 load_dir::<LegacyServiceConfig>(cloud_dir.join("services"))?,
1449 )
1450 } else {
1451 Default::default()
1452 };
1453
1454 // Workloads come from `.yah/infra/workloads/` (R215+). R568-T7: before
1455 // that path was read here, this field was populated *only* from the
1456 // legacy tree above — which R222-B1 emptied — so `cfg.workload(name)`
1457 // resolved nothing in every post-R215 camp and `yah cloud workload
1458 // deploy` could not find any declaration at all. The bug survived
1459 // because the only workloads ever deployed were forge/QED runs, which
1460 // build their spec in memory and never come through here. Same
1461 // dedupe-by-name shape as machines below: R215+ wins.
1462 let mut workloads = load_workloads(crate::paths::workloads_dir(workspace_root))?;
1463 let workload_names: std::collections::HashSet<String> =
1464 workloads.iter().map(|w| w.spec.name.clone()).collect();
1465 for w in legacy_workloads {
1466 if !workload_names.contains(&w.spec.name) {
1467 workloads.push(w);
1468 }
1469 }
1470
1471 // Machines come from `.yah/infra/machines/` (R215+); the pre-R215
1472 // tree shouldn't have any since B1 moved them, but if it does we
1473 // dedupe by name (R215 wins).
1474 let mut machines = load_dir::<MachineConfig>(crate::paths::machines_dir(workspace_root))?;
1475 let names: std::collections::HashSet<String> =
1476 machines.iter().map(|m| m.name.clone()).collect();
1477 for m in legacy_machines {
1478 if !names.contains(&m.name) {
1479 machines.push(m);
1480 }
1481 }
1482
1483 // R615-F2: overlay every linked `.yah/infra/sources.toml` source's
1484 // machines/providers UNDER what's already loaded above, so camp-local
1485 // (including the legacy-tree entries just merged in) always wins on a
1486 // name collision. `SourcesConfig::load` itself never touches the
1487 // network — git sources are read from `yah infra sync`'s cache
1488 // (R615-T3), so this call keeps `load()`'s whole offline contract.
1489 let sources = SourcesConfig::load(&crate::paths::infra_dir(workspace_root))?;
1490 let mut machine_origins = BTreeMap::new();
1491 let mut provider_origins = BTreeMap::new();
1492 overlay_infra_sources(
1493 workspace_root,
1494 &sources,
1495 &mut machines,
1496 &mut providers,
1497 &mut machine_origins,
1498 &mut provider_origins,
1499 );
1500
1501 Ok(Self {
1502 workspace_root: workspace_root.to_path_buf(),
1503 machines,
1504 providers,
1505 machine_origins,
1506 provider_origins,
1507 services,
1508 domains,
1509 legacy_mirrors,
1510 workloads,
1511 topology,
1512 legacy_services,
1513 })
1514 }
1515
1516 /// Load the R215+ tree (`infra/`, `services/`, `domains/`) rooted at an
1517 /// arbitrary config directory instead of the hardcoded `.yah/`. This is the
1518 /// building block for multi-root deployments (W206 config layout (b), sibling
1519 /// `.noisetable/` trees) — see [`crate::multi_root`]. Part of R558-F4.
1520 ///
1521 /// `config_dir` is the `.X/` directory itself (e.g. `<parent>/.noisetable`);
1522 /// `workspace_root` remains the camp dir (the config dir's parent) so a
1523 /// component's `path` reference resolves against the same tree the classic
1524 /// [`CloudConfig::load`] uses. The legacy `.yah/cloud/` reads are skipped —
1525 /// multi-root deployments are post-R215 by construction — so `legacy_*`,
1526 /// `workloads`, and `topology` come back empty. Machines are read from
1527 /// `config_dir/infra/machines` directly (sibling trees declare their own
1528 /// inventory or none).
1529 ///
1530 /// R615-F2 decision, explicit rather than silent: **sources.toml overlay
1531 /// does NOT apply here.** This function
1532 /// exists specifically because a multi-root sibling tree (W206 layout
1533 /// (b), e.g. `.noisetable/`) is a *second config root inside the same
1534 /// camp*, not a second camp — `config_dir` is already wherever the
1535 /// caller decided this tree's infra lives, and `.yah/infra/sources.toml`
1536 /// (singular, tied to `paths::infra_dir(workspace_root)`) has no
1537 /// well-defined meaning for an arbitrary `config_dir` that isn't that
1538 /// path. A sibling tree that wants borrowed infra declares its own
1539 /// `sources.toml` under whichever root actually calls
1540 /// [`CloudConfig::load`] for it; `machine_origins`/`provider_origins`
1541 /// come back empty here, not wrong — there is nothing to overlay.
1542 pub fn load_from_config_dir(config_dir: &Path, workspace_root: &Path) -> Result<Self> {
1543 let providers = load_providers(&config_dir.join("infra").join("providers"))?;
1544 let services = load_services(&config_dir.join("services"), workspace_root)?;
1545 let domains = load_domains(&config_dir.join("domains"))?;
1546
1547 Self::cross_ref_validate(&providers, &services, &domains)?;
1548
1549 let machines = load_dir::<MachineConfig>(config_dir.join("infra").join("machines"))?;
1550
1551 Ok(Self {
1552 workspace_root: workspace_root.to_path_buf(),
1553 machines,
1554 providers,
1555 machine_origins: BTreeMap::new(),
1556 provider_origins: BTreeMap::new(),
1557 services,
1558 domains,
1559 legacy_mirrors: vec![],
1560 workloads: vec![],
1561 topology: TopologyConfig::default(),
1562 legacy_services: vec![],
1563 })
1564 }
1565
1566 /// Cross-reference validation shared by [`CloudConfig::load`] and
1567 /// [`CloudConfig::load_from_config_dir`]: every mirror `providers.X.use =
1568 /// "<id>"` must resolve to a declared provider, and every domain route's
1569 /// `component = "<service>/<component-id>"` must resolve to a real component.
1570 fn cross_ref_validate(
1571 providers: &[ProviderConfig],
1572 services: &BTreeMap<String, ServiceWithMirrors>,
1573 domains: &BTreeMap<String, DomainConfig>,
1574 ) -> Result<()> {
1575 // Mirror `use = "<id>"` slots must resolve to a declared provider.
1576 let provider_ids: std::collections::HashSet<&str> =
1577 providers.iter().map(|p| p.id.as_str()).collect();
1578 for (svc_name, svc) in services {
1579 for (env, mirror) in &svc.mirrors {
1580 for (slot, body) in &mirror.providers {
1581 if let Some(id) = body.provider_id() {
1582 if !provider_ids.contains(id) {
1583 anyhow::bail!(
1584 "services/{svc_name}/mirrors/{env}.toml: \
1585 providers.{slot}.use = \"{id}\" — no such provider; \
1586 declare it at infra/providers/{id}.toml"
1587 );
1588 }
1589 }
1590 }
1591 // An `[[ingress]]` edge's own `use` is the same kind of
1592 // reference (R845) and gets the same check: a typo there is
1593 // otherwise invisible until `yah cloud apply` reaches the
1594 // Cloudflare arm and fails on a missing provider file.
1595 for (idx, edge) in mirror.ingress_edge_slice().iter().enumerate() {
1596 if let Some(id) = edge.provider_id.as_deref() {
1597 if !provider_ids.contains(id) {
1598 anyhow::bail!(
1599 "services/{svc_name}/mirrors/{env}.toml: \
1600 ingress[{idx}].use = \"{id}\" — no such provider; \
1601 declare it at infra/providers/{id}.toml"
1602 );
1603 }
1604 }
1605 }
1606 }
1607 }
1608
1609 // Every domain route's `component = "<service>/<component-id>"` must
1610 // resolve to a real component.
1611 for (dom_name, dom) in domains {
1612 for (idx, route) in dom.routes.iter().enumerate() {
1613 let Some(component_ref) = route.mode.component() else {
1614 continue; // redirects don't reference components
1615 };
1616 let Some((svc_name, comp_id)) = split_component_ref(component_ref) else {
1617 anyhow::bail!(
1618 "domains/{dom_name}.toml: routes[{idx}].component = \
1619 \"{component_ref}\" — expected \"<service>/<component-id>\""
1620 );
1621 };
1622 let Some(svc) = services.get(svc_name) else {
1623 anyhow::bail!(
1624 "domains/{dom_name}.toml: routes[{idx}].component = \
1625 \"{component_ref}\" — no such service \"{svc_name}\" \
1626 under services/"
1627 );
1628 };
1629 let Some(component) = svc.service.components.iter().find(|c| c.id == comp_id)
1630 else {
1631 anyhow::bail!(
1632 "domains/{dom_name}.toml: routes[{idx}].component = \
1633 \"{component_ref}\" — service \"{svc_name}\" has no \
1634 component with id \"{comp_id}\""
1635 );
1636 };
1637
1638 // R746: a mounted component must be routed where it publishes.
1639 // The publisher writes its bundle under the mount and the front
1640 // door looks a request up by its own path, so a route path and
1641 // a mount that disagree produce a 404 with its cause two files
1642 // away. Checked in both directions, since either one alone is
1643 // the same silent miss.
1644 //
1645 // Static routes only: `mount` is a *storage* prefix, and a
1646 // backend route proxies to an origin that owns its own paths.
1647 if !matches!(route.mode, RouteMode::Static { .. }) {
1648 continue;
1649 }
1650 let mount = component.mount.as_deref().map(normalize_mount);
1651 let route_prefix = route_path_prefix(&route.path);
1652 if let Some(mount) = mount {
1653 if mount != route_prefix {
1654 anyhow::bail!(
1655 "domains/{dom_name}.toml: routes[{idx}].path = \
1656 \"{path}\" serves \"{component_ref}\", which \
1657 declares mount = \"/{mount}\" — a mounted \
1658 component publishes under its mount, so the route \
1659 must be \"/{mount}\" or \"/{mount}/*\" (or drop \
1660 the mount to serve from the service root)",
1661 path = route.path,
1662 );
1663 }
1664 } else if !route_prefix.is_empty() {
1665 anyhow::bail!(
1666 "domains/{dom_name}.toml: routes[{idx}].path = \
1667 \"{path}\" serves \"{component_ref}\", which declares \
1668 no `mount` — its bundle publishes at the service root, \
1669 so nothing is stored under \"/{route_prefix}\". Set \
1670 mount = \"/{route_prefix}\" on the component, or route \
1671 it at \"/*\"",
1672 path = route.path,
1673 );
1674 }
1675 }
1676 }
1677 Ok(())
1678 }
1679
1680 /// Look up a domain manifest by name (file stem under `.yah/domains/`).
1681 pub fn domain(&self, name: &str) -> Option<&DomainConfig> {
1682 self.domains.get(name)
1683 }
1684
1685 pub fn machine(&self, name: &str) -> Option<&MachineConfig> {
1686 self.machines.iter().find(|m| m.name == name)
1687 }
1688
1689 /// Look up a provider by id (matches `provider.id`, not the file stem).
1690 pub fn provider(&self, id: &str) -> Option<&ProviderConfig> {
1691 self.providers.iter().find(|p| p.id == id)
1692 }
1693
1694 /// Look up a service by name (matches `service.toml`'s `name` field).
1695 pub fn service(&self, name: &str) -> Option<&ServiceWithMirrors> {
1696 self.services.get(name)
1697 }
1698
1699 /// Look up a legacy mirror by camp name (pre-R215 .yah/cloud/mirrors/).
1700 pub fn legacy_mirror(&self, camp: &str) -> Option<&LegacyMirrorConfig> {
1701 self.legacy_mirrors.iter().find(|m| m.camp == camp)
1702 }
1703
1704 pub fn workload(&self, name: &str) -> Option<&WorkloadConfig> {
1705 self.workloads.iter().find(|w| w.spec.name == name)
1706 }
1707
1708 /// Every machine declaring `sovereign_group == group`, in declaration order.
1709 ///
1710 /// W305/R742-F3. A sovereign group has no file of its own — it exists only
1711 /// as the set of machines that name the same string — so "which boxes are
1712 /// the dev cluster" has to be *derived*, and before this it was not derived
1713 /// anywhere: `yah cloud rollout plan` still takes a hand-listed
1714 /// `--voter us-west-011 --voter us-west-013 …` for a fact the machine TOMLs
1715 /// already state (W314 gap 1).
1716 ///
1717 /// **This is not placement.** Resolving a group to its members is a
1718 /// *lookup*, and it stays outside [`RequiredSpec`] on purpose — see
1719 /// [`MachineConfig::sovereign_group`]. `migrate` calls this to pick the
1720 /// candidate set it then admits a workload against; nothing here filters
1721 /// scheduling, and adding `sovereign_group` to `matches` would still be the
1722 /// category error that doc warns about.
1723 ///
1724 /// An empty result means no machine declares `group`, which is
1725 /// indistinguishable from a typo — callers should say so with
1726 /// [`Self::declared_sovereign_groups`] rather than reporting "no
1727 /// candidates".
1728 pub fn machines_in_group(&self, group: &str) -> Vec<&MachineConfig> {
1729 self.machines
1730 .iter()
1731 .filter(|m| m.sovereign_group.as_deref() == Some(group))
1732 .collect()
1733 }
1734
1735 /// Every distinct `sovereign_group` declared by any machine, sorted.
1736 ///
1737 /// Exists so a bad `--to` names the real vocabulary instead of complaining
1738 /// abstractly — the same fail-loud shape [`taint_effect`]'s legal-key list
1739 /// gives `check_inert_taints`. Standalone machines (`None`) contribute
1740 /// nothing: "in no group" is not a group you can migrate *to*.
1741 pub fn declared_sovereign_groups(&self) -> Vec<&str> {
1742 let mut groups: Vec<&str> = self
1743 .machines
1744 .iter()
1745 .filter_map(|m| m.sovereign_group.as_deref())
1746 .collect();
1747 groups.sort_unstable();
1748 groups.dedup();
1749 groups
1750 }
1751
1752 /// F16 placement v1: the first machine satisfying every hard axis of `req`
1753 /// (region/zone/provider membership + mesh_tags superset). Declaration order
1754 /// in `.yah/infra/machines/` decides ties — deterministic-greedy, no
1755 /// backtracking. A fully-unconstrained `req` matches the first machine.
1756 ///
1757 /// Fails loud with the constraint summary and the candidate machine names
1758 /// when nothing matches, so `yah cloud apply` surfaces *why* placement
1759 /// failed instead of a silent empty set.
1760 pub fn resolve_machine(&self, req: &RequiredSpec) -> Result<&MachineConfig> {
1761 resolve_machine_among(&self.machines, req)
1762 }
1763
1764 /// F16 placement at horizontal scale: the first
1765 /// [`RequiredSpec::replica_count`] machines satisfying every hard axis of
1766 /// `req`, in declaration order (R844-F8).
1767 ///
1768 /// The N-valued form of [`Self::resolve_machine`], which is the N=1 case of
1769 /// this and not a different selector — both land in [`select_matching`].
1770 /// That shared bottom is what makes the deploy resolver
1771 /// (`reconciler::mesofact_bundle::resolve_bundle_machines`, which calls
1772 /// this) and the ingress planner's
1773 /// (`reconciler::ingress::resolve_ingress_placements`, which calls
1774 /// [`resolve_machines_among`] over the same `machines` slice) agree on the
1775 /// same N machines **by construction**. They must agree set-for-set, not
1776 /// merely in count: a front door aimed at nodes the workload was never
1777 /// deployed to renders a *subset* of the backends, which is the failure that
1778 /// looks like it worked.
1779 pub fn resolve_machines(&self, req: &RequiredSpec) -> Result<Vec<&MachineConfig>> {
1780 resolve_machines_among(&self.machines, req)
1781 }
1782
1783 /// F16 placement: first machine whose `mesh_tags` is a superset of
1784 /// `required`. Declaration order in `.yah/infra/machines/` decides ties.
1785 /// Empty `required` matches the first machine; callers should treat
1786 /// empty-required as "no constraint" and skip this lookup.
1787 ///
1788 /// Back-compat thin wrapper over [`CloudConfig::resolve_machine`] for the
1789 /// mesh-tags-only call sites that predate the topology axes.
1790 pub fn resolve_machine_by_mesh_tags(&self, required: &[String]) -> Option<&MachineConfig> {
1791 let req = RequiredSpec {
1792 mesh_tags: required.to_vec(),
1793 ..Default::default()
1794 };
1795 self.resolve_machine(&req).ok()
1796 }
1797
1798 /// Admission: resolve the target machine for a remote [`WorkloadSpec`],
1799 /// honoring the R594 mesh-tag node-selector annotation
1800 /// (`velveteen_exec::remote::NODE_SELECTOR_MESH_TAGS_ANNOTATION` =
1801 /// `yah.node-selector.mesh-tags`, comma-joined).
1802 ///
1803 /// The producer side (`velveteen_exec::remote::build_workload_spec`, R594) writes
1804 /// `TaskLocation::RemoteAny.mesh_tags` — e.g. `[tag:build-worker, arch:x86]`
1805 /// from [`qed::platform::build_worker_mesh_tags`] — into the workload's
1806 /// annotations. This is the consumer: candidates are restricted to machines
1807 /// whose `mesh_tags` are a **superset** of the requested set, so an amd64
1808 /// build lands on the `arch:x86` build-worker (us-west-002) and an arm64
1809 /// build on a `arch:arm` Pi5. Declaration order in `.yah/infra/machines/`
1810 /// breaks ties.
1811 ///
1812 /// An absent or empty annotation means "no mesh-tag constraint" — pre-R594
1813 /// behavior (any node), matching [`RequiredSpec::is_unconstrained`].
1814 ///
1815 /// This is the single admission seam: R572-F5 extends it with the capacity
1816 /// floor (workload request fits node allocatable−committed) and taint
1817 /// repulsion/affinity by enriching [`RequiredSpec::matches`] /
1818 /// [`Self::resolve_machine`]. Do not fork a second selector.
1819 pub fn admit_workload(&self, ws: &WorkloadSpec) -> Result<&MachineConfig> {
1820 self.resolve_machine(&admission_spec(ws, &self.workloads))
1821 }
1822
1823 /// Every machine that admits `ws`, in declaration order — the *pool*
1824 /// [`Self::admit_workload`] returns the head of (R605-T14).
1825 ///
1826 /// # Why a pool and not just the winner
1827 ///
1828 /// `tag:build-worker` is a statement that the tagged boxes are
1829 /// **interchangeable**: a build is booked against the tag, not against
1830 /// `us-west-002`. Returning one machine forced every caller to act as if it
1831 /// were booked against a name, and admission has no liveness input — so a
1832 /// tagged box that is asleep won the file-name tie-break and its builds
1833 /// failed rather than landing on the identical box next to it. That is
1834 /// exactly what happened on 2026-09-03 when `us-west-002` regained the tag.
1835 ///
1836 /// The fix is **not** to teach this function about liveness. It stays a pure
1837 /// function of the declared inventory (see `xtask/tests/fleet_build_placement.rs`
1838 /// on why a placement pin that needs the network is a flake). It hands the
1839 /// dispatcher the whole interchangeable set instead, and the dispatcher —
1840 /// which has the network — probes and fails over within it:
1841 /// `app/yah/cli/src/yubaba_client.rs`'s `MeshYubabaClient::deploy`.
1842 ///
1843 /// Order is the declaration order `admit_workload` already used, and callers
1844 /// should preserve it as their preference order rather than load-balancing
1845 /// across it: a retried build wants the node still holding its warm
1846 /// `target/`, which is the same reason [`first_match`] is deliberately
1847 /// first-fit.
1848 ///
1849 /// `Err` — never `Ok(vec![])` — when nothing admits `ws`, carrying the same
1850 /// message [`Self::admit_workload`] would have produced. "No node admits
1851 /// this" and "the pool is empty" are the same failure and must read the same.
1852 pub fn admit_workload_candidates(&self, ws: &WorkloadSpec) -> Result<Vec<&MachineConfig>> {
1853 let req = admission_spec(ws, &self.workloads);
1854 let all: Vec<&MachineConfig> = self.machines.iter().collect();
1855 let matched = matching(&all, &req);
1856 if matched.is_empty() {
1857 // Delegate the wording so the two paths cannot drift apart.
1858 return Err(first_match(&all, &req, DECLARED_POOL, EMPTY_DECLARED_POOL)
1859 .expect_err("matching() found nothing, so first_match cannot succeed"));
1860 }
1861 Ok(matched)
1862 }
1863
1864 /// [`Self::admit_workload`] restricted to the machines of one sovereign
1865 /// group (W305/R742-F3, `yah cloud migrate --to <group>`).
1866 ///
1867 /// Same [`RequiredSpec`], same [`RequiredSpec::matches`], same
1868 /// declaration-order tie-break — only the candidate *set* differs. That is
1869 /// the whole reason this is a narrowing of the admission seam rather than a
1870 /// second selector: a workload that cannot be scheduled onto a group's
1871 /// boxes must fail here for exactly the reason it would fail anywhere else,
1872 /// and `no-appliance` on the dev Pis (W305 finding 2) is precisely the case
1873 /// that must not be silently routed around by a migration verb.
1874 ///
1875 /// `Err` when the group has no members *or* when no member admits `ws`; the
1876 /// two are different mistakes, so callers wanting to tell them apart should
1877 /// check [`Self::machines_in_group`] first.
1878 pub fn admit_workload_in_group(
1879 &self,
1880 ws: &WorkloadSpec,
1881 group: &str,
1882 ) -> Result<&MachineConfig> {
1883 let members = self.machines_in_group(group);
1884 let empty_pool = format!(
1885 "(no machine declares sovereign_group = \"{group}\" — declared groups: {})",
1886 match self.declared_sovereign_groups().as_slice() {
1887 [] => "(none)".to_string(),
1888 gs => gs.join(", "),
1889 }
1890 );
1891 first_match(
1892 &members,
1893 &admission_spec(ws, &self.workloads),
1894 &format!("machines in sovereign group '{group}'"),
1895 &empty_pool,
1896 )
1897 }
1898}
1899
1900/// **The** placement selector: the first candidate satisfying every axis of
1901/// `req`, declaration order breaking ties, deterministic-greedy with no
1902/// backtracking.
1903///
1904/// Every path that picks a machine goes through here, and the only thing any
1905/// of them varies is *which machines are candidates* — never the predicate.
1906/// [`CloudConfig::resolve_machine`] passes the whole fleet;
1907/// [`CloudConfig::admit_workload_in_group`] passes one sovereign group's
1908/// members. That split is the point: a candidate-set narrowing composes with
1909/// the [`RequiredSpec`] axes for free, whereas expressing the same narrowing
1910/// *as* an axis would put facts like blast radius into a filter they must
1911/// never be in (see [`MachineConfig::sovereign_group`]).
1912///
1913/// So a new placement scope is a new candidate set plus a `pool` label, and a
1914/// new placement *constraint* is a field on [`RequiredSpec`] — those are the
1915/// two extension points, and neither is a second selector. `pool` and
1916/// `empty_pool` exist only so the failure names the set it actually searched;
1917/// a refusal that says "no candidates" without saying *among what* is one the
1918/// operator has to reconstruct by hand.
1919/// F16 placement v1 resolution over an explicit machine list — the
1920/// `.machines`-only half of [`CloudConfig::resolve_machine`], for callers that
1921/// have loaded just the machines tree rather than the whole cross-ref-validated
1922/// config.
1923///
1924/// R772: `resolve_ingress_placements` (`reconciler::ingress`) is the reason
1925/// this is `pub(crate)` rather than staying folded into
1926/// `CloudConfig::resolve_machine` — ingress collation walks every mirror in
1927/// the workspace and has no business hard-failing over an unrelated mirror's
1928/// `providers.X.use = "<id>"` typo, which is what going through
1929/// `CloudConfig::load`'s cross-ref validation would do. "Do not fork a second
1930/// selector" (see the module doc above) still holds: this is the *same*
1931/// [`first_match`], just handed a narrower candidate set than `self.machines`.
1932pub(crate) fn resolve_machine_among<'a>(
1933 machines: &'a [MachineConfig],
1934 req: &RequiredSpec,
1935) -> Result<&'a MachineConfig> {
1936 let all: Vec<&MachineConfig> = machines.iter().collect();
1937 first_match(&all, req, DECLARED_POOL, EMPTY_DECLARED_POOL)
1938}
1939
1940/// R844-F8: [`resolve_machine_among`] widened to the constraint's own replica
1941/// count — the first [`RequiredSpec::replica_count`] matching machines, in the
1942/// same declaration order, from the same candidate slice.
1943///
1944/// **The one entry point both resolvers share.**
1945/// `reconciler::ingress::resolve_ingress_placements` calls this directly and
1946/// `reconciler::mesofact_bundle::resolve_bundle_machines` reaches it through
1947/// [`CloudConfig::resolve_machines`], both over `cfg.machines` — so the ingress
1948/// planner and the deployer cannot pick different subsets. That is a structural
1949/// guarantee, not a tested coincidence, and it has to be: discovery aimed at a
1950/// node the bundle was never placed on publishes a hostname with a dead
1951/// backend behind it, and at scale > 1 the front door still answers from the
1952/// nodes that *did* get it.
1953///
1954/// Determinism is therefore part of correctness here. `machines` arrives in
1955/// file-name order (`load_dir`, pinned by
1956/// `machines_load_in_file_name_order_not_read_dir_order`), and selection is a
1957/// stable prefix of that order — so "the first two matching" is the same two
1958/// on both sides of the same tree.
1959pub(crate) fn resolve_machines_among<'a>(
1960 machines: &'a [MachineConfig],
1961 req: &RequiredSpec,
1962) -> Result<Vec<&'a MachineConfig>> {
1963 let all: Vec<&MachineConfig> = machines.iter().collect();
1964 select_matching(
1965 &all,
1966 req,
1967 req.replica_count(),
1968 DECLARED_POOL,
1969 EMPTY_DECLARED_POOL,
1970 )
1971}
1972
1973const DECLARED_POOL: &str = "declared machines";
1974const EMPTY_DECLARED_POOL: &str = "(no machines declared under .yah/infra/machines/)";
1975
1976fn first_match<'a>(
1977 candidates: &[&'a MachineConfig],
1978 req: &RequiredSpec,
1979 pool: &str,
1980 empty_pool: &str,
1981) -> Result<&'a MachineConfig> {
1982 Ok(select_matching(candidates, req, 1, pool, empty_pool)?
1983 .into_iter()
1984 .next()
1985 .expect("select_matching errors rather than returning short"))
1986}
1987
1988/// The N-selecting core of the placement selector: the first `want` candidates
1989/// satisfying `req`, in candidate order (R844-F8).
1990///
1991/// [`first_match`] is this with `want = 1`, which is why widening a caller to a
1992/// replica count cannot introduce a second selector — the predicate, the
1993/// ordering and the failure vocabulary are all one implementation.
1994///
1995/// **A shortfall is an error.** Matching one machine when two were asked for
1996/// returns `Err` naming both numbers and the pool searched, never a one-element
1997/// vec: a half-placed workload that reports success is worse than a failed
1998/// apply, because the front door then publishes a hostname whose backend set is
1999/// quietly smaller than declared. `want = 0` is the same mistake spelled
2000/// differently and is refused for the same reason.
2001fn select_matching<'a>(
2002 candidates: &[&'a MachineConfig],
2003 req: &RequiredSpec,
2004 want: usize,
2005 pool: &str,
2006 empty_pool: &str,
2007) -> Result<Vec<&'a MachineConfig>> {
2008 let names = || {
2009 if candidates.is_empty() {
2010 empty_pool.to_string()
2011 } else {
2012 candidates
2013 .iter()
2014 .map(|m| m.name.as_str())
2015 .collect::<Vec<_>>()
2016 .join(", ")
2017 }
2018 };
2019
2020 if want == 0 {
2021 anyhow::bail!(
2022 "replicas = 0 places {} on nothing — a placement that deploys to no machine is \
2023 a typo, not a scale-down; remove the slot instead",
2024 req.describe()
2025 );
2026 }
2027
2028 let mut matched = matching(candidates, req);
2029 if matched.len() >= want {
2030 matched.truncate(want);
2031 return Ok(matched);
2032 }
2033
2034 if want == 1 {
2035 anyhow::bail!(
2036 "no candidates matching {} — {pool}: {}",
2037 req.describe(),
2038 names()
2039 );
2040 }
2041 anyhow::bail!(
2042 "only {} of {want} machines match {} — placing fewer than the declared \
2043 `replicas = {want}` would publish a smaller backend set than the mirror asks for; \
2044 {pool}: {}",
2045 matched.len(),
2046 req.describe(),
2047 names()
2048 )
2049}
2050
2051/// The predicate itself, applied to every candidate in order — the one place
2052/// `req.matches` is called on a set.
2053///
2054/// [`select_matching`] takes a prefix of this; [`CloudConfig::admit_workload_candidates`]
2055/// takes all of it. Keeping both on this function is what makes "the pool the
2056/// dispatcher failed over within" and "the machine admission picked" the same
2057/// answer by construction rather than by two filters that happen to agree.
2058fn matching<'a>(candidates: &[&'a MachineConfig], req: &RequiredSpec) -> Vec<&'a MachineConfig> {
2059 candidates
2060 .iter()
2061 .copied()
2062 .filter(|m| req.matches(m))
2063 .collect()
2064}
2065
2066/// The [`RequiredSpec`] a workload is admitted against — the single place the
2067/// axes are derived from a [`WorkloadSpec`].
2068///
2069/// Extracted from [`CloudConfig::admit_workload`] so that
2070/// [`CloudConfig::admit_workload_in_group`] narrows the candidate set without
2071/// restating the axes. Forking that derivation is how the two paths would
2072/// silently disagree about whether a workload fits a node.
2073///
2074/// # It admits a group, not a workload (R860-T4 / W338)
2075///
2076/// The axes come from [`placement_group`] — `ws` plus the transitive closure of
2077/// its `local` requirement edges — because those members are placed together or
2078/// not at all. Capacity is their **sum**, archetype repulsion their **union**,
2079/// and mesh tags their union too. `prefer-local` and `anywhere` edges bind
2080/// nothing: a spec with neither `requires` nor `depends_on` local edges has a
2081/// group of exactly itself and resolves byte-identically to the pre-R860 axes.
2082///
2083/// This is the **only** gate. Node election is CLI-side
2084/// (`MeshYubabaClient::elect_node`, which picks a live member of the pool this
2085/// produces); the yubaba node process accepts whatever it is handed and never
2086/// re-checks placement, so a wrong group here is not caught downstream.
2087///
2088/// @yah:ticket(R860-T4, "Admission: place the transitive closure of `local` edges as one group, not one workload")
2089/// @yah:status(review)
2090/// @yah:phase(P1)
2091/// @yah:at(2026-09-05T18:29:13Z)
2092/// @yah:assignee(agent:bundle-anthropic-ashguard)
2093/// @yah:parent(R860)
2094/// @yah:next("W338 §Placement consequences 1 and 2. `admission_spec()` (config.rs:1974-1998) derives its axes from ONE spec; it must derive them from the group — the transitive closure of `local` requirement edges over `effective_requirements()`. `prefer-local` and `anywhere` edges do NOT bind the group. Three consequences: memory/cpu floor becomes the SUM of the group's requests, not the requirer's alone; `repel_archetype` becomes the union over members (so a group containing an Appliance is repelled by `no-appliance` even if the requirer is a Server); and the group is non-drainable if ANY member is an Appliance, which today is a per-workload check at yubaba/src/lib.rs:3117-3128 and now has to be computed over a set.")
2095/// @yah:verify("cargo test -p cloud --lib config")
2096/// @yah:gotcha("Node election is CLI-side, not cluster-side: `MeshYubabaClient::elect_node` (app/yah/cli/src/yubaba_client.rs:235-268) calls `admit_workload_candidates` (config.rs:1753), picks one node, and POSTs the deploy there. The yubaba node process never decides placement — it accepts whatever it is handed. So group admission has to be right in `config.rs` because there is no second gate downstream to catch it.")
2097/// @arch:see(.yah/docs/working/W338-workload-dependencies-and-appliance-composition.md)
2098/// @yah:depends_on(R860-T1)
2099/// @yah:handoff("ADMISSION NOW PLACES A GROUP, NOT A WORKLOAD. `admission_spec` (oss/yubaba/crates/cloud/src/config.rs:2009) takes `(ws, declared: &[WorkloadConfig])` and derives every axis from `placement_group(ws, declared)` (:2108) — the transitive closure of `local` requirement edges over `effective_requirements()`, traversing `Requirement::provides` where present and resolving by ident against `cfg.workloads` (.yah/infra/workloads/) otherwise, mesh-identity first and workload name second. Capacity is the SUM of the members' `memory_request_mb()` / `resources.cpu_millis` (saturating). Only `local` binds: `prefer-local` and `anywhere` (which every legacy `depends_on` folds into) are skipped, so a spec without local edges has a group of exactly itself and its axes are bit-identical to the pre-R860 derivation.")
2100/// @yah:handoff("REPEL BECAME A SET. `RequiredSpec::repel_archetype: Option<LifecycleArchetype>` is now `repel_archetypes: Vec<LifecycleArchetype>` (config.rs:3878), the union over group members; `matches` (:3971) rejects a node carrying `no-<taint_key()>` for ANY of them, `describe` emits one `not-tainted(...)` part per archetype, `is_unconstrained` tests `is_empty()`. The field is `#[serde(skip)]`, so no wire or schema drift, and grep over app/ crates/ oss/ xtask/ finds no other referent of the old name and no `RequiredSpec { .. }` literal outside config.rs — the rename is contained. `admit_workload` / `admit_workload_candidates` / `admit_workload_in_group` signatures are unchanged; all three now pass `&self.workloads`.")
2101/// @yah:handoff("CYCLE GUARD, AND THE BUG IT TOOK TO GET RIGHT. The walker keeps TWO visited lists: `in_group` (member mesh identities) and `expanded` (requirement idents already resolved). The first version used one list and was silently wrong in the common case — a requirement's ident IS its provider's mesh identity, so marking the ident before resolving made every provider look already-present and `placement_group` returned a group of one. Five of the new tests caught it. If you refactor this, keep the two questions separate.")
2102/// @yah:handoff("ELECT_NODE NEEDS NO CHANGE FOR THIS TICKET — read it (app/yah/cli/src/yubaba_client.rs:235-268). It calls `admit_workload_candidates`, so it now receives a pool already filtered to nodes that can host the WHOLE group, then probes for liveness within it. That is correct for T4 because only the requirer is deployed today. It becomes load-bearing at R860-T6: `supply = \"self\"` provisioning MUST reuse the node URL `elect_node` returned for the requirer and must not re-elect per member — the probe is liveness-sensitive, so a second election can legally return a different member of the same pool and split the group across two nodes.")
2103/// @yah:handoff("DECISIONS THE BRIEF DID NOT COVER, all recorded in doc comments at the site. (1) `mesh_tags` are UNIONED over the group — the axis is already a superset/AND check, so a node that cannot host one member cannot host the group; zero regression risk since nothing in the tree declares `requires` yet. (2) `nodes` (the R833-F8 operator pin) stays REQUIRER-ONLY: it is a membership list, so intersecting two members' pins can yield an empty vec, which the axis reads as no-constraint — the exact inverse of the conflict. (3) `requires_taint` is a single Option: the requirer's wins, else the first member declaring one. Two members demanding DIFFERENT taints is not representable and would be an unplaceable group; widening that axis to a set is a follow-up if a real case appears. (4) An unresolvable `local` ident is SKIPPED, not an error — admission is a pure function of the declared inventory and must not start refusing deploys over a provider a later ticket declares; the cost is that its request does not count toward the floor, which is the exposure `depends_on` has always had.")
2104/// @yah:handoff("DRAINABILITY: placement half landed, node half deliberately NOT touched. `group_is_drainable(members)` (config.rs:2160) is the set-valued predicate W338 §Placement consequences 2 asks for — false as soon as any member is an Appliance — and the `no-appliance` repulsion that follows from it is enforced through `repel_archetypes`. The node-side loop `drain_workloads` (oss/yubaba/crates/yubaba/src/lib.rs, the R572-F4 archetype_registry skip) still decides per workload and knows nothing about requirement edges, so a Server bound to an Appliance by a `local` edge would still be drained alone. Not fixed here for two reasons: that file has three sessions live in it (the brief named them), and the fix needs group edges plumbed to the node process, which is R860-T6's rail rather than a local edit. `yubaba` already depends on `cloud`, so the predicate is directly callable from there when that plumbing exists.")
2105/// @yah:verify("BASELINE recorded before editing, tree anchor 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2: `cargo test -p yah-cloud --lib` (from oss/yubaba) = 1081 passed, 0 failed, 4 ignored, exit 0. AFTER: 1090 passed, 0 failed, 4 ignored, exit 0 — +9, exactly the nine tests added. `cargo check -p yah-cloud --all-targets` exit 0, and `cargo check -p yubaba --all-targets` exit 0 as well (yubaba consumes `cloud`, so it is where the `repel_archetypes` rename would have surfaced). Every exit code echoed explicitly, never inferred from an empty grep.")
2106/// @yah:verify("NEW TESTS (config.rs `mod tests`, R860-T4 section at the end): a_local_edge_binds_the_provider_into_the_placement_group; prefer_local_and_anywhere_edges_do_not_bind_the_group (covers a legacy `depends_on` too); the_group_is_the_transitive_closure_and_traverses_inline_provides; an_ident_cycle_closes_the_group_instead_of_looping_forever; an_unresolvable_local_ident_is_skipped_rather_than_refused; the_capacity_floor_is_the_sum_of_the_group_not_the_requirer_alone (a 300 MiB node refuses two 256 MiB members and the error names memory_mb>=512; a 512 MiB node admits); a_server_requiring_an_appliance_locally_is_repelled_by_no_appliance (same requirer alone still lands on the tainted Pi, so the repulsion provably comes from the edge); a_group_containing_an_appliance_is_not_drainable; a_spec_with_no_local_edges_admits_exactly_as_it_did_before.")
2107/// @yah:gotcha("The camp's `yah build run` rail killed three consecutive verification runs against the shared oss/yubaba/target dir: each ended with only `Blocking waiting for file lock on build directory` in the log and no exit code, after 121s / 720s. The green result above was obtained with `CARGO_TARGET_DIR=/tmp/r860t4-target`, which sidesteps the contended lock at the cost of one cold dep build. Worth reaching for directly when the yubaba target dir is busy rather than burning three cycles discovering it.")
2108/// @yah:handoff("Tree anchor at handoff: 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2 — the shared tree as I left it. Diff against it (`git diff 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
2109/// @yah:next("R860-T6 (supply = \"self\"): deploy the group's non-requirer members onto the node `elect_node` already returned for the requirer — do NOT re-elect per member, or a liveness probe can split the group across two nodes. `placement_group` (config.rs:2108) hands you the member specs in traversal order, requirer first.")
2110/// @yah:next("Node-side drain is still per-workload: teach `drain_workloads` (oss/yubaba/crates/yubaba/src/lib.rs) to consult `cloud::config::group_is_drainable` over the requesting workload's placement group once R860-T6 plumbs group membership to the node. Left untouched here on purpose — three sessions were live in that file.")
2111/// @yah:handoff("LEADER RE-VERIFIED (session:69b18855, independent of the courier's self-report). `cargo test -p yah-cloud --lib` from oss/yubaba: 1090 passed / 0 failed / 4 ignored, exit 0, against the courier's recorded 1081/0/4 baseline — +9 = exactly its new tests. Confirmed by content in config.rs: `placement_group` :2120 with the `req.locality != Locality::Local` guard at :2136 (so `prefer-local` and `anywhere` correctly do NOT bind), `group_is_drainable` :2172, and `RequiredSpec::repel_archetype: Option<_>` widened to `repel_archetypes: Vec<_>` at :3890 with the union built at :2039-2050 and enforced at :4004/:4044. The repel rename is `#[serde(skip)]`, so no wire or schema drift.")
2112/// @yah:handoff("Tree anchor at handoff: 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2 — the shared tree as I left it. Diff against it (`git diff 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
2113/// @yah:verify("cargo test -p yah-cloud --lib (from oss/yubaba): 1090 passed / 0 failed / 4 ignored, exit 0, vs a 1081/0/4 baseline. Exit codes echoed explicitly throughout rather than inferred from an empty grep — the trap that cost R860-T1 three misses.")
2114/// @yah:gotcha("CORRECTION FROM R860-T6, and the leader propagated the error so it is worth naming: this ticket's handoff asserted \\\"`yubaba` already depends on `cloud`, so the predicate is directly callable from there\\\". THAT IS WRONG. `cloud` is a DEV-dependency of yubaba only — oss/yubaba/crates/yubaba/Cargo.toml:150-152, under the comment \\\"Integration test harness\\\" — and cloud's own Cargo.toml records that the runtime yubaba→cloud edge was DELIBERATELY avoided from R374-F3 onward. The leader repeated the claim verbatim in R860-T6's dispatch brief; T6's courier checked it against the manifest instead of trusting it, which is the only reason it did not become a runtime dependency inversion. Resolution: `group_is_drainable`'s body moved down to `workload_spec::group_is_drainable` (workload-spec/src/lib.rs:2365), the shared home both crates already depend on, and `cloud::config::group_is_drainable` (config.rs:2240) now delegates to it keeping its signature. Verified after the move: yah-cloud still 1093/0/4, yah-workload-spec 171+98/0.")
2115/// @yah:handoff("Tree anchor at handoff: 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2 — the shared tree as I left it. Diff against it (`git diff 0a85122cdb33dbf97ebc04b84e07d9cfc049c0b2..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
2116/// @yah:verify("RE-VERIFIED AT HEAD 00ee20d1 (session:aa5e882d, 2026-09-05). `cargo test --manifest-path oss/yubaba/Cargo.toml -p yah-cloud --lib` = 1141 passed / 0 failed / 4 ignored, exit 0 (was 1093 at the first leader's check, 1110 at the second; the deltas are peers' tests). Group placement confirmed by content in oss/yubaba/crates/cloud/src/config.rs: `placement_group` derivation at :2073/:2108, `repel_archetypes: Vec<LifecycleArchetype>` at :3878. NOTE FOR ANYONE RE-RUNNING THIS: `cargo test -p yah-cloud --lib` from the repo root FAILS with \"package `yah-cloud` cannot be tested because it requires dev-dependencies and is not a member of the workspace\" — yah-cloud lives in the oss/yubaba workspace, so the invocation needs `--manifest-path oss/yubaba/Cargo.toml`.")
2117fn admission_spec(ws: &WorkloadSpec, declared: &[WorkloadConfig]) -> RequiredSpec {
2118 let group = placement_group(ws, declared);
2119
2120 // Capacity is the group's demand, not the requirer's (W338 §Placement
2121 // consequences 1). Saturating rather than wrapping: an absurd declared
2122 // request must read as "nothing is big enough", never as a small number.
2123 //
2124 // `memory_request_mb()` and NOT `resources.memory_mb`: the latter is a
2125 // cgroup ceiling, and reading a ceiling as a floor made `for_forge`'s
2126 // deliberately-roomy 32 GiB limit mean "only place me on a 32 GiB node".
2127 // That excluded every build-worker in the fleet but one. The accessor falls
2128 // back to `resources.memory_mb` when no request is declared, so specs that
2129 // never set one are admitted exactly as before.
2130 let mut memory_mb: u32 = 0;
2131 let mut cpu_millis: u32 = 0;
2132 // R572-F5 taint repulsion, unioned over the group (W338 §Placement
2133 // consequences 2): a group is non-drainable — and `no-appliance`-repelled —
2134 // if *any* member is an Appliance, even when the requirer is a Server.
2135 let mut repel_archetypes: Vec<LifecycleArchetype> = Vec::new();
2136 // Mesh tags are already AND-ed (a machine must be a superset), so unioning
2137 // them over the group is the same predicate applied to every member: a node
2138 // that cannot host one member cannot host the group.
2139 let mut mesh_tags = node_selector_mesh_tags(ws);
2140
2141 for member in &group {
2142 memory_mb = memory_mb.saturating_add(member.memory_request_mb());
2143 cpu_millis = cpu_millis.saturating_add(member.resources.cpu_millis);
2144 let arch = member.effective_archetype();
2145 if !repel_archetypes.contains(&arch) {
2146 repel_archetypes.push(arch);
2147 }
2148 for tag in node_selector_mesh_tags(member) {
2149 if !mesh_tags.contains(&tag) {
2150 mesh_tags.push(tag);
2151 }
2152 }
2153 }
2154
2155 // R860-T5 / W338 §Placement consequences 3: per-node native-exec
2156 // capability. Computed over the group for the same reason every other axis
2157 // is — a `local` edge to a native provider makes the *requirer* unplaceable
2158 // on a node without the backend, even when the requirer is an ordinary
2159 // container workload. This is the `supply = "self"` precondition W338 names:
2160 // a self-supplied native provider has to be placeable where its requirer
2161 // lands, and until now nothing upstream could see whether it was.
2162 //
2163 // Appended to `mesh_tags` rather than given its own field: the axis is
2164 // already an AND-ed superset check against `machine.mesh_tags`, `describe`
2165 // already renders it, and `RequiredSpec` needs no new shape. See
2166 // [`NATIVE_EXEC_MESH_TAG`] for why a tag and not a taint.
2167 if group.iter().any(WorkloadSpec::wants_native_exec)
2168 && !mesh_tags.iter().any(|t| t == NATIVE_EXEC_MESH_TAG)
2169 {
2170 mesh_tags.push(NATIVE_EXEC_MESH_TAG.to_string());
2171 }
2172
2173 RequiredSpec {
2174 mesh_tags,
2175 // R833-F8: imperative node pin. Derived here alongside the inferred
2176 // mesh tags rather than short-circuiting the resolver, so a pinned
2177 // workload is still checked against capacity and taints.
2178 //
2179 // Requirer-only on purpose: the pin is what the operator typed on
2180 // *this* deploy, and `nodes` is a membership list, so intersecting two
2181 // members' pins could yield an empty vec — which this axis reads as "no
2182 // constraint", i.e. the exact opposite of the conflict it represents.
2183 nodes: node_selector_node(ws).into_iter().collect(),
2184 memory_mb,
2185 cpu_millis,
2186 repel_archetypes,
2187 // R572-F5: taint affinity from the requires-taint annotation. The
2188 // requirer's wins; otherwise the first member that declares one, since
2189 // the group shares a node and this axis holds a single key. Two members
2190 // demanding *different* taints is not representable here and would be
2191 // an unplaceable group anyway — see the R860-T4 handoff.
2192 requires_taint: group
2193 .iter()
2194 .find_map(|m| m.requires_taint().map(str::to_owned)),
2195 ..Default::default()
2196 }
2197}
2198
2199/// The workloads that must be placed together with `ws`: the transitive closure
2200/// of `local` requirement edges over [`WorkloadSpec::effective_requirements`],
2201/// starting at the requirer (R860-T4 / W338 §"Each member keeps its own mesh
2202/// identity").
2203///
2204/// **Only `local` binds.** `prefer-local` explicitly "never blocks placement"
2205/// (W338's locality table) and `anywhere` is an ordinary service dependency —
2206/// treating either as a co-scheduling constraint would turn every `depends_on`
2207/// in the tree into one, since the legacy field folds in as `anywhere` + `wait`.
2208///
2209/// A group is **not** a new addressable object: every member keeps its own mesh
2210/// identity, spec and healthcheck (W338). This function returns the members'
2211/// specs so admission can take the sum / union over them, and nothing here
2212/// deploys, provisions or tears anything down — `supply = "self"` provisioning
2213/// is R860-T6 and per-node native-exec capability is R860-T5.
2214///
2215/// Two ways a member is reached, in this order:
2216/// - [`Requirement::provides`], the inline spec a `supply = "self"` requirement
2217/// carries;
2218/// - otherwise an ident lookup against `declared` (`.yah/infra/workloads/`),
2219/// matched on mesh identity first and on workload name second, because those
2220/// coincide for every spec in the tree today but the requirement is written in
2221/// the mesh-identity currency.
2222///
2223/// An ident that resolves to neither is **skipped**, not an error: admission is
2224/// a pure function of the declared inventory and must not start failing deploys
2225/// over a provider that a not-yet-written ticket will declare. The cost is that
2226/// its request does not count toward the floor, which is the same exposure
2227/// `depends_on` has always had.
2228///
2229/// **Cycle-guarded.** `validate::check_requires` bounds `provides` *nesting* to
2230/// depth 1 but nothing stops two separately-declared specs from requiring each
2231/// other, and this closure would otherwise not terminate. Each requirement ident
2232/// is resolved at most once and each member joins the group at most once, so a
2233/// cycle simply closes the group.
2234pub fn placement_group(ws: &WorkloadSpec, declared: &[WorkloadConfig]) -> Vec<WorkloadSpec> {
2235 let mut members = vec![ws.clone()];
2236 // Two separate visited sets, because the two questions differ: `in_group`
2237 // stops a workload being added twice, `expanded` stops an ident being
2238 // resolved twice. Folding them into one list makes the ident of a member
2239 // already in the group indistinguishable from the member itself — and since
2240 // a requirement's ident *is* its provider's mesh identity, that reads every
2241 // provider as already-present and silently returns a group of one.
2242 let mut in_group: Vec<String> = vec![group_key(ws)];
2243 let mut expanded: Vec<String> = Vec::new();
2244 let mut next = 0;
2245
2246 while next < members.len() {
2247 let requirements = members[next].effective_requirements();
2248 next += 1;
2249 for req in requirements {
2250 if req.locality != Locality::Local {
2251 continue;
2252 }
2253 if expanded.contains(&req.ident.0) {
2254 continue;
2255 }
2256 expanded.push(req.ident.0.clone());
2257
2258 let provider = match req.provides.as_deref() {
2259 Some(spec) => spec.clone(),
2260 None => match resolve_requirement_ident(&req.ident, declared) {
2261 Some(spec) => spec,
2262 None => continue,
2263 },
2264 };
2265 let key = group_key(&provider);
2266 if in_group.contains(&key) {
2267 continue;
2268 }
2269 in_group.push(key);
2270 members.push(provider);
2271 }
2272 }
2273
2274 members
2275}
2276
2277/// Whether a placement group may be drained off its node (W338 §Placement
2278/// consequences 2): false as soon as **any** member is an Appliance.
2279///
2280/// The set-valued form of the per-workload check the node itself makes in
2281/// `drain_workloads` (`oss/yubaba/crates/yubaba/src/lib.rs`), which skips an
2282/// Appliance by its own archetype and knows nothing about requirement edges. A
2283/// `Server` bound to an Appliance by a `local` edge has to move with it or not
2284/// at all, so draining it alone breaks the group the same way placing it alone
2285/// would.
2286///
2287/// R860-T6 moved the body to [`workload_spec::group_is_drainable`] and left this
2288/// signature untouched. The node's `drain_workloads` needs the identical
2289/// predicate, and yubaba has no runtime dependency on this crate by design
2290/// (R374-F3) — so the one implementation now lives in the crate both sides
2291/// already depend on, rather than being copied into the second caller.
2292pub fn group_is_drainable(members: &[WorkloadSpec]) -> bool {
2293 workload_spec::group_is_drainable(members)
2294}
2295
2296/// Identity a placement-group member is deduplicated by — its mesh identity,
2297/// which is the currency [`Requirement::ident`] is written in.
2298fn group_key(ws: &WorkloadSpec) -> String {
2299 ws.expose.mesh.identity.0.clone()
2300}
2301
2302/// Resolve a requirement's ident to a separately-declared spec: mesh identity
2303/// first, workload file name second.
2304fn resolve_requirement_ident(
2305 ident: &workload_spec::MeshIdent,
2306 declared: &[WorkloadConfig],
2307) -> Option<WorkloadSpec> {
2308 declared
2309 .iter()
2310 .find(|w| w.spec.expose.mesh.identity == *ident)
2311 .or_else(|| declared.iter().find(|w| w.spec.name == ident.0))
2312 .map(|w| w.spec.clone())
2313}
2314
2315/// The mesh tag a node declares to advertise that its kamaji can run **native**
2316/// (fork+exec) workloads — R860-T5 / W338 §"Placement consequences" 3.
2317///
2318/// A workload marked `yah.exec = native` ([`WorkloadSpec::wants_native_exec`])
2319/// is not containerized: kamaji fork+execs it on the node's own userland. That
2320/// backend only exists when the node's kamaji was **built** with the
2321/// `native-exec` cargo feature and **started** with `--native-exec-dir`
2322/// (`oss/kamaji/crates/kamaji-bin/src/main.rs`). Both are node-local startup
2323/// decisions, invisible to everything upstream — so before this tag, placement
2324/// happily elected a node whose kamaji then refused the deploy with
2325/// `BackendRefused: ... no native backend is available (native backend not
2326/// configured — start kamaji with --native-exec-dir)`. That is exactly how the
2327/// mesh lost its coordination server for 25 hours on 2026-09-03 (R858: raft
2328/// leadership moved headscale, a native workload, to `us-south-001`, which has
2329/// no such kamaji). [`admission_spec`] now requires this tag whenever any
2330/// placement-group member is native, which turns that dispatch-time surprise
2331/// into a placement precondition.
2332///
2333/// # Why a mesh tag and not a taint
2334///
2335/// The two vocabularies on [`MachineConfig`] mean opposite things. `mesh_tags`
2336/// are **positive capability** matched as a superset — "this node CAN" — which
2337/// is precisely the claim being made, and an extra tag on a machine can only
2338/// ever make it match *more* requirement sets, so declaring it is regression-
2339/// free. `taints` are **repulsion** — "keep this class off" — and would have to
2340/// be inverted (`no-native-exec` on every node lacking the backend, i.e. the
2341/// declaration burden falls on the majority) *and* taught to
2342/// [`taint_effect`], or [`crate::validate::check_inert_taints`] would correctly
2343/// lint the key dead.
2344///
2345/// # The `cap:` namespace
2346///
2347/// New here. The live prefixes are `tag:` (role — `tag:build-worker`,
2348/// `tag:qed`, `tag:cloud-runner`), `arch:` and `os:` (facts about the silicon
2349/// and userland), and `tier:` is reserved for the environment axis (R763, see
2350/// [`crate::validate::check_retired_arch_tags`]). A *capability the daemon was
2351/// configured with* is none of those: it is not a role an operator assigns and
2352/// not a property of the hardware, it is a fact about how kamaji was started,
2353/// and it changes when the node is rolled. Nothing validates tag prefixes, so
2354/// this costs no wiring.
2355///
2356/// # Fails closed
2357///
2358/// A node that does not declare it is not a candidate. An undeclared fleet
2359/// therefore reports "no node admits" at election time rather than dispatching
2360/// to a node that will refuse — the refusal moves earlier and names the
2361/// constraint, which is the whole point. Declared today (from readings recorded
2362/// in-repo, not inferred) on `us-west-001` and `us-west-003`; see those
2363/// machines' TOMLs for the evidence and the date.
2364pub const NATIVE_EXEC_MESH_TAG: &str = "cap:native-exec";
2365
2366/// Parse the R594 mesh-tag node-selector off a workload's annotations into the
2367/// requested tag set. Absent annotation or empty value ⇒ empty vec ("no
2368/// constraint"). Whitespace around each comma-separated tag is trimmed and
2369/// empty segments are dropped, so `"tag:build-worker, arch:x86"` and
2370/// `"tag:build-worker,arch:x86"` parse identically.
2371pub fn node_selector_mesh_tags(ws: &WorkloadSpec) -> Vec<String> {
2372 ws.annotations
2373 .get(velveteen_exec::remote::NODE_SELECTOR_MESH_TAGS_ANNOTATION)
2374 .map(|v| {
2375 v.split(',')
2376 .map(str::trim)
2377 .filter(|s| !s.is_empty())
2378 .map(String::from)
2379 .collect()
2380 })
2381 .unwrap_or_default()
2382}
2383
2384/// Parse the R833-F8 imperative node-selector off a workload's annotations —
2385/// the single machine `name` the operator pinned the run to
2386/// (`--where=node:us-west-003`). Absent or blank ⇒ `None` ("no constraint"),
2387/// which is every workload built before this axis existed.
2388///
2389/// One node, not a list: the annotation exists to express "run it *there*", and
2390/// a comma-joined set would be a worse spelling of the mesh-tag selector that
2391/// already handles "any of these".
2392pub fn node_selector_node(ws: &WorkloadSpec) -> Option<String> {
2393 ws.annotations
2394 .get(velveteen_exec::remote::NODE_SELECTOR_NODE_ANNOTATION)
2395 .map(|v| v.trim())
2396 .filter(|v| !v.is_empty())
2397 .map(String::from)
2398}
2399
2400/// Load every `.yah/infra/providers/*.toml` into a [`ProviderConfig`] list.
2401/// Missing directory → empty list.
2402fn load_providers(dir: &Path) -> Result<Vec<ProviderConfig>> {
2403 if !dir.exists() {
2404 return Ok(vec![]);
2405 }
2406 let mut items = vec![];
2407 let mut entries: Vec<_> = std::fs::read_dir(dir)
2408 .with_context(|| format!("reading {}", dir.display()))?
2409 .filter_map(|e| e.ok())
2410 .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
2411 .collect();
2412 entries.sort_by_key(|e| e.file_name());
2413 for entry in entries {
2414 items.push(ProviderConfig::load(&entry.path())?);
2415 }
2416 Ok(items)
2417}
2418
2419/// Map legacy mirror file stems to their canonical tier names.
2420///
2421/// Canonical tiers: `dev` / `pond` / `cloud` / `ha`.
2422/// Legacy stems pre-R362: `local` (dev tier), `local-sim` / `sim` (pond tier), `prod` (cloud tier).
2423/// Both forms are accepted; canonical names are preferred for new files.
2424pub fn canonical_tier(stem: &str) -> &str {
2425 match stem {
2426 "local" => "dev",
2427 "local-sim" | "sim" => "pond",
2428 "prod" => "cloud",
2429 other => other,
2430 }
2431}
2432
2433/// Walk `.yah/services/<svc>/` for every service and its mirrors.
2434/// Missing directory → empty map. Mirror file stems are normalized to canonical
2435/// tier names via [`canonical_tier`] so callers always see `dev/pond/cloud/ha`.
2436fn load_services(
2437 dir: &Path,
2438 workspace_root: &Path,
2439) -> Result<BTreeMap<String, ServiceWithMirrors>> {
2440 if !dir.exists() {
2441 return Ok(BTreeMap::new());
2442 }
2443 let mut out = BTreeMap::new();
2444 let mut entries: Vec<_> = std::fs::read_dir(dir)
2445 .with_context(|| format!("reading {}", dir.display()))?
2446 .filter_map(|e| e.ok())
2447 .filter(|e| e.path().is_dir())
2448 .collect();
2449 entries.sort_by_key(|e| e.file_name());
2450
2451 for entry in entries {
2452 let svc_dir = entry.path();
2453 let service_toml = svc_dir.join("service.toml");
2454 if !service_toml.exists() {
2455 // Skip directories without a service.toml — leaves room for
2456 // future siblings (e.g. `secrets/`, `README.md`) without
2457 // triggering false-positive parse errors.
2458 continue;
2459 }
2460 let service = ServiceConfig::load(&service_toml)?;
2461 let mut mirrors = BTreeMap::new();
2462 let mirrors_dir = svc_dir.join("mirrors");
2463 if mirrors_dir.exists() {
2464 let mut menv: Vec<_> = std::fs::read_dir(&mirrors_dir)
2465 .with_context(|| format!("reading {}", mirrors_dir.display()))?
2466 .filter_map(|e| e.ok())
2467 .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
2468 .collect();
2469 menv.sort_by_key(|e| e.file_name());
2470 for m in menv {
2471 let path = m.path();
2472 let stem = path
2473 .file_stem()
2474 .and_then(|s| s.to_str())
2475 .unwrap_or("")
2476 .to_string();
2477 let tier = canonical_tier(&stem).to_string();
2478 // Last-write wins if both legacy and canonical forms coexist
2479 // (e.g. local-sim.toml + pond.toml). Sort order ensures the
2480 // canonical file (pond.toml) wins because 'p' > 'l'.
2481 mirrors.insert(tier, MirrorConfig::load(&path)?);
2482 }
2483 }
2484 let mut component_transform_recipes = BTreeMap::new();
2485 for component in &service.components {
2486 if component.kind == "static-asset" {
2487 if let Some(recipe) =
2488 read_component_transform_recipe(workspace_root, &component.path)
2489 {
2490 component_transform_recipes.insert(component.id.clone(), recipe);
2491 }
2492 }
2493 }
2494 let passway_machines = mirrors
2495 .iter()
2496 .filter_map(|(env, m)| m.passway_machines().map(|ms| (env.clone(), ms)))
2497 .collect();
2498 out.insert(
2499 service.name.clone(),
2500 ServiceWithMirrors {
2501 service,
2502 mirrors,
2503 component_transform_recipes,
2504 passway_machines,
2505 },
2506 );
2507 }
2508 Ok(out)
2509}
2510
2511/// Read the first transform recipe name from a component's `workload.toml`.
2512/// Returns `None` when the file is absent or has no `[asset.derive.transform]`
2513/// section. Best-effort — parse failures are silently ignored so a malformed
2514/// workload.toml doesn't abort the entire service catalog load.
2515fn read_component_transform_recipe(workspace_root: &Path, component_path: &str) -> Option<String> {
2516 let workload_path = workspace_root.join(component_path).join("workload.toml");
2517 let text = std::fs::read_to_string(&workload_path).ok()?;
2518 let value: toml::Value = toml::from_str(&text).ok()?;
2519 let assets = value.get("asset")?.as_array()?;
2520 for asset in assets {
2521 if let Some(recipe) = asset
2522 .get("derive")
2523 .and_then(|d| d.get("transform"))
2524 .and_then(|t| t.get("recipe"))
2525 .and_then(|r| r.as_str())
2526 {
2527 return Some(recipe.to_string());
2528 }
2529 }
2530 None
2531}
2532
2533/// Load every `.yah/domains/*.toml` into a [`DomainConfig`] map keyed by
2534/// file stem. Missing directory → empty map.
2535fn load_domains(dir: &Path) -> Result<BTreeMap<String, DomainConfig>> {
2536 if !dir.exists() {
2537 return Ok(BTreeMap::new());
2538 }
2539 let mut out = BTreeMap::new();
2540 let mut entries: Vec<_> = std::fs::read_dir(dir)
2541 .with_context(|| format!("reading {}", dir.display()))?
2542 .filter_map(|e| e.ok())
2543 .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
2544 .collect();
2545 entries.sort_by_key(|e| e.file_name());
2546 for entry in entries {
2547 let path = entry.path();
2548 let stem = path
2549 .file_stem()
2550 .and_then(|s| s.to_str())
2551 .unwrap_or("")
2552 .to_string();
2553 let dom = DomainConfig::load(&path)?;
2554 if dom.name != stem {
2555 anyhow::bail!(
2556 "domains/{}.toml: name = \"{}\" must match the file stem",
2557 stem,
2558 dom.name
2559 );
2560 }
2561 out.insert(dom.name.clone(), dom);
2562 }
2563 Ok(out)
2564}
2565
2566/// Load and shape-validate all `*.toml` files in `dir` as [`WorkloadConfig`].
2567fn load_workloads(dir: std::path::PathBuf) -> Result<Vec<WorkloadConfig>> {
2568 if !dir.exists() {
2569 return Ok(vec![]);
2570 }
2571 let mut items = vec![];
2572 let mut entries: Vec<_> = std::fs::read_dir(&dir)
2573 .with_context(|| format!("reading {}", dir.display()))?
2574 .filter_map(|e| e.ok())
2575 .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
2576 .collect();
2577 entries.sort_by_key(|e| e.file_name());
2578
2579 for entry in entries {
2580 let path = entry.path();
2581 let path_str = path.display().to_string();
2582 let src =
2583 std::fs::read_to_string(&path).with_context(|| format!("reading {}", path_str))?;
2584 let spec: WorkloadSpec =
2585 toml::from_str(&src).with_context(|| format!("parsing {}", path_str))?;
2586
2587 // Shape-validate before accepting into the loaded config.
2588 validate::shape(&spec)
2589 .map_err(|e| anyhow::anyhow!("workload {} failed shape validation: {e}", path_str))?;
2590
2591 items.push(WorkloadConfig { spec });
2592 }
2593 Ok(items)
2594}
2595
2596/// Load all mirror configs from the `mirrors/` directory.
2597///
2598/// Handles two layouts that may coexist:
2599/// - **Folder**: `mirrors/<id>/mirror.toml` — preferred; allows secrets and
2600/// per-mirror overrides to live next to the config file.
2601/// - **Flat**: `mirrors/<id>.toml` — legacy; still supported.
2602///
2603/// Each file is parsed as [`LegacyMirrorConfig`]. A malformed file returns an error
2604/// that includes the file path and the TOML field path + line/column, so the
2605/// caller can surface it to the user directly.
2606fn load_mirrors(dir: std::path::PathBuf) -> Result<Vec<LegacyMirrorConfig>> {
2607 if !dir.exists() {
2608 return Ok(vec![]);
2609 }
2610 let mut mirrors = vec![];
2611 let mut entries: Vec<_> = std::fs::read_dir(&dir)
2612 .with_context(|| format!("reading {}", dir.display()))?
2613 .filter_map(|e| e.ok())
2614 .collect();
2615 entries.sort_by_key(|e| e.file_name());
2616
2617 for entry in entries {
2618 let path = entry.path();
2619 if path.is_dir() {
2620 // Folder layout: mirrors/<id>/mirror.toml
2621 let mirror_toml = path.join("mirror.toml");
2622 if mirror_toml.exists() {
2623 let src = std::fs::read_to_string(&mirror_toml)
2624 .with_context(|| format!("reading {}", mirror_toml.display()))?;
2625 let cfg: LegacyMirrorConfig = toml::from_str(&src)
2626 .with_context(|| format!("parsing {}", mirror_toml.display()))?;
2627 mirrors.push(cfg);
2628 }
2629 } else if path.extension().map_or(false, |e| e == "toml") {
2630 // Flat layout: mirrors/<id>.toml
2631 let src = std::fs::read_to_string(&path)
2632 .with_context(|| format!("reading {}", path.display()))?;
2633 let cfg: LegacyMirrorConfig =
2634 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
2635 mirrors.push(cfg);
2636 }
2637 }
2638 Ok(mirrors)
2639}
2640
2641/// Load `topology.toml` if it exists; return a default (empty) topology otherwise.
2642fn load_topology(path: std::path::PathBuf) -> Result<TopologyConfig> {
2643 if !path.exists() {
2644 return Ok(TopologyConfig::default());
2645 }
2646 let src =
2647 std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
2648 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
2649}
2650
2651/// R555-S1: entries are sorted by file name before parsing, so "declaration
2652/// order in `.yah/infra/machines/` breaks ties" — the contract
2653/// [`CloudConfig::admit_workload`] documents — is actually true. `read_dir`
2654/// yields filesystem order, which is unspecified and differs between APFS and
2655/// a hashed-dir ext4; without the sort, *which* of two equally-matching nodes a
2656/// workload admits to could change when an unrelated file is added to the
2657/// directory. That was latent while each tag set had one match and became
2658/// observable the day us-west-003 joined us-west-002 on
2659/// `[tag:build-worker, arch:x86, os:linux]`. Same sort `load_providers` has
2660/// always done.
2661fn load_dir<T: for<'de> Deserialize<'de>>(dir: std::path::PathBuf) -> Result<Vec<T>> {
2662 if !dir.exists() {
2663 return Ok(vec![]);
2664 }
2665 let mut entries: Vec<_> = std::fs::read_dir(&dir)
2666 .with_context(|| format!("reading {}", dir.display()))?
2667 .collect::<std::io::Result<Vec<_>>>()
2668 .with_context(|| format!("reading {}", dir.display()))?;
2669 entries.sort_by_key(|e| e.file_name());
2670
2671 let mut items = vec![];
2672 for entry in entries {
2673 let path = entry.path();
2674 if path.extension().map_or(false, |e| e == "toml") {
2675 let src = std::fs::read_to_string(&path)
2676 .with_context(|| format!("reading {}", path.display()))?;
2677 let item: T =
2678 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
2679 items.push(item);
2680 }
2681 }
2682 Ok(items)
2683}
2684
2685// ─── New manifest shapes (R222 B2) ───────────────────────────────────────────
2686//
2687// The post-R215 layout splits substrate from service declarations:
2688//
2689// .yah/infra/providers/<id>.toml → ProviderConfig
2690// .yah/services/<svc>/service.toml → ServiceConfig
2691// .yah/services/<svc>/mirrors/<env>.toml → MirrorConfig
2692//
2693// CloudConfig::load still reads the legacy layout — B3 swaps in these types
2694// and removes the Legacy* shapes plus TopologyConfig.
2695
2696/// Tag for the infrastructure provider kind. Drives which fields are valid in
2697/// a [`ProviderConfig`] body or a [`MirrorProviderSlot::Inline`] block.
2698///
2699/// Two flavors:
2700/// - **Account/runtime providers** (`cloudflare`, `hetzner`, `local-container`)
2701/// live as files under `.yah/infra/providers/<id>.toml` and are referenced
2702/// from a mirror via `use = "<id>"`.
2703/// - **Inline-only providers** (`local-static`, `miniflare-container`,
2704/// `minio-container`) declare an operator-local stand-in directly inside a
2705/// mirror via `kind = "..."`. They carry no credentials and have no provider
2706/// file. The container-backed kinds ride on top of whichever
2707/// `local-container` runtime is declared in infra (orbstack/colima/docker);
2708/// the reconciler resolves the runtime at up-time.
2709#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2710#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2711#[serde(rename_all = "kebab-case")]
2712pub enum Provider {
2713 /// Cloudflare account: R2 buckets, DNS, Workers, Tunnels.
2714 Cloudflare,
2715 /// Hetzner Cloud + Object Storage account.
2716 Hetzner,
2717 /// Vultr cloud VPS — auto-provisioned via the `cloud.vps.*` Envoy
2718 /// (`VultrEnvoy`), the burst/scaling counterpart to Hetzner. Driver-backed.
2719 Vultr,
2720 /// BYO bare/static node (OVH, on-prem, anything we did NOT provision via a
2721 /// cloud API). Brought up over SSH (`stand-up-yubaba.sh` / `yah cloud
2722 /// machine bootstrap`); reach is declared in the machine's `[connect]`
2723 /// block. No create/destroy driver — placement-only.
2724 Static,
2725 /// Built-in static-file server bound to localhost. Inline-only; never
2726 /// declared as a standalone provider file because it carries no creds.
2727 LocalStatic,
2728 /// Local container runtime (orbstack/colima/docker). Configured by a
2729 /// provider file under `.yah/infra/providers/` so the discovery hints +
2730 /// runtime override sit in one place.
2731 LocalContainer,
2732 /// Dev-tier compute: the component runs as a kamaji-supervised host
2733 /// process against the operator's real workspace, no container and no
2734 /// build step per edit. Inline-only — it carries no credentials, and
2735 /// "the machine you are sitting at" is not an account to point at.
2736 /// See `reconciler::local_process`.
2737 LocalProcess,
2738 /// Containerized miniflare (workerd subprocess) fronting MinIO — the
2739 /// pond-tier stand-in for a CF Worker + R2 static surface. Inline-only;
2740 /// the reconciler spawns miniflare via the JS runtime and starts a MinIO
2741 /// container on the local-container runtime.
2742 MiniflareContainer,
2743 /// Containerized MinIO providing an S3-compatible API — the pond-tier
2744 /// stand-in for Cloudflare R2. Inline-only; the reconciler spins up the
2745 /// container on the local-container runtime and auto-creates the declared
2746 /// bucket on first up.
2747 MinioContainer,
2748 /// Dev-tier PostgreSQL — a real server speaking real pgwire on loopback,
2749 /// supervised by kamaji as the `yah-pg-dev` workload (W265, R584-F1). No
2750 /// docker daemon: the driver fetches a per-arch PostgreSQL tarball on first
2751 /// run and `initdb`s a cluster under `.yah/infra/state/dev/pg/`.
2752 ///
2753 /// Inline-only — it carries no credentials worth a provider file (the
2754 /// cluster is loopback-bound with a fixed dev password). Declared under
2755 /// [`MirrorConfig::drivers`], not `providers`:
2756 ///
2757 /// ```toml
2758 /// [drivers.pg]
2759 /// kind = "local-pg-dev"
2760 /// ```
2761 LocalPgDev,
2762}
2763
2764/// A provider account/runtime binding from `.yah/infra/providers/<id>.toml`.
2765///
2766/// The `kind` discriminator picks the schema for the remaining fields. Strict
2767/// on `kind` (unknown values are a parse error); permissive on per-kind fields
2768/// (carried as a free-form map so this loader stays stable as new fields land).
2769/// B3/B4 will tighten by introducing typed variants alongside JSON Schema.
2770#[derive(Debug, Clone, Serialize, Deserialize)]
2771#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2772pub struct ProviderConfig {
2773 pub schema_version: u32,
2774 pub id: String,
2775 pub kind: Provider,
2776 /// Reference into the OS keystore for live credentials (e.g.
2777 /// `"keystore://cloudflare/yah"`). `None` for providers that don't need
2778 /// creds (local-static, optionally local-container).
2779 #[serde(default, skip_serializing_if = "Option::is_none")]
2780 pub credentials: Option<String>,
2781 /// Kind-specific fields. Examples:
2782 /// - cloudflare: `default_zone`
2783 /// - hetzner: `default_location`, `default_server_type`, `ssh_keys`
2784 /// - local-container: `runtime`, `discovery`
2785 #[serde(flatten)]
2786 #[cfg_attr(
2787 feature = "json-schema",
2788 schemars(with = "std::collections::BTreeMap<String, serde_json::Value>")
2789 )]
2790 pub fields: BTreeMap<String, toml::Value>,
2791}
2792
2793impl ProviderConfig {
2794 /// Parse a single `providers/<id>.toml` file.
2795 pub fn load(path: &Path) -> Result<Self> {
2796 let src =
2797 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
2798 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
2799 }
2800}
2801
2802/// An operator-facing service declaration from
2803/// `.yah/services/<svc>/service.toml`.
2804///
2805/// A service groups one or more components (a static surface, a containerized
2806/// API, an almanac…) under a single domain. Mirrors project the service onto
2807/// concrete infra; see [`MirrorConfig`].
2808#[derive(Debug, Clone, Serialize, Deserialize)]
2809#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2810pub struct ServiceConfig {
2811 pub schema_version: u32,
2812 pub name: String,
2813 pub domain: String,
2814 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2815 pub components: Vec<ServiceComponent>,
2816 /// Databases this service exposes, grouped by environment (W241). Every
2817 /// entry becomes a data-workbench / `sql_*` catalog id of the shape
2818 /// `<env>:<service>:<name>` (e.g. `pond:scrabcake:main`). Optional and
2819 /// default-empty — services without databases omit the `[db]` table
2820 /// entirely.
2821 #[serde(default, skip_serializing_if = "DbCatalog::is_empty")]
2822 pub db: DbCatalog,
2823}
2824
2825impl ServiceConfig {
2826 /// Parse a single `services/<svc>/service.toml` file.
2827 pub fn load(path: &Path) -> Result<Self> {
2828 let src =
2829 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
2830 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
2831 }
2832
2833 /// Persist to `.yah/services/<name>/service.toml`, creating the service
2834 /// directory if needed. Create-or-overwrite — the canonical replacement
2835 /// for the legacy `sites.json` write path. `workspace_root` is the camp
2836 /// dir (the parent of `.yah/`).
2837 pub fn save(&self, workspace_root: &Path) -> Result<()> {
2838 let dir = crate::paths::service_dir(workspace_root, &self.name);
2839 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
2840 let path = crate::paths::service_toml(workspace_root, &self.name);
2841 let s = toml::to_string_pretty(self)
2842 .with_context(|| format!("serializing service {}", self.name))?;
2843 std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
2844 }
2845
2846 /// Remove `.yah/services/<name>/` and everything under it (service.toml
2847 /// plus its `mirrors/`). Returns `false` when the directory was already
2848 /// absent, so callers can distinguish "deleted" from "no-op".
2849 pub fn delete(workspace_root: &Path, name: &str) -> Result<bool> {
2850 let dir = crate::paths::service_dir(workspace_root, name);
2851 if !dir.exists() {
2852 return Ok(false);
2853 }
2854 std::fs::remove_dir_all(&dir).with_context(|| format!("removing {}", dir.display()))?;
2855 Ok(true)
2856 }
2857}
2858
2859/// A git source for a component (R561-F1, "BYO git").
2860///
2861/// When a [`ServiceComponent`] sets `git`, the component's code is NOT in this
2862/// workspace — it lives in an external repo that the reconciler shallow-clones
2863/// into a source cache before build (approach A: clone-at-reconcile, so config
2864/// load + validation stay offline). The component's `path` is then interpreted
2865/// relative to `<checkout>/<subdir>` instead of the workspace root.
2866#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2867#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2868pub struct GitSource {
2869 /// Clone URL (https or ssh) of the tenant repo.
2870 pub repo: String,
2871 /// Branch, tag, or commit SHA to check out. Defaults to `"main"`.
2872 #[serde(default = "default_git_ref")]
2873 pub r#ref: String,
2874 /// Optional sub-directory within the repo that the workspace is rooted at
2875 /// (e.g. a monorepo's `site/`). `path` is resolved relative to this.
2876 #[serde(default, skip_serializing_if = "Option::is_none")]
2877 pub subdir: Option<String>,
2878}
2879
2880fn default_git_ref() -> String {
2881 "main".to_string()
2882}
2883
2884/// How to reach an external infra root (R615-F1 / W274, "linked infra
2885/// sources"): a filesystem link to a sibling camp's live tree, or a git
2886/// checkout of an extracted infra repo.
2887#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2888#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2889#[serde(tag = "kind", rename_all = "kebab-case")]
2890pub enum InfraSourceKind {
2891 /// Filesystem link — reads the owner's live tree. The dev-loop shortcut,
2892 /// and the whole story until W274's "infra as its own repo" end-state.
2893 /// `path` is relative to *this* camp's root; infra is read from
2894 /// `<path>/.yah/infra/`.
2895 Path {
2896 path: String,
2897 },
2898 /// Git link — reused verbatim from [`GitSource`] (R561, "BYO git"),
2899 /// lifted here from "a component's code" to "a camp's infra registry."
2900 /// Loading stays offline (W274 §3): `yah infra sync` (R615-T3) is what
2901 /// clones/pulls this into `.yah/cache/infra/<owner>/`; `CloudConfig::load`
2902 /// only ever reads that cache, never the network.
2903 Git(GitSource),
2904}
2905
2906/// Write-gate for a linked [`InfraSource`] (R615-F1 / W274).
2907///
2908/// An enum, not a bool: the two states today are "borrower renders/plans but
2909/// cannot reconcile" and "this camp genuinely co-administers the shared
2910/// root," and a future read-write-with-approval tier is a third variant, not
2911/// a renamed boolean.
2912#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
2913#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2914#[serde(rename_all = "kebab-case")]
2915pub enum SourceMode {
2916 /// Borrower can render and plan against the linked entries but cannot
2917 /// reconcile/mutate them — the owner remains the single manager. Default:
2918 /// a borrower is opt-in to write access, never opt-out of the safe state.
2919 #[default]
2920 ReadOnly,
2921 /// Escape hatch for a camp that genuinely co-administers a shared root.
2922 Manage,
2923}
2924
2925/// One `[[source]]` entry in `.yah/infra/sources.toml` (R615-F1 / W274) — an
2926/// external infra root this camp borrows machines/providers from.
2927#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2928#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2929pub struct InfraSource {
2930 /// Logical owner name, badged in the Infra tab (e.g. `"yah"`). Distinct
2931 /// from any camp/repo name the `kind` resolves through — this is what an
2932 /// operator sees on a borrowed row, not a path.
2933 pub owner: String,
2934 #[serde(flatten)]
2935 pub kind: InfraSourceKind,
2936 #[serde(default)]
2937 pub mode: SourceMode,
2938 /// Optional filter — name globs or mesh-tag selectors — to borrow a
2939 /// subset of the source root rather than everything it declares. Empty
2940 /// (the default) borrows everything.
2941 #[serde(default)]
2942 pub select: Vec<String>,
2943}
2944
2945fn default_sources_schema_version() -> u32 {
2946 1
2947}
2948
2949/// `.yah/infra/sources.toml` — the ordered list of external infra roots this
2950/// camp borrows from (R615-F1 / W274).
2951#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2952#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2953pub struct SourcesConfig {
2954 #[serde(default = "default_sources_schema_version")]
2955 pub schema_version: u32,
2956 /// `[[source]]` entries, in declaration order — overlay order matters
2957 /// when two linked sources both name the same machine (R615-F2).
2958 #[serde(default, rename = "source")]
2959 pub source: Vec<InfraSource>,
2960}
2961
2962impl Default for SourcesConfig {
2963 fn default() -> Self {
2964 Self {
2965 schema_version: default_sources_schema_version(),
2966 source: Vec::new(),
2967 }
2968 }
2969}
2970
2971impl SourcesConfig {
2972 /// Load `<infra_dir>/sources.toml`. A missing file is not an error —
2973 /// every camp without linked infra has none, which today is every camp —
2974 /// and yields an empty source list rather than `Err`.
2975 pub fn load(infra_dir: &Path) -> Result<Self> {
2976 let path = infra_dir.join("sources.toml");
2977 if !path.exists() {
2978 return Ok(Self::default());
2979 }
2980 let src =
2981 std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
2982 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
2983 }
2984}
2985
2986impl InfraSource {
2987 /// Human-readable descriptor of *which* source this is, for
2988 /// [`InfraOrigin::source`] — distinguishes two linked sources from the
2989 /// same owner. Never includes credentials: `GitSource.repo` is a clone
2990 /// URL (https/ssh), the same thing R561 already treats as safe to log,
2991 /// with any real secret resolved separately via `keystore://` (W274's
2992 /// own precedent).
2993 fn describe(&self) -> String {
2994 match &self.kind {
2995 InfraSourceKind::Path { path } => format!("path:{path}"),
2996 InfraSourceKind::Git(g) => format!("git:{}@{}", g.repo, g.r#ref),
2997 }
2998 }
2999
3000 /// Resolve this source to an infra root directory (R615-F2 / W274 §3).
3001 /// Does no I/O and touches no network: `path` sources read the owner's
3002 /// live tree directly; `git` sources read wherever `yah infra sync`
3003 /// (R615-T3) last synced to, which may not exist yet (an unsynced git
3004 /// source overlays nothing, not an error — see [`load_dir_tolerant`]).
3005 ///
3006 /// `git.subdir` (reused verbatim from [`GitSource`]/R561) is honoured
3007 /// exactly like the component case: the checkout root when unset, or
3008 /// `<checkout>/<subdir>` when set — e.g. `subdir = "infra"` for a
3009 /// monorepo whose infra registry lives under `infra/` rather than at the
3010 /// clone's root. `yah infra sync` (R615-T3) clones into the *checkout*
3011 /// root ([`crate::paths::infra_source_cache_dir`]), never into a
3012 /// subdir-suffixed path, so this is the one place that appends `subdir`.
3013 fn infra_root(&self, workspace_root: &Path) -> std::path::PathBuf {
3014 match &self.kind {
3015 InfraSourceKind::Path { path } => workspace_root.join(path).join(".yah").join("infra"),
3016 InfraSourceKind::Git(g) => {
3017 let checkout = crate::paths::infra_source_cache_dir(workspace_root, &self.owner);
3018 match g.subdir.as_deref() {
3019 Some(subdir) => checkout.join(subdir),
3020 None => checkout,
3021 }
3022 }
3023 }
3024 }
3025}
3026
3027/// Provenance for a [`MachineConfig`] or [`ProviderConfig`] pulled in from a
3028/// linked `.yah/infra/sources.toml` entry, rather than declared in this
3029/// camp's own `.yah/infra/` (R615-F2 / W274).
3030///
3031/// Lives in [`CloudConfig::machine_origins`] / `provider_origins`, keyed by
3032/// name/id, rather than as a field on `MachineConfig`/`ProviderConfig`
3033/// themselves: those two types are constructed by struct literal in test
3034/// helpers across several crates (including ones this ticket has no reason to
3035/// touch), so widening either shape would ripple out past this crate for no
3036/// semantic gain — origin is a property of *this load*, not an inherent
3037/// property of the machine/provider. A name absent from the map is
3038/// camp-local; present means borrowed, and the Infra tab (R615-F4) / reconcile
3039/// gating (`InfraSource::mode`, copied onto `mode` below) read it from here.
3040#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3041#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3042pub struct InfraOrigin {
3043 /// The [`InfraSource::owner`] that supplied this entry, e.g. `"yah"`.
3044 pub owner: String,
3045 /// Which source, rendered — see [`InfraSource::describe`].
3046 pub source: String,
3047 /// The write-gate that applied when this entry was overlaid — copied
3048 /// from [`InfraSource::mode`] so a caller holding just the machine/
3049 /// provider doesn't need the source list in hand to know it's borrowed
3050 /// read-only.
3051 pub mode: SourceMode,
3052}
3053
3054/// Like [`load_dir`], but tolerant **per file**: a foreign infra root (an
3055/// owner's live tree, or a synced git checkout) can carry entries this
3056/// binary's `T` predates — noisetable's pre-migration machines used an older
3057/// schema than yah's, and the reverse will happen too as each side evolves
3058/// independently. One unparseable file on a source this camp doesn't own must
3059/// never sink every other entry in the same directory, let alone this camp's
3060/// own load (R615-F2 gotcha). Contrast [`load_dir`], which stays strict for
3061/// camp-local files, where a malformed TOML genuinely should be a hard error.
3062///
3063/// Returns the entries that parsed, plus `(path, error)` for every file that
3064/// didn't — the caller logs those, it doesn't drop them silently. A missing
3065/// or unreadable directory yields `(vec![], vec![])`, same "no entries" as
3066/// `load_dir`'s `!dir.exists()` case (an unsynced git source, or a source
3067/// root with no `providers/` at all, are both normal, not warnings).
3068fn load_dir_tolerant<T: for<'de> Deserialize<'de>>(
3069 dir: &Path,
3070) -> (Vec<T>, Vec<(std::path::PathBuf, anyhow::Error)>) {
3071 let Ok(read_dir) = std::fs::read_dir(dir) else {
3072 return (Vec::new(), Vec::new());
3073 };
3074 let mut entries: Vec<_> = read_dir.filter_map(|e| e.ok()).collect();
3075 entries.sort_by_key(|e| e.file_name());
3076
3077 let mut items = Vec::new();
3078 let mut skipped = Vec::new();
3079 for entry in entries {
3080 let path = entry.path();
3081 if path.extension().map_or(true, |e| e != "toml") {
3082 continue;
3083 }
3084 let parsed = std::fs::read_to_string(&path)
3085 .with_context(|| format!("reading {}", path.display()))
3086 .and_then(|src| {
3087 toml::from_str::<T>(&src).with_context(|| format!("parsing {}", path.display()))
3088 });
3089 match parsed {
3090 Ok(item) => items.push(item),
3091 Err(e) => skipped.push((path, e)),
3092 }
3093 }
3094 (items, skipped)
3095}
3096
3097/// Whether a borrowed machine passes an [`InfraSource::select`] filter
3098/// (R615-F2 / W274). Empty `select` borrows everything. A non-empty `select`
3099/// entry matches either the machine's exact `name` or literal membership in
3100/// its `mesh_tags` — the one shape W274's own example uses
3101/// (`select = ["tag:cloud-runner"]`). Not a glob engine: mesh tags are
3102/// already flat strings compared for exact equality everywhere else in this
3103/// crate (see `resolve_machine_by_mesh_tags`), so a select entry is that same
3104/// comparison, not a new pattern language.
3105fn machine_matches_select(machine: &MachineConfig, select: &[String]) -> bool {
3106 select.is_empty()
3107 || select
3108 .iter()
3109 .any(|s| *s == machine.name || machine.mesh_tags.contains(s))
3110}
3111
3112/// Overlay every linked `.yah/infra/sources.toml` source's machines and
3113/// providers into `machines`/`providers`, recording provenance into
3114/// `machine_origins`/`provider_origins` (R615-F2 / W274). Must be called
3115/// AFTER camp-local entries are already in both vectors and both origin maps
3116/// are seeded with every camp-local name/id already `HashSet`-tracked as
3117/// "seen": collision resolution is "first writer wins," so seeding with
3118/// camp-local first is what makes camp-local win over every source, and an
3119/// earlier source win over a later one.
3120///
3121/// `select` filters which machines a source contributes; it does not apply
3122/// to providers (nothing in W274 or the source ticket describes a
3123/// provider-scoped filter — every provider a source declares either overlays
3124/// whole or, on a name collision, doesn't).
3125fn overlay_infra_sources(
3126 workspace_root: &Path,
3127 sources: &SourcesConfig,
3128 machines: &mut Vec<MachineConfig>,
3129 providers: &mut Vec<ProviderConfig>,
3130 machine_origins: &mut BTreeMap<String, InfraOrigin>,
3131 provider_origins: &mut BTreeMap<String, InfraOrigin>,
3132) {
3133 let mut seen_machine_names: std::collections::HashSet<String> =
3134 machines.iter().map(|m| m.name.clone()).collect();
3135 let mut seen_provider_ids: std::collections::HashSet<String> =
3136 providers.iter().map(|p| p.id.clone()).collect();
3137
3138 for source in &sources.source {
3139 let root = source.infra_root(workspace_root);
3140 let origin = InfraOrigin {
3141 owner: source.owner.clone(),
3142 source: source.describe(),
3143 mode: source.mode,
3144 };
3145
3146 let (foreign_machines, skipped) = load_dir_tolerant::<MachineConfig>(&root.join("machines"));
3147 for (path, e) in skipped {
3148 tracing::warn!(
3149 "infra source {:?} ({}): skipping unparseable machine {}: {e:#}",
3150 source.owner,
3151 root.display(),
3152 path.display()
3153 );
3154 }
3155 for m in foreign_machines {
3156 if seen_machine_names.contains(&m.name) {
3157 continue; // camp-local, or an earlier source, already claimed this name
3158 }
3159 if !machine_matches_select(&m, &source.select) {
3160 continue;
3161 }
3162 seen_machine_names.insert(m.name.clone());
3163 machine_origins.insert(m.name.clone(), origin.clone());
3164 machines.push(m);
3165 }
3166
3167 let (foreign_providers, skipped) = load_dir_tolerant::<ProviderConfig>(&root.join("providers"));
3168 for (path, e) in skipped {
3169 tracing::warn!(
3170 "infra source {:?} ({}): skipping unparseable provider {}: {e:#}",
3171 source.owner,
3172 root.display(),
3173 path.display()
3174 );
3175 }
3176 for p in foreign_providers {
3177 if seen_provider_ids.contains(&p.id) {
3178 continue;
3179 }
3180 seen_provider_ids.insert(p.id.clone());
3181 provider_origins.insert(p.id.clone(), origin.clone());
3182 providers.push(p);
3183 }
3184 }
3185}
3186
3187/// One component of a [`ServiceConfig`]. The `kind` (e.g. `"mesofact-static"`,
3188/// `"almanac"`, `"container"`) selects which reconciler runs against the
3189/// pointed-at workload manifest.
3190#[derive(Debug, Clone, Serialize, Deserialize)]
3191#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3192pub struct ServiceComponent {
3193 pub id: String,
3194 pub kind: String,
3195 /// Path of the directory holding this component's `workload.toml`. Relative
3196 /// to the workspace root for in-tree components, or to the materialized
3197 /// `<checkout>/<subdir>` when [`git`](Self::git) is set.
3198 pub path: String,
3199 /// Optional external git source (R561-F1). When set, the component's code
3200 /// is materialized by shallow-clone before build; see [`GitSource`].
3201 #[serde(default, skip_serializing_if = "Option::is_none")]
3202 pub git: Option<GitSource>,
3203 /// Operator-facing role label, e.g. `"static"`, `"dynamic"`, `"compute"`.
3204 pub role: String,
3205 /// Optional artifact kind this component publishes (`"static"`,
3206 /// `"container-image"`, …). Drives mirror provider-slot routing.
3207 #[serde(default, skip_serializing_if = "Option::is_none")]
3208 pub publishes: Option<String>,
3209 /// URL sub-path a static component's build output is published under,
3210 /// relative to the service's publish prefix (R746). `None` = the service
3211 /// root, which is what every pre-R746 component means.
3212 ///
3213 /// Static publishers lay a component's `out_dir` down at
3214 /// `<bucket>/<service>/<env>/…` and the front door fetches
3215 /// `${ASSET_ORIGIN}/<request path>` — the request path *is* the key. So a
3216 /// service with two static components had them overwrite each other at
3217 /// one prefix, and there was no way to say "this bundle serves under
3218 /// /app". `mount` is that: it appends to the publish prefix, which makes
3219 /// the URL sub-path and the storage sub-path the same string by
3220 /// construction rather than by two manifests agreeing.
3221 ///
3222 /// Cross-checked against the domain route that names the component
3223 /// ([`CloudConfig::cross_ref_validate`]): a component mounted at `/app`
3224 /// must be routed at `/app` or `/app/*`, because a disagreement means
3225 /// requests land on a prefix nothing published to — a 404 whose cause is
3226 /// two files apart.
3227 #[serde(default, skip_serializing_if = "Option::is_none")]
3228 pub mount: Option<String>,
3229 /// Sync-wave index (0-based). Components in wave 0 roll out in parallel
3230 /// first; the reconciler waits for all wave-N components to become healthy
3231 /// before starting wave N+1. Defaults to 0 (all components in one wave).
3232 #[serde(default, skip_serializing_if = "is_zero_u32")]
3233 pub wave: u32,
3234}
3235
3236#[inline]
3237fn is_zero_u32(n: &u32) -> bool {
3238 *n == 0
3239}
3240
3241/// A service's declared databases, grouped by environment (W241 §Sections).
3242/// Parsed from the `[db]` table of `service.toml`; each `[[db.<env>]]` array
3243/// entry names one database. The environment tag drives backend selection at
3244/// query time (see the data-workbench's `db.query` / the `sql_*` MCP tools):
3245/// `dev` = local file, `pond` = a DB inside the running pond container stack
3246/// (reached on a declared localhost port), `cloud` = a remote libSQL/Turso or
3247/// Postgres endpoint whose auth comes from an env var (never stored in TOML).
3248#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3249#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3250pub struct DbCatalog {
3251 /// Local-file SQLite databases used in dev mode.
3252 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3253 pub dev: Vec<DevDb>,
3254 /// Databases running inside the pond container stack.
3255 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3256 pub pond: Vec<PondDb>,
3257 /// Remote cloud databases (Turso, Postgres).
3258 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3259 pub cloud: Vec<CloudDb>,
3260}
3261
3262impl DbCatalog {
3263 /// True when no database is declared in any environment. Lets
3264 /// [`ServiceConfig`] skip serializing an empty `[db]` table.
3265 pub fn is_empty(&self) -> bool {
3266 self.dev.is_empty() && self.pond.is_empty() && self.cloud.is_empty()
3267 }
3268}
3269
3270/// A dev-mode local SQLite database (`[[db.dev]]`). `path` is resolved
3271/// relative to the workspace root and opened as a local file — read/write, no
3272/// network, no auth.
3273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3274#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3275pub struct DevDb {
3276 /// Logical name, unique within the service's `dev` list. Forms the `name`
3277 /// segment of the catalog id `dev:<service>:<name>`.
3278 pub name: String,
3279 /// On-disk SQLite path, relative to the workspace root (or absolute).
3280 pub path: String,
3281}
3282
3283/// A database running inside the pond container stack (`[[db.pond]]`). The
3284/// pond publishes the DB on a localhost TCP port; the hub connects to
3285/// `127.0.0.1:<port>` when the pond is up and returns a clear error when it is
3286/// not. Either `port` (defaulting to a libSQL/`sqld` HTTP endpoint) or a full
3287/// `url` must be given.
3288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3289#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3290pub struct PondDb {
3291 /// Logical name, unique within the service's `pond` list.
3292 pub name: String,
3293 /// Localhost TCP port the pond publishes the DB on. Interpreted per
3294 /// [`kind`](Self::kind). Mutually complete with `url` (provide one).
3295 #[serde(default, skip_serializing_if = "Option::is_none")]
3296 pub port: Option<u16>,
3297 /// Full connection URL, overriding `port` when set (e.g. a non-localhost
3298 /// host or an explicit scheme).
3299 #[serde(default, skip_serializing_if = "Option::is_none")]
3300 pub url: Option<String>,
3301 /// Wire protocol the pond DB speaks. Selects how a bare `port` becomes a
3302 /// URL: `turso` → `http://127.0.0.1:<port>` (libSQL/`sqld` over Hrana),
3303 /// `postgres` → `postgres://127.0.0.1:<port>`.
3304 #[serde(default)]
3305 pub kind: PondDbKind,
3306}
3307
3308/// Wire protocol of a [`PondDb`].
3309#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
3310#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3311#[serde(rename_all = "kebab-case")]
3312pub enum PondDbKind {
3313 /// libSQL / `sqld` over Hrana HTTP — the default.
3314 #[default]
3315 Turso,
3316 /// PostgreSQL wire protocol.
3317 Postgres,
3318}
3319
3320/// A remote cloud database (`[[db.cloud]]`). The connection `url` is stored in
3321/// TOML but the credential never is — `auth_token_env` names an environment
3322/// variable the daemon reads at connect time, so the same declaration works
3323/// whether the token is provisioned service-locally or camp-shared (W241;
3324/// operator confirmed both scopes are needed). A camp-wide cloud DB not owned
3325/// by any single service is declared identically in `.yah/db/cloud.toml`.
3326#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
3327#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3328pub struct CloudDb {
3329 /// Logical name, unique within its `cloud` list.
3330 pub name: String,
3331 /// Connection URL: `libsql://…` / `http(s)://…` (Turso, `sqld`) or
3332 /// `postgres://…`.
3333 pub url: String,
3334 /// Name of the environment variable holding the auth token. Resolved in
3335 /// the daemon at connect time (value never stored on disk). For a libSQL
3336 /// URL the token is threaded as `?auth_token=…`.
3337 #[serde(default, skip_serializing_if = "Option::is_none")]
3338 pub auth_token_env: Option<String>,
3339}
3340
3341/// A camp-shared cloud database catalog, parsed from `.yah/db/cloud.toml`.
3342/// These are cloud DBs not owned by any single service — declared once at camp
3343/// scope and addressed as `cloud:<name>` (two-segment id), distinct from a
3344/// service-local `cloud:<service>:<name>`.
3345#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
3346#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3347pub struct CampCloudDbs {
3348 #[serde(default, rename = "cloud", skip_serializing_if = "Vec::is_empty")]
3349 pub cloud: Vec<CloudDb>,
3350}
3351
3352impl CampCloudDbs {
3353 /// Load `<camp_root>/.yah/db/cloud.toml`, or an empty catalog if the file
3354 /// is absent (the common case — most camps declare no shared cloud DBs).
3355 pub fn load(camp_root: &Path) -> Result<Self> {
3356 let path = camp_root.join(".yah/db/cloud.toml");
3357 if !path.exists() {
3358 return Ok(Self::default());
3359 }
3360 let src = std::fs::read_to_string(&path)
3361 .with_context(|| format!("reading {}", path.display()))?;
3362 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
3363 }
3364}
3365
3366/// Topological shape of a mirror — how its providers sit relative to each other.
3367#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3368#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3369#[serde(rename_all = "kebab-case")]
3370pub enum MirrorShape {
3371 /// Single machine hosts compute (and any non-Cloudflare-fronted static).
3372 SingleMachine,
3373 /// Operator-local dev mirror — static via built-in file server, compute
3374 /// via the local container runtime.
3375 Local,
3376 /// Multi-machine deployment (machines listed per provider slot).
3377 MultiMachine,
3378}
3379
3380/// Which public-ingress provider fronts this mirror's compute (W267, R594-F11).
3381///
3382/// Both arms answer exactly one question — *given these local workload ports,
3383/// make them publicly reachable at these hostnames* — and they differ only in
3384/// where the ingress rules live and who supervises the front door:
3385///
3386/// | | [`CloudflareTunnel`](Self::CloudflareTunnel) | [`Passway`](Self::Passway) |
3387/// |---|---|---|
3388/// | Ingress rules live | Cloudflare's API (token-form tunnels are remotely-managed) | the pingora `Backends` set in the proxy process |
3389/// | How they get there | an API call per deployed workload | passway polls `GET /service-records?ready=true` |
3390/// | Front door lifecycle | a kamaji-supervised `cloudflared` appliance | a kamaji-supervised passway appliance |
3391///
3392/// Flipping this field is the whole tier ladder: rented edge → sovereign edge
3393/// is a one-line mirror edit, not a rewrite. The provider owns **addressing**
3394/// and never **rendering** — the W173 render cube stays in mesofact's manifest.
3395#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
3396#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3397#[serde(rename_all = "kebab-case")]
3398pub enum IngressProvider {
3399 /// No public front door for this mirror. The default: a mirror that
3400 /// publishes to R2 behind a Worker, or a mesh-only compute tier, has no
3401 /// ingress provider to reconcile.
3402 #[default]
3403 None,
3404 /// Rented edge — `cloudflared` dials *out* from the node to Cloudflare's
3405 /// edge. Zero inbound ports, no TLS to manage on the box, hostname rules
3406 /// held in Cloudflare's API.
3407 CloudflareTunnel,
3408 /// Sovereign edge — passway terminates TLS on the node and load-balances
3409 /// an upstream set discovered from yubaba's service records.
3410 Passway,
3411}
3412
3413impl IngressProvider {
3414 /// `true` when this mirror declares a front door that has to be reconciled.
3415 pub fn is_declared(self) -> bool {
3416 !matches!(self, Self::None)
3417 }
3418
3419 /// Kebab-case wire name, as it appears in `mirrors/<env>.toml`.
3420 pub fn as_str(self) -> &'static str {
3421 match self {
3422 Self::None => "none",
3423 Self::CloudflareTunnel => "cloudflare-tunnel",
3424 Self::Passway => "passway",
3425 }
3426 }
3427}
3428
3429/// One declared **edge**: a front door, the slots it fronts, and the nodes it
3430/// is placed on (W305 F2).
3431///
3432/// A mirror declares a *list* of these, which is what lets one service mix
3433/// front doors — cloudflare for the public web tier, passway for an internal or
3434/// high-throughput one. Before this, [`MirrorConfig::ingress`] was a single
3435/// [`IngressProvider`], so a mirror could **swap** front doors but never mix
3436/// them.
3437///
3438/// ```toml
3439/// [[ingress]]
3440/// provider = "passway"
3441/// machines = ["us-east-001", "us-south-001"]
3442/// slots = ["bundle"]
3443///
3444/// [[ingress]]
3445/// provider = "cloudflare-tunnel"
3446/// hostnames = ["issues.yah.dev"]
3447/// ```
3448///
3449/// **The per-node appliance is derived from this, never declared beside it.**
3450/// An edge does invoke a cloudflared or passway process on a box, but that is a
3451/// *consequence* of the service's declaration:
3452/// [`collate_front_doors`](crate::reconciler::collate_front_doors) walks every
3453/// service and derives what each node must run. Declaring it node-side too is
3454/// what produces two sources of truth for one fact.
3455#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
3456#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3457pub struct IngressEdge {
3458 /// Which front door this edge is. [`IngressProvider::None`] is rejected at
3459 /// plan time — an edge that fronts with nothing is always a typo, never an
3460 /// intent (write no edge instead).
3461 pub provider: IngressProvider,
3462 /// Nodes this front door is placed on — **independent of where the fronted
3463 /// workload runs** (R330-F37).
3464 ///
3465 /// Empty falls back to the fronted slot's own `machine` / `machines`, which
3466 /// is the co-located shape every mirror had before front-door placement was
3467 /// expressible. Listing several is what lets the ingress tier and the
3468 /// service tier scale independently: **N front doors over ONE deployment**,
3469 /// one rendered copy, so no cache coherence to settle.
3470 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3471 pub machines: Vec<String>,
3472 /// Provider slot roles this edge fronts (`"bundle"`, `"compute"`, …).
3473 ///
3474 /// One of the two selectors. With a single edge both may be empty, meaning
3475 /// "every fronted slot" — the legacy shape. With **several** edges a
3476 /// selector is mandatory on each, and the partition must be total and
3477 /// disjoint: a slot claimed by no edge, or by two, is an error naming it.
3478 /// An implicit catch-all across mixed front doors would silently publish a
3479 /// service through the wrong one.
3480 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3481 pub slots: Vec<String>,
3482 /// Public hostnames this edge fronts — the other selector, for partitioning
3483 /// by what the world dials rather than by which slot serves it.
3484 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3485 pub hostnames: Vec<String>,
3486 /// Cloudflare Tunnel id this edge publishes through, overriding the
3487 /// fronting machine's [`MachineConfig::cloudflared`].
3488 ///
3489 /// This is W267 Gap 3's real fix, and it is the *service* side of it: a node
3490 /// can join two cohorts' orange networks, and since §Granularity argues the
3491 /// tunnel credential **is** the isolation boundary, which cohort a given
3492 /// service fronts through is a property of the service, not of the box.
3493 /// `MachineConfig.cloudflared` stays as the per-node default (one tunnel is
3494 /// the common case, and the credential does live on the node), but it is no
3495 /// longer the only way to say it — so the node never has to enumerate
3496 /// cohorts.
3497 #[serde(default, skip_serializing_if = "Option::is_none")]
3498 pub tunnel_id: Option<String>,
3499 /// Infra provider id whose credentials this edge's front door authenticates
3500 /// with — `use = "cloudflare"`, resolved through
3501 /// `.yah/infra/providers/<id>.toml` exactly as a slot's `use` is.
3502 ///
3503 /// Same split as [`tunnel_id`](Self::tunnel_id), one field over: whose
3504 /// Cloudflare account holds the tunnel is a property of the **front door**,
3505 /// not of the box that runs the compute. Without this the account was read
3506 /// off the fronted slot's own `use`, which conflates two unrelated facts —
3507 /// and is unwritable for a slot whose compute provider is `kind = "static"`
3508 /// (a borrowed bare box: placement only, no credentials). Such a mirror had
3509 /// no way to name a Cloudflare account at all, short of writing
3510 /// `use = "cloudflare"` on the compute slot and lying about what runs it
3511 /// (R845).
3512 ///
3513 /// `None` falls back to the fronted slot's `use`, which is what every
3514 /// mirror written before this field meant.
3515 #[serde(default, rename = "use", skip_serializing_if = "Option::is_none")]
3516 pub provider_id: Option<String>,
3517}
3518
3519impl IngressEdge {
3520 /// An edge with no selector — fronts every fronted slot, legal only when it
3521 /// is the mirror's only edge.
3522 pub fn all_slots(provider: IngressProvider, machines: Vec<String>) -> Self {
3523 Self {
3524 provider,
3525 machines,
3526 slots: Vec::new(),
3527 hostnames: Vec::new(),
3528 tunnel_id: None,
3529 provider_id: None,
3530 }
3531 }
3532
3533 /// `true` when this edge names which slots/hostnames it fronts.
3534 pub fn has_selector(&self) -> bool {
3535 !self.slots.is_empty() || !self.hostnames.is_empty()
3536 }
3537
3538 /// Does this edge claim the rule derived from `slot` publishing `hostname`?
3539 ///
3540 /// A selectorless edge claims everything; that is checked to be
3541 /// unambiguous (one edge only) before this is consulted.
3542 pub fn claims(&self, slot: &str, hostname: &str) -> bool {
3543 if !self.has_selector() {
3544 return true;
3545 }
3546 self.slots.iter().any(|s| s == slot) || self.hostnames.iter().any(|h| h == hostname)
3547 }
3548
3549 /// Human-readable identity for an error message — the provider plus
3550 /// whichever selector was written.
3551 pub fn label(&self) -> String {
3552 let sel = match (self.slots.is_empty(), self.hostnames.is_empty()) {
3553 (true, true) => "no selector".to_string(),
3554 (false, true) => format!("slots = {:?}", self.slots),
3555 (true, false) => format!("hostnames = {:?}", self.hostnames),
3556 (false, false) => format!("slots = {:?} + hostnames = {:?}", self.slots, self.hostnames),
3557 };
3558 format!("[[ingress]] provider = {:?} ({sel})", self.provider.as_str())
3559 }
3560}
3561
3562/// A mirror's `ingress` declaration, in either spelling.
3563///
3564/// The list is the general form; the bare provider is shorthand for the single
3565/// edge fronting everything, and is kept rather than migrated because it is the
3566/// honest spelling for the common case — one service, one front door. Both
3567/// normalize to the same `Vec<IngressEdge>` through
3568/// [`MirrorConfig::ingress_edges`], so nothing downstream branches on which was
3569/// written.
3570#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
3571#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3572#[serde(untagged)]
3573pub enum IngressDecl {
3574 /// `ingress = "passway"` — one edge fronting every fronted slot, placed by
3575 /// the sibling [`MirrorConfig::ingress_machines`].
3576 Provider(IngressProvider),
3577 /// `[[ingress]]` — one entry per declared edge.
3578 Edges(Vec<IngressEdge>),
3579}
3580
3581/// Hand-written because `#[serde(untagged)]` throws the real error away.
3582///
3583/// A derived untagged `Deserialize` tries each variant and, on failure, reports
3584/// only `data did not match any variant of untagged enum IngressDecl` — so a
3585/// misspelled `provider = "passwya"` says nothing about providers, nothing about
3586/// the legal values, and points at the `[[ingress]]` header rather than the
3587/// field. Dispatching on the input shape first means each arm's own error
3588/// survives: a bad string names the legal provider vocabulary, a bad edge table
3589/// names the offending field.
3590impl<'de> Deserialize<'de> for IngressDecl {
3591 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
3592 struct DeclVisitor;
3593
3594 impl<'de> serde::de::Visitor<'de> for DeclVisitor {
3595 type Value = IngressDecl;
3596
3597 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
3598 f.write_str(
3599 "a provider name (`ingress = \"passway\"`) or a list of edge tables \
3600 (`[[ingress]]`)",
3601 )
3602 }
3603
3604 fn visit_str<E: serde::de::Error>(self, v: &str) -> std::result::Result<Self::Value, E> {
3605 IngressProvider::deserialize(serde::de::value::StrDeserializer::new(v))
3606 .map(IngressDecl::Provider)
3607 }
3608
3609 fn visit_seq<A: serde::de::SeqAccess<'de>>(
3610 self,
3611 seq: A,
3612 ) -> std::result::Result<Self::Value, A::Error> {
3613 Vec::<IngressEdge>::deserialize(serde::de::value::SeqAccessDeserializer::new(seq))
3614 .map(IngressDecl::Edges)
3615 }
3616 }
3617
3618 d.deserialize_any(DeclVisitor)
3619 }
3620}
3621
3622/// No front door — the shape of every mirror that publishes to R2 behind a
3623/// Worker, or runs a mesh-only compute tier.
3624impl Default for IngressDecl {
3625 fn default() -> Self {
3626 Self::Provider(IngressProvider::None)
3627 }
3628}
3629
3630impl IngressDecl {
3631 /// `true` when this mirror declares no front door at all.
3632 pub fn is_absent(&self) -> bool {
3633 match self {
3634 Self::Provider(p) => !p.is_declared(),
3635 Self::Edges(e) => e.is_empty(),
3636 }
3637 }
3638}
3639
3640impl From<IngressProvider> for IngressDecl {
3641 fn from(p: IngressProvider) -> Self {
3642 Self::Provider(p)
3643 }
3644}
3645
3646/// A service mirror — the projection of a [`ServiceConfig`] onto concrete
3647/// infra. Lives at `.yah/services/<svc>/mirrors/<env>.toml`.
3648#[derive(Debug, Clone, Serialize, Deserialize)]
3649#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3650pub struct MirrorConfig {
3651 pub schema_version: u32,
3652 pub shape: MirrorShape,
3653 /// Public-ingress edges fronting this mirror (W267, W305 F2). Defaults to
3654 /// none.
3655 ///
3656 /// Two spellings, one meaning — see [`IngressDecl`]. `ingress = "passway"`
3657 /// is one edge fronting everything; `[[ingress]]` entries declare several,
3658 /// each naming its provider plus the slots or hostnames it fronts. Read it
3659 /// through [`ingress_edges`](Self::ingress_edges), never by matching on the
3660 /// enum, so the two spellings cannot drift apart.
3661 ///
3662 /// Declared at mirror scope rather than per provider slot because a front
3663 /// door does **fan-in**: one `cloudflared` (or one passway) on a node
3664 /// multiplexes every hostname→port rule it fronts, so pinning one to a
3665 /// single slot would mint one edge connection per slot for no gain. An
3666 /// edge's `slots` selector is the general form of that — it groups slots
3667 /// behind one front door, it does not split a front door per slot.
3668 #[serde(default, skip_serializing_if = "IngressDecl::is_absent")]
3669 pub ingress: IngressDecl,
3670 /// Machines the front door is placed on — **independent of where the
3671 /// fronted workload runs** (R330-F37).
3672 ///
3673 /// The single-edge spelling of [`IngressEdge::machines`]: it applies to the
3674 /// one edge `ingress = "<provider>"` declares, and combining it with
3675 /// `[[ingress]]` entries is an error rather than a silent precedence rule.
3676 ///
3677 /// Empty (the default) keeps the pre-existing behaviour: the front door is
3678 /// co-located with the fronted slot's own `machine` / `machines`. That was
3679 /// never a design choice, it was an artifact of bundles binding
3680 /// `127.0.0.1` — nothing off-node could reach a workload, so a proxy had to
3681 /// sit on top of it. R599-F12 landed mesh binding, which removes the
3682 /// constraint: passway is a reverse proxy, and a valid front door needs a
3683 /// cert and an upstream it can *reach*, not a local copy of the service.
3684 ///
3685 /// Listing several machines is what lets the ingress tier and the service
3686 /// tier scale independently — **N front doors over ONE deployment**. There
3687 /// is still exactly one rendered copy of the site, so fanning the front door
3688 /// out introduces no cache-coherence problem; that only appears if you
3689 /// deploy the *workload* to every node instead.
3690 ///
3691 /// ```toml
3692 /// ingress = "passway"
3693 /// ingress_machines = ["us-east-001", "us-west-001"]
3694 /// ```
3695 ///
3696 /// Declaring this without [`ingress`](Self::ingress) is an error, not a
3697 /// no-op — it always means the operator expected a front door somewhere.
3698 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3699 pub ingress_machines: Vec<String>,
3700 /// Provider slots, keyed by role (`"static"`, `"compute"`, …). Each value
3701 /// either references a provider declared under `.yah/infra/providers/` or
3702 /// inlines a local-only provider (no creds, no infra file).
3703 ///
3704 /// A role is normally service-wide — one slot serves every component that
3705 /// shares it — but [`ReconcileCtx::slot`](crate::reconciler::ReconcileCtx::slot)
3706 /// looks up the component-qualified key `"<role>:<component id>"` first.
3707 /// A service with two components of the same role (e.g. two
3708 /// `mesofact-static` components under one mirror) declares
3709 /// `providers."static:<id>"` per component to give each its own port;
3710 /// omitting the qualifier keeps the pre-existing single-slot behavior.
3711 #[serde(default)]
3712 pub providers: BTreeMap<String, MirrorProviderSlot>,
3713 /// Capability→driver bindings, keyed by **capability** (`"pg"`, `"s3"`, …)
3714 /// rather than by slot role (W265 §Drivers).
3715 ///
3716 /// This is the generalization of [`Self::providers`]: `providers.static` /
3717 /// `providers.object_store` are the special case where the slot name and
3718 /// the capability happen to coincide, and keying by capability is what stops
3719 /// the slot enum growing one arm per tier-specific implementation. A service
3720 /// says "I need pg"; the mirror says which implementation of pg *this tier*
3721 /// uses; the app talks the same wire protocol either way and never forks.
3722 ///
3723 /// ```toml
3724 /// [drivers.pg]
3725 /// kind = "local-pg-dev" # dev — kamaji-supervised loopback postgres
3726 /// ```
3727 ///
3728 /// Additive in P1: `drivers` lands *alongside* `providers`, and migrating
3729 /// the existing `providers.static` / `providers.object_store` declarations
3730 /// over is a separate pass (W265 §"Open follow-ups"). A mirror that declares
3731 /// neither is unchanged.
3732 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3733 pub drivers: BTreeMap<String, MirrorProviderSlot>,
3734 /// Per-environment alias overrides for `kind = "static-asset"` components.
3735 ///
3736 /// Keys are logical names (e.g. `"whisper-default"`); values must be
3737 /// filenames present in the component's `workload.toml` catalog.
3738 /// **Resolution only** — this table may never introduce a filename absent
3739 /// from the catalog. Validated against the workload catalog at sync time.
3740 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3741 pub asset_aliases: BTreeMap<String, String>,
3742}
3743
3744impl MirrorConfig {
3745 /// This mirror's declared edges, with both spellings normalized (W305 F2).
3746 ///
3747 /// The single place `ingress` + `ingress_machines` are reconciled, so no
3748 /// consumer has to know which spelling was written. Returns an empty vec
3749 /// when the mirror declares no front door.
3750 ///
3751 /// Errors are the declarations that cannot mean anything:
3752 ///
3753 /// - `ingress_machines` with no `ingress` — front-door placement with no
3754 /// front door to place, always a typo (R330-F37);
3755 /// - `ingress_machines` alongside `[[ingress]]` — placement declared twice,
3756 /// in a form where one silently wins;
3757 /// - `provider = "none"` on an edge — an edge that fronts with nothing.
3758 /// The `[[ingress]]` entries exactly as written, without normalizing the
3759 /// scalar spelling or validating anything.
3760 ///
3761 /// [`ingress_edges`](Self::ingress_edges) is the one to reach for; this
3762 /// exists for the checks that must run *before* a mirror is known to be
3763 /// well-formed — cross-reference validation walks every mirror in the
3764 /// workspace, and hard-failing there on an unrelated mirror's shape error
3765 /// would report the wrong file. Empty for the scalar spelling, which has no
3766 /// edge table to carry per-edge fields.
3767 pub fn ingress_edge_slice(&self) -> &[IngressEdge] {
3768 match &self.ingress {
3769 IngressDecl::Edges(edges) => edges,
3770 IngressDecl::Provider(_) => &[],
3771 }
3772 }
3773
3774 pub fn ingress_edges(&self) -> Result<Vec<IngressEdge>> {
3775 match &self.ingress {
3776 IngressDecl::Provider(p) if !p.is_declared() => {
3777 if !self.ingress_machines.is_empty() {
3778 bail!(
3779 "mirror declares `ingress_machines = {:?}` but no `ingress` provider — \
3780 front-door placement with no front door to place. Add \
3781 `ingress = \"passway\"` (or \"cloudflare-tunnel\"), or drop \
3782 `ingress_machines`.",
3783 self.ingress_machines
3784 );
3785 }
3786 Ok(Vec::new())
3787 }
3788 IngressDecl::Provider(p) => Ok(vec![IngressEdge::all_slots(
3789 *p,
3790 self.ingress_machines.clone(),
3791 )]),
3792 IngressDecl::Edges(edges) => {
3793 if !self.ingress_machines.is_empty() {
3794 bail!(
3795 "mirror declares both `[[ingress]]` edges and the single-edge \
3796 `ingress_machines = {:?}` — front-door placement stated twice. Move \
3797 those names onto the edge they place: `machines = [...]` inside the \
3798 `[[ingress]]` entry.",
3799 self.ingress_machines
3800 );
3801 }
3802 for edge in edges {
3803 if !edge.provider.is_declared() {
3804 bail!(
3805 "{}: `provider = \"none\"` fronts nothing. An edge exists to name a \
3806 front door — delete the entry instead.",
3807 edge.label()
3808 );
3809 }
3810 }
3811 Ok(edges.clone())
3812 }
3813 }
3814 }
3815
3816 /// Nodes this mirror's **passway** front doors are placed on, in
3817 /// declaration order and de-duplicated — or `None` when the mirror declares
3818 /// no passway edge at all.
3819 ///
3820 /// `Some(vec![])` is a real and different answer from `None`: a passway edge
3821 /// is declared but names no machine, so its placement falls back to the
3822 /// fronted slot's own. That fallback is placement *resolution* — it belongs
3823 /// to [`IngressRule::machines`](crate::reconciler::IngressRule::machines)
3824 /// and the plan it is built from, not to a mirror read in isolation — so it
3825 /// is reported as "declared, placement unknown from here" rather than
3826 /// half-derived. A caller that needs a node to dial has to say so.
3827 ///
3828 /// Passway-only because the caller is tenant DNS onboarding: only a passway
3829 /// node serves yubaba's `GET /domains/{domain}/onboarding`. A
3830 /// cloudflare-tunnel edge publishes through Cloudflare's own DNS and has no
3831 /// such record to hand a tenant, so folding its machines in would point the
3832 /// UI at a node that cannot answer.
3833 ///
3834 /// Read through [`ingress_edges`](Self::ingress_edges), so both spellings
3835 /// are covered by construction. A declaration that cannot mean anything
3836 /// (`ingress_machines` with no `ingress`, or both spellings at once) reads
3837 /// as `None` rather than propagating an error: those are reported by
3838 /// cross-reference validation, which can name the offending file.
3839 pub fn passway_machines(&self) -> Option<Vec<String>> {
3840 let edges = self.ingress_edges().ok()?;
3841 let mut declared = false;
3842 let mut machines: Vec<String> = Vec::new();
3843 for edge in edges
3844 .iter()
3845 .filter(|e| matches!(e.provider, IngressProvider::Passway))
3846 {
3847 declared = true;
3848 for m in &edge.machines {
3849 if !machines.iter().any(|seen| seen == m) {
3850 machines.push(m.clone());
3851 }
3852 }
3853 }
3854 declared.then_some(machines)
3855 }
3856
3857 /// Parse a single `mirrors/<env>.toml` file.
3858 pub fn load(path: &Path) -> Result<Self> {
3859 let src =
3860 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
3861 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
3862 }
3863
3864 /// Persist to `.yah/services/<service>/mirrors/<env>.toml`, creating the
3865 /// `mirrors/` directory if needed. Create-or-overwrite. The mirror file is
3866 /// named by `env` (its stem); `service` selects the owning service dir.
3867 pub fn save(&self, workspace_root: &Path, service: &str, env: &str) -> Result<()> {
3868 let dir = crate::paths::service_mirrors_dir(workspace_root, service);
3869 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
3870 let path = crate::paths::service_mirror_toml(workspace_root, service, env);
3871 let s = toml::to_string_pretty(self)
3872 .with_context(|| format!("serializing mirror {service}/{env}"))?;
3873 std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
3874 }
3875
3876 /// Remove `.yah/services/<service>/mirrors/<env>.toml`. Returns `false`
3877 /// when the file was already absent. Leaves the service and its other
3878 /// mirrors untouched.
3879 ///
3880 /// Also checks legacy stems (e.g. `local-sim` when `env = "pond"`) so
3881 /// deleting a canonical tier name removes whichever file exists on disk.
3882 pub fn delete(workspace_root: &Path, service: &str, env: &str) -> Result<bool> {
3883 let path = crate::paths::service_mirror_toml(workspace_root, service, env);
3884 if path.exists() {
3885 std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
3886 return Ok(true);
3887 }
3888 // Try legacy file stems for canonical tier names.
3889 let legacy: &[&str] = match env {
3890 "dev" => &["local"],
3891 "pond" => &["local-sim", "sim"],
3892 "cloud" => &["prod"],
3893 _ => &[],
3894 };
3895 for stem in legacy {
3896 let alt = crate::paths::service_mirror_toml(workspace_root, service, stem);
3897 if alt.exists() {
3898 std::fs::remove_file(&alt)
3899 .with_context(|| format!("removing {}", alt.display()))?;
3900 return Ok(true);
3901 }
3902 }
3903 Ok(false)
3904 }
3905}
3906
3907/// A provider slot inside a [`MirrorConfig`]. Two shapes:
3908/// - **Reference** (`use = "<provider-id>"`) — point at an infra-declared
3909/// provider; extra fields are slot-specific (bucket, zone, dns, …).
3910/// - **Inline** (`kind = "local-*"`) — for providers that need no infra
3911/// declaration because they carry no credentials.
3912#[derive(Debug, Clone, Serialize, Deserialize)]
3913#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
3914#[serde(untagged)]
3915pub enum MirrorProviderSlot {
3916 Reference {
3917 #[serde(rename = "use")]
3918 provider_id: String,
3919 #[serde(flatten)]
3920 #[cfg_attr(
3921 feature = "json-schema",
3922 schemars(with = "std::collections::BTreeMap<String, serde_json::Value>")
3923 )]
3924 fields: BTreeMap<String, toml::Value>,
3925 },
3926 Inline {
3927 kind: Provider,
3928 #[serde(flatten)]
3929 #[cfg_attr(
3930 feature = "json-schema",
3931 schemars(with = "std::collections::BTreeMap<String, serde_json::Value>")
3932 )]
3933 fields: BTreeMap<String, toml::Value>,
3934 },
3935}
3936
3937impl MirrorProviderSlot {
3938 /// Provider id this slot references, or `None` for inline slots.
3939 pub fn provider_id(&self) -> Option<&str> {
3940 match self {
3941 Self::Reference { provider_id, .. } => Some(provider_id),
3942 Self::Inline { .. } => None,
3943 }
3944 }
3945
3946 /// Provider kind for inline slots, or `None` for reference slots
3947 /// (resolve via the referenced [`ProviderConfig`]).
3948 pub fn inline_kind(&self) -> Option<Provider> {
3949 match self {
3950 Self::Reference { .. } => None,
3951 Self::Inline { kind, .. } => Some(*kind),
3952 }
3953 }
3954
3955 pub fn fields(&self) -> &BTreeMap<String, toml::Value> {
3956 match self {
3957 Self::Reference { fields, .. } | Self::Inline { fields, .. } => fields,
3958 }
3959 }
3960
3961 /// F16 placement: parse the optional `required = { … }` sub-table on this
3962 /// slot. Returns `None` when absent or unparseable (callers treat as no
3963 /// constraint). See [`RequiredSpec`] for the field grammar.
3964 pub fn required(&self) -> Option<RequiredSpec> {
3965 let v = self.fields().get("required")?.clone();
3966 v.try_into().ok()
3967 }
3968}
3969
3970/// F16 placement constraints declared on a [`MirrorProviderSlot`], lives under
3971/// `[providers.<role>] required = { regions = [...], mesh_tags = [...] }` in
3972/// `mirrors/<env>.toml`.
3973///
3974/// Hard (must-satisfy) axes, all AND-ed together:
3975/// - `regions` / `zones` / `providers` — *membership*: the machine's
3976/// `region` / `zone` / `provider` must be one of the listed values.
3977/// - `mesh_tags` — *superset*: the machine's `mesh_tags` must contain every
3978/// listed tag.
3979/// - `memory_mb` / `cpu_millis` — *capacity floor* (R572-F5): the machine's
3980/// `allocatable` budget must cover the demand. `0` = no constraint.
3981/// - `repel_archetypes` — *taint repulsion* (R572-F5, widened to a set by
3982/// R860-T4): the machine must not carry `"no-<archetype.taint_key()>"` for
3983/// **any** archetype in the placement group. Empty = no repulsion check.
3984/// Absolute — see [`Self::repel_archetypes`].
3985/// - `requires_taint` — *taint affinity* (R572-F5): the machine must carry
3986/// this taint key (in `taints` or `mesh_tags`). `None` = no affinity.
3987///
3988/// These two are the **only** readers of [`MachineConfig::taints`], which is
3989/// what makes [`taint_effect`]'s closed vocabulary well-founded.
3990///
3991/// [`MachineConfig::sovereign_group`] is deliberately **not** an axis here and
3992/// must not become one (W305/R742-F1). A sovereign group is a blast radius,
3993/// not a filter: which quorum a box votes in says nothing about whether a
3994/// workload may run on it, and a dev-group node exists precisely so dev-mode
3995/// services — stateful ones included — can be scheduled onto it. Filtering on
3996/// it would re-make the mistake W305 exists to undo, where one mechanism
3997/// silently carried three unrelated properties.
3998///
3999/// An empty / zero / None on every axis means "no constraint on that axis".
4000/// A fully-unconstrained `RequiredSpec` matches every machine (see
4001/// [`RequiredSpec::is_unconstrained`]).
4002#[derive(Debug, Clone, Default, Serialize, Deserialize)]
4003#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4004pub struct RequiredSpec {
4005 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4006 pub regions: Vec<String>,
4007 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4008 pub zones: Vec<String>,
4009 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4010 pub providers: Vec<String>,
4011 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4012 pub mesh_tags: Vec<String>,
4013
4014 /// R833-F8: **imperative** placement — the machine must be one of these by
4015 /// `name`. Empty (the default) = no constraint, which is every pre-R833-F8
4016 /// caller.
4017 ///
4018 /// This is the one axis that is not a *capability* the scheduler infers.
4019 /// The operator typed `--where=node:us-west-003`, so it composes with the
4020 /// other axes exactly like the rest — a named node that fails the capacity
4021 /// floor or carries a repelling taint still does not match, and the refusal
4022 /// names why rather than silently placing the work somewhere else.
4023 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4024 pub nodes: Vec<String>,
4025
4026 /// R572-F5: minimum memory (MiB) the target node must have in its
4027 /// declared `allocatable` budget. `0` = no constraint. Filled by
4028 /// [`CloudConfig::admit_workload`] from the workload's
4029 /// `memory_request_mb()` — its placement **request**, which is not the
4030 /// same number as the `resources.memory_mb` cgroup **ceiling**.
4031 #[serde(default, skip_serializing_if = "is_zero_u32")]
4032 pub memory_mb: u32,
4033 /// R572-F5: minimum CPU (millicores) the target node must have in its
4034 /// declared `allocatable` budget. `0` = no constraint. Filled by
4035 /// [`CloudConfig::admit_workload`] from the workload's `resources.cpu_millis`.
4036 #[serde(default, skip_serializing_if = "is_zero_u32")]
4037 pub cpu_millis: u32,
4038 /// R572-F5: effective archetypes of the workloads being placed. The
4039 /// scheduler rejects any node carrying `"no-<archetype.taint_key()>"` for
4040 /// any of them. Empty = no repulsion check (backwards-compat for callers
4041 /// that don't thread a spec through).
4042 ///
4043 /// **A set, not a single value (R860-T4 / W338 §Placement consequences
4044 /// 2).** [`admission_spec`] places the requirer's whole *placement group* —
4045 /// the transitive closure of `local` requirement edges — so the axis is the
4046 /// union of the members' archetypes. A `Server` that requires an
4047 /// `Appliance` `local` must be repelled by `no-appliance`, because the two
4048 /// land on one node or neither does; collapsing that back to one archetype
4049 /// would place the group on a node that rejects half of it.
4050 ///
4051 /// **This is an absolute block, not a preference.**
4052 /// [`CloudConfig::admit_workload`] sets it unconditionally from the
4053 /// group's effective archetypes, and nothing in the tree tolerates a
4054 /// taint — so a workload cannot opt out of a `no-<archetype>` node
4055 /// (W305 finding 2 / R742-T4). Adding toleration means giving
4056 /// `WorkloadSpec` a tolerations list and consulting it here; until then,
4057 /// do not describe this as "repel-unless-tolerate".
4058 #[serde(skip)]
4059 pub repel_archetypes: Vec<LifecycleArchetype>,
4060 /// R572-F5: taint the workload requires the target node to carry
4061 /// (annotation `yah.placement.requires-taint`). The node must have the
4062 /// key in its `taints` list or `mesh_tags`. `None` = no affinity constraint.
4063 #[serde(skip)]
4064 pub requires_taint: Option<String>,
4065
4066 /// R844-F8: **how many** machines this constraint places onto. `None` — the
4067 /// only shape on disk before this field — means one, so every mirror in the
4068 /// tree resolves byte-identically across the change.
4069 ///
4070 /// This is not a match axis: it never appears in [`Self::matches`] and never
4071 /// changes whether a given machine qualifies. It is the *cardinality* of the
4072 /// answer, which is why it lives here rather than as another filter — the
4073 /// operator declares what is required and how many of it, and the scheduler
4074 /// picks which.
4075 ///
4076 /// **Declared, never inferred.** The count is emphatically not "how many
4077 /// machines happen to match": deriving it that way would make adding a box
4078 /// to the fleet silently scale a production front door. A constraint that
4079 /// matches four machines and asks for two places on two.
4080 ///
4081 /// **Fewer matches than asked is an error** ([`select_matching`]), not a
4082 /// partial placement. Placing one of two and reporting success is the
4083 /// subset-that-looks-like-it-worked failure R844 exists to close.
4084 ///
4085 /// Deliberately absent from [`Self::is_unconstrained`], which answers "does
4086 /// every machine match" — a question about the predicate, not the count. A
4087 /// `required = { replicas = 2 }` with no axis is therefore still
4088 /// unconstrained, and the deploy side still refuses it as an
4089 /// underspecified placement.
4090 #[serde(default, skip_serializing_if = "Option::is_none")]
4091 pub replicas: Option<u32>,
4092}
4093
4094impl RequiredSpec {
4095 /// How many machines this constraint places onto — [`Self::replicas`],
4096 /// resolving the absent case to the pre-R844-F8 answer of one.
4097 ///
4098 /// The single place that default is spelled, so the ingress planner and the
4099 /// deploy resolver cannot disagree about what "no replica count" means.
4100 pub fn replica_count(&self) -> usize {
4101 self.replicas.unwrap_or(1) as usize
4102 }
4103
4104 /// True when no axis carries a constraint — every machine matches.
4105 pub fn is_unconstrained(&self) -> bool {
4106 self.regions.is_empty()
4107 && self.zones.is_empty()
4108 && self.providers.is_empty()
4109 && self.mesh_tags.is_empty()
4110 && self.nodes.is_empty()
4111 && self.memory_mb == 0
4112 && self.cpu_millis == 0
4113 && self.repel_archetypes.is_empty()
4114 && self.requires_taint.is_none()
4115 }
4116
4117 /// Whether `machine` satisfies every hard axis.
4118 ///
4119 /// - Membership axes (region/zone/provider): machine must carry the field
4120 /// and it must appear in the constraint list.
4121 /// - `mesh_tags`: machine tags must be a superset of the required set.
4122 /// - **R572-F5 capacity floor**: `machine.allocatable.{memory,cpu}` must
4123 /// cover `self.{memory,cpu}`. A machine with no `allocatable` block passes
4124 /// unconditionally (capacity unknown → no constraint enforced).
4125 /// - **R572-F5 taint repulsion**: machine must not carry the taint
4126 /// `"no-<archetype.taint_key()>"` for *any* archetype in
4127 /// [`Self::repel_archetypes`] — every class in the placement group, not
4128 /// just the requirer's (R860-T4). Absolute — the workload has no way to
4129 /// tolerate it (W305 finding 2).
4130 /// - **R572-F5 taint affinity**: if `requires_taint` is set, the machine
4131 /// must carry that key in its `taints` list or `mesh_tags`.
4132 ///
4133 /// Any *other* taint on the machine is ignored here, which is precisely
4134 /// why [`crate::validate::check_inert_taints`] refuses to let one be
4135 /// declared: it would read as a constraint and be none.
4136 pub fn matches(&self, machine: &MachineConfig) -> bool {
4137 let member_ok = |constraint: &[String], value: Option<&str>| -> bool {
4138 constraint.is_empty() || value.map_or(false, |v| constraint.iter().any(|c| c == v))
4139 };
4140
4141 // R833-F8: imperative node pin, checked first because it is the axis a
4142 // human asserted rather than one the scheduler derived — a refusal
4143 // should read "us-west-003 does not match" and not lead with a tag set
4144 // the operator never typed.
4145 if !member_ok(&self.nodes, Some(machine.name.as_str())) {
4146 return false;
4147 }
4148
4149 // Membership + mesh-tags (pre-existing axes).
4150 if !member_ok(&self.regions, machine.region.as_deref())
4151 || !member_ok(&self.zones, machine.zone.as_deref())
4152 || !member_ok(&self.providers, Some(machine.provider.as_str()))
4153 || !self
4154 .mesh_tags
4155 .iter()
4156 .all(|t| machine.mesh_tags.iter().any(|mt| mt == t))
4157 {
4158 return false;
4159 }
4160
4161 // R572-F5: capacity floor. Skipped when machine has no allocatable
4162 // declaration (unknown capacity → passes, consistent with pre-F5 behaviour).
4163 if self.memory_mb > 0 || self.cpu_millis > 0 {
4164 if let Some(alloc) = &machine.allocatable {
4165 if self.memory_mb > alloc.memory_mb || self.cpu_millis > alloc.cpu_millis {
4166 return false;
4167 }
4168 }
4169 }
4170
4171 // R572-F5: taint repulsion. A node taint "no-<archetype>" rejects the
4172 // workload class outright — there is no toleration list to consult.
4173 for arch in &self.repel_archetypes {
4174 let repel_key = format!("no-{}", arch.taint_key());
4175 if machine.taints.iter().any(|t| *t == repel_key) {
4176 return false;
4177 }
4178 }
4179
4180 // R572-F5: taint affinity. Machine must carry the required taint key
4181 // in either its `taints` list or `mesh_tags`.
4182 if let Some(req) = &self.requires_taint {
4183 let has_it = machine.taints.iter().any(|t| t == req)
4184 || machine.mesh_tags.iter().any(|t| t == req);
4185 if !has_it {
4186 return false;
4187 }
4188 }
4189
4190 true
4191 }
4192
4193 /// Human-readable summary of the constraints, for fail-loud error messages.
4194 /// Example: `required.regions=[us-west] + required.mesh_tags=[tag:cloud-runner]`.
4195 pub fn describe(&self) -> String {
4196 let mut parts = Vec::new();
4197 let mut push = |label: &str, vals: &[String]| {
4198 if !vals.is_empty() {
4199 parts.push(format!("required.{label}=[{}]", vals.join(",")));
4200 }
4201 };
4202 push("nodes", &self.nodes);
4203 push("regions", &self.regions);
4204 push("zones", &self.zones);
4205 push("providers", &self.providers);
4206 push("mesh_tags", &self.mesh_tags);
4207 if self.memory_mb > 0 {
4208 parts.push(format!("memory_mb>={}", self.memory_mb));
4209 }
4210 if self.cpu_millis > 0 {
4211 parts.push(format!("cpu_millis>={}", self.cpu_millis));
4212 }
4213 for arch in &self.repel_archetypes {
4214 parts.push(format!("not-tainted(no-{})", arch.taint_key()));
4215 }
4216 if let Some(req) = &self.requires_taint {
4217 parts.push(format!("requires_taint={req}"));
4218 }
4219 if parts.is_empty() {
4220 "no constraints".to_string()
4221 } else {
4222 parts.join(" + ")
4223 }
4224 }
4225}
4226
4227/// Which front door actually serves a domain's requests (R594-F12).
4228///
4229/// Every domain manifest must say this out loud. Before it existed the
4230/// difference between "R2 serves this hostname directly" and "a Worker
4231/// serves it" was expressed *only* by whether the file happened to carry
4232/// `[[routes]]` — so binding a route-carrying domain straight to R2 was
4233/// accepted silently and served 200s on its SSG half while losing clean
4234/// URLs, SPA shell fallback, deferred-route pointers and branded error
4235/// pages. All of those live in the Worker
4236/// (`oss/mesofact/packages/mesofact-edge/src/router.ts`) or in
4237/// mesofact-serve; an R2 custom domain has none of them.
4238///
4239/// The vocabulary mirrors `scripts/cf-apex-mode.sh` (worker | grey | orange)
4240/// — this moves the choice into the config where it can be checked instead
4241/// of living in one bash script.
4242///
4243/// A front door does **fan-in** only. The render cube (SSG / SPA / SSR /
4244/// deferred / 404) is mesofact's manifest, not this one — see W173 and
4245/// `.yah/docs/working/W267-sovereign-public-ingress.md`
4246/// §"Two front doors, one render contract".
4247#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4248#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4249#[serde(rename_all = "kebab-case")]
4250pub enum FrontDoor {
4251 /// Cloudflare R2 custom domain. Requests hit R2 objects with edge
4252 /// caching and nothing else — no clean URLs, no SPA fallback, no
4253 /// branded errors. Correct for a pure asset tier (W175's verdict for
4254 /// `cdn.yah.dev`) and wrong for anything that renders pages.
4255 /// Implies zero `[[routes]]` and no `worker_bundle_path`.
4256 BucketDirect,
4257 /// Cloudflare Worker generated from this manifest's route table.
4258 Worker,
4259 /// Sovereign L7 ingress — the `passway` proxy on yah-owned metal
4260 /// (`oss/passway`, W267). Same route table as `worker`; different
4261 /// machine terminates TLS.
4262 Passway,
4263}
4264
4265impl FrontDoor {
4266 /// Whether this front door consumes the manifest's `[[routes]]` table.
4267 /// `bucket-direct` does not; the other two are nothing without it.
4268 pub fn is_route_driven(self) -> bool {
4269 matches!(self, FrontDoor::Worker | FrontDoor::Passway)
4270 }
4271
4272 /// The manifest spelling, for error messages.
4273 pub fn as_str(self) -> &'static str {
4274 match self {
4275 FrontDoor::BucketDirect => "bucket-direct",
4276 FrontDoor::Worker => "worker",
4277 FrontDoor::Passway => "passway",
4278 }
4279 }
4280}
4281
4282/// A routing manifest for one domain, from `.yah/domains/<name>.toml`.
4283///
4284/// The domain manifest is the *only* place that knows about path routing:
4285/// services declare static/backend components by opaque ID, and this
4286/// manifest binds those components to URL paths on a public-facing
4287/// domain. Generated Worker bundles consume this. See
4288/// `.yah/docs/working/W118-yah-domain-tiers.md` (R347).
4289#[derive(Debug, Clone, Serialize, Deserialize)]
4290#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4291pub struct DomainConfig {
4292 pub schema_version: u32,
4293 /// Stable identifier for this domain (file stem of the manifest).
4294 /// Example: `"yah-dev"` for the `yah.dev` zone.
4295 pub name: String,
4296 /// The fully-qualified domain this manifest routes for. Example:
4297 /// `"yah.dev"`, `"app.yah.dev"`.
4298 pub domain: String,
4299 /// Which front door serves this domain (R594-F12). **Required** — a
4300 /// default here would silently re-create the defect the field exists to
4301 /// close. Cross-checked against `routes` / `worker_bundle_path` by
4302 /// [`DomainConfig::validate_front_door`] at load time.
4303 pub front_door: FrontDoor,
4304 /// Public CDN bucket name. Static-mode route components publish into
4305 /// this bucket. Owned by the domain, *not* by any single service.
4306 pub cdn_bucket: String,
4307 /// Optional path (relative to workspace root) where the generated
4308 /// Worker bundle lands. `None` while the bundle generator (R347-F4)
4309 /// is still being wired up.
4310 #[serde(default, skip_serializing_if = "Option::is_none")]
4311 pub worker_bundle_path: Option<String>,
4312 #[serde(default, skip_serializing_if = "Vec::is_empty")]
4313 pub routes: Vec<DomainRoute>,
4314}
4315
4316/// One entry in a [`DomainConfig`]'s route table.
4317///
4318/// The `mode` discriminator picks the variant's body via serde's
4319/// internally-tagged enum representation. Path patterns follow the
4320/// Worker convention: a trailing `*` matches everything underneath.
4321#[derive(Debug, Clone, Serialize, Deserialize)]
4322#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4323pub struct DomainRoute {
4324 /// URL pattern this route matches. Examples: `"/"`, `"/dashboard/*"`,
4325 /// `"/camp/ws"`.
4326 pub path: String,
4327 /// Response headers the front door sets on every response served under
4328 /// this route (R746). Empty by default.
4329 ///
4330 /// This is the manifest's answer to "who decides a path's response
4331 /// headers". Before it existed the answer was *nobody*: a `_headers` file
4332 /// is a Cloudflare Pages / Netlify convention, and neither of this
4333 /// repo's front doors reads one — a Worker returns what it fetched from
4334 /// R2, and R2 serves only the object's own httpMetadata. So a site could
4335 /// carry a `_headers` file declaring COOP/COEP and ship without them,
4336 /// which is exactly how it was found: `SharedArrayBuffer` is simply
4337 /// absent in a document served cross-origin-isolation-free, with no
4338 /// error anywhere to say why.
4339 ///
4340 /// Deliberately a free-form `name -> value` map rather than named fields
4341 /// for the isolation headers: the domain manifest has no business
4342 /// knowing which headers a route's payload happens to need. Ordering
4343 /// follows the route table's own rule — first matching route wins, no
4344 /// merging across routes (see the Worker's `applyRouteHeaders`).
4345 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
4346 pub headers: BTreeMap<String, String>,
4347 #[serde(flatten)]
4348 pub mode: RouteMode,
4349}
4350
4351/// Body of a [`DomainRoute`]. Three modes:
4352/// - **Static** — Worker reads from the domain's CDN bucket. Component
4353/// ref points at a `kind = "mesofact-static"` (or similar) service
4354/// component.
4355/// - **Backend** — Worker proxies to an HTTP origin owned by a backend
4356/// component (yubaba workload, gateway, etc.).
4357/// - **Redirect** — Worker emits a 30x to the target URL. Used to keep
4358/// old paths alive during domain refactors.
4359#[derive(Debug, Clone, Serialize, Deserialize)]
4360#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4361#[serde(tag = "mode", rename_all = "kebab-case")]
4362pub enum RouteMode {
4363 Static {
4364 /// Component reference `"<service>/<component-id>"`. Validated
4365 /// at [`CloudConfig::load`] time.
4366 component: String,
4367 },
4368 Backend {
4369 /// Component reference `"<service>/<component-id>"`. Validated
4370 /// at [`CloudConfig::load`] time.
4371 component: String,
4372 /// Origin URL the Worker `fetch()`es. Schema-permissive — could
4373 /// be `https://...`, `wss://...`, or a yah-internal mesh URL
4374 /// resolved by yubaba.
4375 origin: String,
4376 },
4377 Redirect {
4378 /// Absolute URL or path the Worker emits a 30x to.
4379 target: String,
4380 /// HTTP status code. Defaults to 308 (permanent + method-preserving)
4381 /// so deprecations don't silently turn POSTs into GETs.
4382 #[serde(default = "default_redirect_status")]
4383 status: u16,
4384 },
4385}
4386
4387fn default_redirect_status() -> u16 {
4388 308
4389}
4390
4391/// Normalize a component `mount` to a storage/URL key prefix: strip the
4392/// surrounding slashes. `"/app"`, `"app/"`, `"/app/"` → `"app"`; `"/"`, `""`
4393/// → `""` (the service root).
4394///
4395/// One producer on purpose — the publisher's key prefix, the route-path
4396/// cross-check and the front door's key lookup must all agree on what `/app`
4397/// means down to the byte, and three copies of `trim_matches('/')` is how they
4398/// stop agreeing.
4399pub fn normalize_mount(raw: &str) -> String {
4400 raw.trim_matches('/').to_string()
4401}
4402
4403/// The key prefix a domain route pattern serves under: `"/*"` → `""`,
4404/// `"/app/*"` and `"/app"` → `"app"`. The twin of [`normalize_mount`] on the
4405/// routing side.
4406pub fn route_path_prefix(path: &str) -> String {
4407 normalize_mount(path.strip_suffix('*').unwrap_or(path))
4408}
4409
4410/// The route-driven domain whose route table binds a component of `service`,
4411/// if any. Used by static publishers to pick up the per-route response
4412/// headers a service's paths were declared with.
4413///
4414/// Deterministic by `BTreeMap` key order when more than one domain routes the
4415/// same service (a legitimate shape: an apex and a staging host serving one
4416/// bundle). Returning the first is a real limitation, not a considered
4417/// choice — the day two such domains want *different* headers for one
4418/// component, this needs the domain identity threaded in rather than inferred.
4419pub fn domain_serving_service<'a>(
4420 domains: &'a BTreeMap<String, DomainConfig>,
4421 service: &str,
4422) -> Option<&'a DomainConfig> {
4423 domains
4424 .values()
4425 .find(|d| d.front_door.is_route_driven() && d.serves_service(service))
4426}
4427
4428/// The `ROUTE_HEADERS` Worker-binding value for `service`, read from the
4429/// workspace's domain manifests. `"[]"` when no route-driven domain routes the
4430/// service, or when the one that does declares no headers.
4431///
4432/// Reads `.yah/domains/` directly rather than taking a loaded [`CloudConfig`]:
4433/// the static reconcilers are handed a per-component [`ReconcileCtx`], not the
4434/// whole workspace config, and threading a config reference through all 22 of
4435/// its construction sites to reach one string would be a wide change for a
4436/// narrow read. Manifest parse errors propagate — a domain file that no longer
4437/// loads is a deploy-stopping fact, not a reason to ship a Worker with the
4438/// headers quietly missing.
4439pub fn route_headers_for_service(workspace_root: &Path, service: &str) -> Result<String> {
4440 let domains = load_domains(&crate::paths::domains_dir(workspace_root))?;
4441 Ok(domain_serving_service(&domains, service)
4442 .map(DomainConfig::route_headers_json)
4443 .unwrap_or_else(|| "[]".to_string()))
4444}
4445
4446impl DomainConfig {
4447 /// Parse a single `.yah/domains/<name>.toml`, rejecting a manifest whose
4448 /// declared front door contradicts its route table
4449 /// ([`Self::validate_front_door`]).
4450 pub fn load(path: &Path) -> Result<Self> {
4451 let src =
4452 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
4453 let dom: Self =
4454 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
4455 dom.validate_front_door()
4456 .with_context(|| format!("validating {}", path.display()))?;
4457 dom.validate_route_headers()
4458 .with_context(|| format!("validating {}", path.display()))?;
4459 Ok(dom)
4460 }
4461
4462 /// R594-F12 — the front door must agree with the rest of the manifest.
4463 ///
4464 /// - `bucket-direct` is an R2 custom domain: a Worker route table would
4465 /// never be consulted, so declaring one means the author expected
4466 /// Worker behaviour (clean URLs, SPA fallback, branded errors) from a
4467 /// surface that cannot provide it. Rejected rather than silently
4468 /// ignored. Same for `worker_bundle_path` — nothing would deploy it.
4469 /// - `worker` / `passway` with an empty route table is a silent 404
4470 /// machine: the front door exists, has nothing to serve, and every
4471 /// request falls through to the catch-all.
4472 ///
4473 /// Called from [`Self::load`], so both [`CloudConfig::load`] and
4474 /// [`CloudConfig::load_from_config_dir`] enforce it.
4475 pub fn validate_front_door(&self) -> Result<()> {
4476 match self.front_door {
4477 FrontDoor::BucketDirect => {
4478 if let Some(route) = self.routes.first() {
4479 anyhow::bail!(
4480 "front_door = \"bucket-direct\" but routes[0].path = \"{}\" — \
4481 an R2 custom domain never consults a route table, so this \
4482 route would silently do nothing (no clean URLs, no SPA \
4483 fallback, no branded errors). Set front_door = \"worker\" \
4484 (or \"passway\") to keep the routes, or drop the [[routes]] \
4485 to keep the bucket-direct binding.",
4486 route.path
4487 );
4488 }
4489 if let Some(path) = &self.worker_bundle_path {
4490 anyhow::bail!(
4491 "front_door = \"bucket-direct\" but worker_bundle_path = \
4492 \"{path}\" — nothing deploys a Worker bundle for a domain \
4493 bound straight to R2"
4494 );
4495 }
4496 }
4497 FrontDoor::Worker | FrontDoor::Passway => {
4498 if self.routes.is_empty() {
4499 anyhow::bail!(
4500 "front_door = \"{}\" but [[routes]] is empty — a front door \
4501 with no route table is a silent 404 machine. Declare at \
4502 least one route, or set front_door = \"bucket-direct\" if \
4503 this domain really is served straight from R2.",
4504 self.front_door.as_str()
4505 );
4506 }
4507 }
4508 }
4509 Ok(())
4510 }
4511
4512 /// The `ROUTE_HEADERS` Worker binding for this domain (R746) — the route
4513 /// table's `path` + `headers` pairs, in manifest order, with routes that
4514 /// declare no headers dropped. `"[]"` when nothing declares any.
4515 ///
4516 /// Order is load-bearing and must survive serialization: the front door
4517 /// applies the FIRST matching rule, so `/app/*` above `/*` is what gives
4518 /// the app its isolation headers and leaves the marketing site alone.
4519 /// That is why this is a `Vec` of pairs and not a map keyed by path.
4520 ///
4521 /// Infallible by design — [`Self::validate_route_headers`] has already run
4522 /// at [`Self::load`], so by the time a reconciler calls this the table is
4523 /// known to be one both front doors can apply.
4524 pub fn route_headers_json(&self) -> String {
4525 #[derive(Serialize)]
4526 struct Rule<'a> {
4527 path: &'a str,
4528 headers: &'a BTreeMap<String, String>,
4529 }
4530 let rules: Vec<Rule<'_>> = self
4531 .routes
4532 .iter()
4533 .filter(|r| !r.headers.is_empty())
4534 .map(|r| Rule {
4535 path: &r.path,
4536 headers: &r.headers,
4537 })
4538 .collect();
4539 serde_json::to_string(&rules).unwrap_or_else(|_| "[]".to_string())
4540 }
4541
4542 /// R749-T5 — everything [`Self::route_headers_json`] emits must be
4543 /// *applicable*, checked here where the table is PRODUCED.
4544 ///
4545 /// That method serializes a typed struct, so the table's JSON *shape* is
4546 /// sound by construction. Its contents are not: a route's `headers` map is
4547 /// a free-form `name -> value` read verbatim out of hand-written TOML, so
4548 /// `"Cross Origin Opener Policy"` (spaces instead of hyphens) or a value
4549 /// carrying a newline ships a structurally-valid table that neither front
4550 /// door can apply — and they fail *differently*, neither naming the
4551 /// manifest line responsible:
4552 ///
4553 /// - **passway** — `mesofact::route_headers::RouteHeaderTable::parse`
4554 /// refuses the start, so the origin is simply down.
4555 /// - **worker** — `validateRouteHeaderTable` accepts it (it checks shape,
4556 /// not header validity) and `applyRouteHeaders` then throws inside the
4557 /// exported `fetch`, which is a 500 on every request, not the
4558 /// serve-without-the-headers degradation that code intends.
4559 ///
4560 /// So the strictness lives at the producer: a table that cannot be applied
4561 /// fails `yah cloud apply` at manifest load, naming domain, route and
4562 /// header. This is deliberately *not* a second parser — the check is
4563 /// `HeaderName`/`HeaderValue`'s own, the very constructors the passway door
4564 /// runs on the far side, and route *matching* semantics stay defined once,
4565 /// at the doors. Only routes that contribute to the table are checked, so
4566 /// the invariant is exactly "`route_headers_json`'s output parses".
4567 ///
4568 /// Called from [`Self::load`], alongside [`Self::validate_front_door`].
4569 pub fn validate_route_headers(&self) -> Result<()> {
4570 use axum::http::{HeaderName, HeaderValue};
4571
4572 for route in self.routes.iter().filter(|r| !r.headers.is_empty()) {
4573 if route.path.is_empty() {
4574 anyhow::bail!(
4575 "domain \"{}\" declares response headers on a route whose `path` is \
4576 empty — a rule that matches nothing (or everything, depending on \
4577 which front door reads it) is not a policy",
4578 self.name
4579 );
4580 }
4581 for (name, value) in &route.headers {
4582 HeaderName::try_from(name.as_str()).with_context(|| {
4583 format!(
4584 "domain \"{}\" route \"{}\" declares {name:?}, which is not a valid \
4585 HTTP header name — names are token characters only, so it is \
4586 `Cross-Origin-Opener-Policy`, never `Cross Origin Opener Policy`",
4587 self.name, route.path
4588 )
4589 })?;
4590 HeaderValue::try_from(value.as_str()).with_context(|| {
4591 format!(
4592 "domain \"{}\" route \"{}\" declares {name} = {value:?}, which is not \
4593 a valid HTTP header value — no newlines and no control characters",
4594 self.name, route.path
4595 )
4596 })?;
4597 }
4598 }
4599 Ok(())
4600 }
4601
4602 /// Whether this domain's route table binds any component of `service`.
4603 pub fn serves_service(&self, service: &str) -> bool {
4604 self.routes.iter().any(|r| {
4605 r.mode
4606 .component()
4607 .and_then(split_component_ref)
4608 .is_some_and(|(svc, _)| svc == service)
4609 })
4610 }
4611
4612 /// Persist to `.yah/domains/<name>.toml`, creating the domains
4613 /// directory if needed. Create-or-overwrite.
4614 pub fn save(&self, workspace_root: &Path) -> Result<()> {
4615 let dir = crate::paths::domains_dir(workspace_root);
4616 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
4617 let path = crate::paths::domain_toml(workspace_root, &self.name);
4618 let s = toml::to_string_pretty(self)
4619 .with_context(|| format!("serializing domain {}", self.name))?;
4620 std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
4621 }
4622
4623 /// Remove `.yah/domains/<name>.toml`. Returns `false` when the file
4624 /// was already absent.
4625 pub fn delete(workspace_root: &Path, name: &str) -> Result<bool> {
4626 let path = crate::paths::domain_toml(workspace_root, name);
4627 if !path.exists() {
4628 return Ok(false);
4629 }
4630 std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
4631 Ok(true)
4632 }
4633}
4634
4635impl RouteMode {
4636 /// Component reference for static/backend modes; `None` for redirects.
4637 pub fn component(&self) -> Option<&str> {
4638 match self {
4639 Self::Static { component } | Self::Backend { component, .. } => Some(component),
4640 Self::Redirect { .. } => None,
4641 }
4642 }
4643}
4644
4645// ─── Service-group vault (R706 / W294) ───────────────────────────────────────
4646
4647/// A camp's declaration of one cluster secret, from
4648/// `.yah/infra/secrets/<slug>.toml`.
4649///
4650/// This is the *authoring* side of the fleet's cluster-secret store: it names
4651/// where the value lives in the camp (a `fob` vault slot), what the fleet should
4652/// call it, and — the point of R706 — which workloads are allowed to mount it.
4653///
4654/// The declaration is not itself the enforcement point. `yah cloud secret put`
4655/// reads this file, seals the vault value under the cluster KEK, and ships the
4656/// ciphertext **with its access rule** into raft; yubaba's `ClusterResolver`
4657/// evaluates the rule on the node at mount time. Deleting this file does not
4658/// revoke anything — the record in raft is the live authority. That asymmetry is
4659/// deliberate: a rule that lived only in a git-tracked camp file would be
4660/// trivially bypassed by anyone who could reach the fleet without the camp.
4661///
4662/// ```toml
4663/// #:schema ../../schema/secret.toml.schema.json
4664/// schema_version = 1
4665/// name = "cheers/cloud-admin/verify-key"
4666/// vault_slot = "cheers-cloud-admin-verify-key"
4667/// description = "Ed25519 public key yah-cloud-admin verifies operator PASETOs with"
4668///
4669/// [access]
4670/// workloads = [{ workload = "yah-cloud-admin" }]
4671///
4672/// [target]
4673/// kind = "file"
4674/// path = "/run/secrets/cheers-verify.key"
4675/// mode = 0o400
4676/// ```
4677#[derive(Debug, Clone, Serialize, Deserialize)]
4678#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4679pub struct SecretConfig {
4680 pub schema_version: u32,
4681
4682 /// Logical cluster-secret key, as `SecretRef::Cluster { name }` spells it —
4683 /// e.g. `"tls/yah.dev/cert"`, `"cheers/cloud-admin/verify-key"`. May contain
4684 /// `/`; the file stem is a filesystem-safe slug and carries no meaning.
4685 pub name: String,
4686
4687 /// The `fob` vault slot in this camp holding the plaintext value. Read by
4688 /// `yah cloud secret put` at ship time and never recorded anywhere else — in
4689 /// particular the value is not in this file, so the declaration is safe to
4690 /// commit.
4691 pub vault_slot: String,
4692
4693 /// Human note for `yah cloud secret ls`. What this secret is and who minted
4694 /// it — the thing nobody remembers 6 months later.
4695 #[serde(default, skip_serializing_if = "Option::is_none")]
4696 pub description: Option<String>,
4697
4698 /// How the vault slot's text decodes into the bytes the consumer expects.
4699 ///
4700 /// `fob` slots hold strings, but plenty of real secrets are **binary** — an
4701 /// Ed25519 key is exactly 32 raw bytes, and `yah-cloud-admin` rejects a key
4702 /// file of any other length. Without this field the only way to ship such a
4703 /// key would be to hope its bytes happened to be valid UTF-8, which for a
4704 /// random key they are not.
4705 ///
4706 /// Defaults to [`SecretEncoding::Utf8`] — the right answer for tokens,
4707 /// passwords, and PEM, which is most secrets.
4708 #[serde(default)]
4709 pub encoding: SecretEncoding,
4710
4711 /// Who may mount it. Stamped onto the raft record verbatim.
4712 ///
4713 /// Defaults to [`SecretAccess::default`] — the deny-all empty allow-list. A
4714 /// declaration that forgets this field produces a secret nobody can mount,
4715 /// which is the correct direction to fail in.
4716 ///
4717 /// Three forms:
4718 ///
4719 /// ```toml
4720 /// access = "allow_any" # explicit escape hatch
4721 ///
4722 /// [access] # named workloads
4723 /// workloads = [{ workload = "yah-cloud-admin" }]
4724 ///
4725 /// [access] # signed recipes (R555-F5)
4726 /// recipes = [{ recipe = "rusty-v8-musl", key = "3d40…" }]
4727 /// ```
4728 ///
4729 /// Use the `recipes` form for a credential a **dispatched build** needs (the
4730 /// R2 write key, the cosign signing key). A remote QED run's workload name
4731 /// is a fresh `forge-<uuid>` every time, so `workloads` cannot name it and
4732 /// `allow_any` over-answers — see W235 §Seam (c) secret scoping. `key` is
4733 /// the hex Ed25519 public key from the recipe's `[admission]` block.
4734 #[serde(default)]
4735 pub access: SecretAccess,
4736
4737 /// Advisory: the mount shape a consuming workload should declare. Not
4738 /// enforced — yubaba honours whatever the `WorkloadSpec` asks for — but it
4739 /// lets `yah cloud secret put` print the exact `SecretMount` to paste, so
4740 /// the consumer and the declaration can't drift on path or mode.
4741 #[serde(default, skip_serializing_if = "Option::is_none")]
4742 pub target: Option<SecretTargetDecl>,
4743}
4744
4745/// How a [`SecretConfig`]'s vault text becomes the bytes delivered to the
4746/// container (R706 / W294).
4747#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
4748#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4749#[serde(rename_all = "kebab-case")]
4750pub enum SecretEncoding {
4751 /// Ship the vault string's UTF-8 bytes verbatim. Tokens, passwords, PEM.
4752 #[default]
4753 Utf8,
4754 /// The vault string is hex; ship the decoded bytes. Use for binary key
4755 /// material — e.g. a raw Ed25519 key, which must land as exactly 32 bytes.
4756 Hex,
4757}
4758
4759/// Advisory mount shape on a [`SecretConfig`]. Mirrors
4760/// `workload_spec::SecretTarget` in a TOML-friendly, externally-tagged-free
4761/// shape (a `kind` discriminator reads better in a hand-written manifest than
4762/// serde's default enum encoding).
4763#[derive(Debug, Clone, Serialize, Deserialize)]
4764#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
4765#[serde(tag = "kind", rename_all = "kebab-case")]
4766pub enum SecretTargetDecl {
4767 /// Mounted as a tmpfs-backed file inside the container.
4768 File {
4769 /// Absolute path inside the container.
4770 path: String,
4771 /// Unix permission bits. Defaults to `0o400` (owner-read-only).
4772 #[serde(default = "default_secret_mode")]
4773 mode: u32,
4774 },
4775 /// Injected as an environment variable. Prefer `file` — env vars leak
4776 /// through subprocess environments and log dumps.
4777 EnvVar { name: String },
4778}
4779
4780fn default_secret_mode() -> u32 {
4781 0o400
4782}
4783
4784impl SecretTargetDecl {
4785 /// The `workload_spec` target this declaration describes.
4786 pub fn to_target(&self) -> workload_spec::SecretTarget {
4787 match self {
4788 Self::File { path, mode } => workload_spec::SecretTarget::File {
4789 path: path.into(),
4790 mode: *mode,
4791 },
4792 Self::EnvVar { name } => workload_spec::SecretTarget::EnvVar { name: name.clone() },
4793 }
4794 }
4795}
4796
4797impl SecretConfig {
4798 /// Parse a single `.yah/infra/secrets/<slug>.toml`.
4799 pub fn load(path: &Path) -> Result<Self> {
4800 let src =
4801 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
4802 let cfg: Self =
4803 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
4804 cfg.validate()
4805 .with_context(|| format!("validating {}", path.display()))?;
4806 Ok(cfg)
4807 }
4808
4809 /// Load every declaration in `dir`, keyed by logical secret name. A missing
4810 /// directory is an empty map (a camp with no cluster secrets is normal).
4811 ///
4812 /// Two files declaring the same `name` is a hard error, not a last-writer-
4813 /// wins merge: they would race to define the access rule for one record, and
4814 /// whichever lost would look correct in git while being inert on the fleet.
4815 pub fn load_dir(dir: &Path) -> Result<BTreeMap<String, Self>> {
4816 let mut out: BTreeMap<String, Self> = BTreeMap::new();
4817 if !dir.exists() {
4818 return Ok(out);
4819 }
4820 for entry in std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? {
4821 let path = entry?.path();
4822 if path.extension().is_none_or(|e| e != "toml") {
4823 continue;
4824 }
4825 let cfg = Self::load(&path)?;
4826 if let Some(prev) = out.insert(cfg.name.clone(), cfg) {
4827 anyhow::bail!(
4828 "two secret declarations both claim name {:?} (one of them is {}); \
4829 a cluster secret must have exactly one declaration so its access \
4830 rule has one author",
4831 prev.name,
4832 path.display()
4833 );
4834 }
4835 }
4836 Ok(out)
4837 }
4838
4839 /// Reject declarations that would produce an unusable or dangerous record.
4840 pub fn validate(&self) -> Result<()> {
4841 if self.name.trim().is_empty() {
4842 anyhow::bail!("`name` must not be empty");
4843 }
4844 if self.vault_slot.trim().is_empty() {
4845 anyhow::bail!(
4846 "`vault_slot` must not be empty — it names the fob slot holding the value"
4847 );
4848 }
4849 // A deny-all rule is a *valid* record (it is the fail-closed default the
4850 // resolver relies on) but it is never a useful thing to deliberately
4851 // ship, so catching it here saves an operator the round-trip of
4852 // deploying a workload that mysteriously can't see its own secret.
4853 if let SecretAccess::Workloads(entries) = &self.access {
4854 if entries.is_empty() {
4855 anyhow::bail!(
4856 "`[access]` admits nobody: list the workloads allowed to mount {:?} \
4857 (e.g. `workloads = [{{ workload = \"my-service\" }}]`), or set \
4858 `access = \"allow_any\"` to store it unrestricted",
4859 self.name
4860 );
4861 }
4862 if let Some(bad) = entries.iter().find(|e| e.workload.trim().is_empty()) {
4863 anyhow::bail!("`[access]` entry has an empty `workload` name: {bad:?}");
4864 }
4865 }
4866 Ok(())
4867 }
4868}
4869
4870#[cfg(test)]
4871mod secret_config_tests {
4872 use super::*;
4873
4874 fn parse(body: &str) -> Result<SecretConfig> {
4875 let cfg: SecretConfig = toml::from_str(body)?;
4876 cfg.validate()?;
4877 Ok(cfg)
4878 }
4879
4880 #[test]
4881 fn minimal_declaration_parses_with_narrow_defaults() {
4882 let cfg = parse(
4883 r#"
4884schema_version = 1
4885name = "svc/token"
4886vault_slot = "svc-token"
4887[access]
4888workloads = [{ workload = "svc" }]
4889"#,
4890 )
4891 .unwrap();
4892
4893 assert_eq!(cfg.encoding, SecretEncoding::Utf8, "text is the default");
4894 assert!(cfg.target.is_none());
4895 // The omitted tenant/namespace must narrow to the singletons, not widen
4896 // to a wildcard.
4897 assert!(cfg
4898 .access
4899 .admits(&workload_spec::secrets::SecretConsumer::workload("svc")));
4900 assert!(!cfg
4901 .access
4902 .admits(&workload_spec::secrets::SecretConsumer::workload("other")));
4903 }
4904
4905 #[test]
4906 fn allow_any_is_spelled_as_a_bare_string() {
4907 // The operator-facing spelling, pinned: `access = "allow_any"`.
4908 let cfg = parse(
4909 r#"
4910schema_version = 1
4911name = "public/thing"
4912vault_slot = "slot"
4913access = "allow_any"
4914"#,
4915 )
4916 .unwrap();
4917 assert_eq!(cfg.access, SecretAccess::AllowAny);
4918 }
4919
4920 #[test]
4921 fn a_declaration_with_no_access_block_is_rejected() {
4922 // Omitting `[access]` defaults to deny-all, which is the correct
4923 // *runtime* default but never a correct authoring intent — so it must
4924 // not silently produce a secret nobody can mount.
4925 let err = parse(
4926 r#"
4927schema_version = 1
4928name = "svc/token"
4929vault_slot = "svc-token"
4930"#,
4931 )
4932 .unwrap_err()
4933 .to_string();
4934 assert!(err.contains("admits nobody"), "got {err}");
4935 }
4936
4937 #[test]
4938 fn empty_name_or_slot_is_rejected() {
4939 assert!(parse(
4940 r#"
4941schema_version = 1
4942name = ""
4943vault_slot = "slot"
4944access = "allow_any"
4945"#
4946 )
4947 .is_err());
4948 assert!(parse(
4949 r#"
4950schema_version = 1
4951name = "x"
4952vault_slot = " "
4953access = "allow_any"
4954"#
4955 )
4956 .is_err());
4957 }
4958
4959 #[test]
4960 fn target_declaration_maps_onto_the_workload_spec_type() {
4961 let cfg = parse(
4962 r#"
4963schema_version = 1
4964name = "svc/token"
4965vault_slot = "slot"
4966access = "allow_any"
4967[target]
4968kind = "file"
4969path = "/run/secrets/t"
4970"#,
4971 )
4972 .unwrap();
4973 match cfg.target.unwrap().to_target() {
4974 workload_spec::SecretTarget::File { path, mode } => {
4975 assert_eq!(path, std::path::PathBuf::from("/run/secrets/t"));
4976 assert_eq!(mode, 0o400, "owner-read-only by default");
4977 }
4978 other => panic!("expected File, got {other:?}"),
4979 }
4980 }
4981
4982 #[test]
4983 fn load_dir_is_empty_for_a_camp_with_no_secrets() {
4984 let tmp = tempfile::TempDir::new().unwrap();
4985 assert!(SecretConfig::load_dir(&tmp.path().join("nope"))
4986 .unwrap()
4987 .is_empty());
4988 }
4989}
4990
4991/// Split a `"<service>/<component-id>"` ref. Returns `None` if the ref
4992/// isn't shaped like `service/component`.
4993fn split_component_ref(s: &str) -> Option<(&str, &str)> {
4994 let (svc, comp) = s.split_once('/')?;
4995 if svc.is_empty() || comp.is_empty() || comp.contains('/') {
4996 return None;
4997 }
4998 Some((svc, comp))
4999}
5000
5001#[cfg(test)]
5002mod tests {
5003 use super::*;
5004 use std::path::PathBuf;
5005
5006 fn make_machine(name: &str, mesh_tags: Vec<&str>) -> MachineConfig {
5007 MachineConfig {
5008 name: name.into(),
5009 provider: "hetzner".into(),
5010 location: Some("hil".into()),
5011 server_type: Some("ccx13".into()),
5012 hosts_mirrors: vec![],
5013 mesh_tags: mesh_tags.into_iter().map(String::from).collect(),
5014 region: None,
5015 zone: None,
5016 arch: None,
5017 bucket: None,
5018 vendor: None,
5019 nickname: None,
5020 legacy_hostkey_fingerprint: None,
5021 registration: Default::default(),
5022 ssh_keys: vec![],
5023 cloudflared: None,
5024 hosts_operator_bridge: false,
5025 connect: None,
5026 allocatable: None,
5027 taints: vec![],
5028 sovereign_group: None,
5029 sovereign_role: None,
5030 ingress_floating_ip: None,
5031 }
5032 }
5033
5034 /// Like [`make_machine`] but with explicit topology axes for F16 tests.
5035 fn make_machine_topo(
5036 name: &str,
5037 provider: &str,
5038 region: &str,
5039 mesh_tags: Vec<&str>,
5040 ) -> MachineConfig {
5041 MachineConfig {
5042 provider: provider.into(),
5043 region: Some(region.into()),
5044 zone: Some(region.into()),
5045 ..make_machine(name, mesh_tags)
5046 }
5047 }
5048
5049 fn make_empty_cfg(machines: Vec<MachineConfig>) -> CloudConfig {
5050 CloudConfig {
5051 workspace_root: PathBuf::new(),
5052 machines,
5053 providers: vec![],
5054 machine_origins: BTreeMap::new(),
5055 provider_origins: BTreeMap::new(),
5056 services: BTreeMap::new(),
5057 domains: BTreeMap::new(),
5058 legacy_mirrors: vec![],
5059 workloads: vec![],
5060 topology: TopologyConfig::default(),
5061 legacy_services: vec![],
5062 }
5063 }
5064
5065 #[test]
5066 fn required_spec_parses_from_provider_fields() {
5067 let toml_src = r#"
5068use = "hetzner-primary"
5069[required]
5070mesh_tags = ["tag:cloud-runner"]
5071"#;
5072 let slot: MirrorProviderSlot = toml::from_str(toml_src).unwrap();
5073 let req = slot.required().expect("required block present");
5074 assert_eq!(req.mesh_tags, vec!["tag:cloud-runner"]);
5075 }
5076
5077 #[test]
5078 fn required_spec_absent_when_field_missing() {
5079 let slot: MirrorProviderSlot = toml::from_str(r#"use = "hetzner-primary""#).unwrap();
5080 assert!(slot.required().is_none());
5081 }
5082
5083 #[test]
5084 fn db_catalog_parses_all_env_blocks() {
5085 // W241 / R571-F8: a service.toml [db] table with dev/pond/cloud.
5086 let toml_src = r#"
5087schema_version = 1
5088name = "scrabcake"
5089domain = "scrabcake.net.yah.dev"
5090
5091[[db.dev]]
5092name = "main"
5093path = "data/dev.sqlite"
5094
5095[[db.pond]]
5096name = "main"
5097port = 5433
5098
5099[[db.pond]]
5100name = "pg"
5101port = 5432
5102kind = "postgres"
5103
5104[[db.cloud]]
5105name = "main"
5106url = "libsql://scrabcake.turso.io"
5107auth_token_env = "SCRABCAKE_TURSO_TOKEN"
5108"#;
5109 let svc: ServiceConfig = toml::from_str(toml_src).unwrap();
5110 assert_eq!(svc.db.dev.len(), 1);
5111 assert_eq!(svc.db.dev[0].path, "data/dev.sqlite");
5112 assert_eq!(svc.db.pond.len(), 2);
5113 assert_eq!(svc.db.pond[0].port, Some(5433));
5114 assert_eq!(svc.db.pond[0].kind, PondDbKind::Turso); // default
5115 assert_eq!(svc.db.pond[1].kind, PondDbKind::Postgres);
5116 assert_eq!(
5117 svc.db.cloud[0].auth_token_env.as_deref(),
5118 Some("SCRABCAKE_TURSO_TOKEN")
5119 );
5120 }
5121
5122 #[test]
5123 fn service_without_db_table_has_empty_catalog() {
5124 let svc: ServiceConfig =
5125 toml::from_str("schema_version = 1\nname = \"s\"\ndomain = \"s.dev\"\n").unwrap();
5126 assert!(svc.db.is_empty());
5127 // And an empty [db] must not appear when re-serialized.
5128 let out = toml::to_string(&svc).unwrap();
5129 assert!(
5130 !out.contains("[db"),
5131 "empty db table should be skipped: {out}"
5132 );
5133 }
5134
5135 #[test]
5136 fn camp_shared_cloud_toml_parses() {
5137 let src = r#"
5138[[cloud]]
5139name = "analytics"
5140url = "postgres://shared/analytics"
5141"#;
5142 let shared: CampCloudDbs = toml::from_str(src).unwrap();
5143 assert_eq!(shared.cloud.len(), 1);
5144 assert_eq!(shared.cloud[0].name, "analytics");
5145 }
5146
5147 #[test]
5148 fn resolve_machine_by_mesh_tags_superset_match() {
5149 let cfg = make_empty_cfg(vec![
5150 make_machine("yah-bnt-1", vec!["tag:primary-yah", "tag:tier-scratch"]),
5151 make_machine("us-west-001", vec!["tag:primary-yah", "tag:cloud-runner"]),
5152 ]);
5153 let picked = cfg
5154 .resolve_machine_by_mesh_tags(&["tag:cloud-runner".into()])
5155 .map(|m| m.name.as_str());
5156 assert_eq!(picked, Some("us-west-001"));
5157 }
5158
5159 #[test]
5160 fn resolve_machine_by_mesh_tags_returns_none_when_no_match() {
5161 let cfg = make_empty_cfg(vec![make_machine("yah-bnt-1", vec!["tag:primary-yah"])]);
5162 assert!(cfg
5163 .resolve_machine_by_mesh_tags(&["tag:cloud-runner".into()])
5164 .is_none());
5165 }
5166
5167 // ─── R590-F1 mesh-tag node-selector admission ───────────────────────────
5168
5169 /// Build a forge WorkloadSpec carrying the R594 node-selector annotation.
5170 /// `selector` is the comma-joined mesh-tag set; `None` omits the annotation
5171 /// entirely (pre-R594 "no constraint").
5172 fn ws_with_selector(selector: Option<&str>) -> WorkloadSpec {
5173 use workload_spec::{ImageRef, TierTag};
5174 let mut ws = WorkloadSpec::for_forge(
5175 "R590-F1-test",
5176 ImageRef {
5177 registry: "docker.io".into(),
5178 repository: "library/busybox".into(),
5179 tag: "latest".into(),
5180 digest: workload_spec::testing::test_digest(),
5181 },
5182 TierTag("infra".into()),
5183 vec![],
5184 );
5185 if let Some(sel) = selector {
5186 ws.annotations.insert(
5187 velveteen_exec::remote::NODE_SELECTOR_MESH_TAGS_ANNOTATION.into(),
5188 sel.into(),
5189 );
5190 }
5191 ws
5192 }
5193
5194 /// The build-worker fleet shape: one x86 node (us-west-002) and one arm
5195 /// node (a Pi5), both carrying `tag:build-worker`.
5196 fn build_worker_fleet() -> CloudConfig {
5197 make_empty_cfg(vec![
5198 make_machine("us-west-002", vec!["tag:build-worker", "arch:x86"]),
5199 make_machine("pi5-001", vec!["tag:build-worker", "arch:arm"]),
5200 ])
5201 }
5202
5203 #[test]
5204 fn admit_workload_routes_amd64_to_x86_worker() {
5205 let cfg = build_worker_fleet();
5206 let ws = ws_with_selector(Some("tag:build-worker,arch:x86"));
5207 let picked = cfg.admit_workload(&ws).unwrap();
5208 assert_eq!(picked.name, "us-west-002");
5209 }
5210
5211 #[test]
5212 fn admit_workload_routes_arm64_to_pi5_worker() {
5213 let cfg = build_worker_fleet();
5214 let ws = ws_with_selector(Some("tag:build-worker,arch:arm"));
5215 let picked = cfg.admit_workload(&ws).unwrap();
5216 assert_eq!(picked.name, "pi5-001");
5217 }
5218
5219 /// A forge run must be admissible on a build-worker smaller than its own
5220 /// cgroup ceiling.
5221 ///
5222 /// The fleet's arm build-workers are 8 GiB Pi-5s and `for_forge` sets a
5223 /// 32 GiB ceiling, so while admission read `resources.memory_mb` as the
5224 /// capacity floor this returned "no candidates" and *every* offloaded qed
5225 /// step to those nodes failed at dispatch — measured on desktop-release run
5226 /// b04cef47, where the aarch64-linux row died in 1.6s. The other
5227 /// build-workers (16 GiB us-west-003, and the arm Pi-5s) were excluded the
5228 /// same way, leaving one 47 GiB node as the fleet's only legal target for
5229 /// remote CI.
5230 #[test]
5231 fn admit_workload_places_a_forge_run_on_a_worker_smaller_than_its_ceiling() {
5232 let mut pi = make_machine("pi5-001", vec!["tag:build-worker", "arch:arm"]);
5233 pi.allocatable = Some(NodeAllocatable {
5234 memory_mb: 8192,
5235 cpu_millis: 4000,
5236 });
5237 let cfg = make_empty_cfg(vec![pi]);
5238
5239 let ws = ws_with_selector(Some("tag:build-worker,arch:arm"));
5240 assert!(
5241 ws.resources.memory_mb > 8192,
5242 "precondition: the ceiling must exceed the node, or this proves nothing"
5243 );
5244
5245 let picked = cfg
5246 .admit_workload(&ws)
5247 .expect("an 8 GiB build-worker must admit a forge run");
5248 assert_eq!(picked.name, "pi5-001");
5249 }
5250
5251 /// The floor is still enforced — the fix separates two numbers, it does not
5252 /// disable the R572-F5 capacity check.
5253 #[test]
5254 fn admit_workload_still_rejects_a_node_below_the_declared_request() {
5255 let mut tiny = make_machine("tiny-001", vec!["tag:build-worker", "arch:arm"]);
5256 tiny.allocatable = Some(NodeAllocatable {
5257 memory_mb: 512,
5258 cpu_millis: 4000,
5259 });
5260 let cfg = make_empty_cfg(vec![tiny]);
5261
5262 let ws = ws_with_selector(Some("tag:build-worker,arch:arm"));
5263 assert!(
5264 cfg.admit_workload(&ws).is_err(),
5265 "a 512 MiB node cannot satisfy a 2 GiB forge request"
5266 );
5267 }
5268
5269 // ─── R833-F8 imperative node-selector admission ─────────────────────────
5270
5271 /// Build a forge WorkloadSpec carrying the R833-F8 imperative node
5272 /// selector — the operator's `--where=node:<machine>`.
5273 fn ws_pinned_to(node: &str) -> WorkloadSpec {
5274 let mut ws = ws_with_selector(None);
5275 ws.annotations.insert(
5276 velveteen_exec::remote::NODE_SELECTOR_NODE_ANNOTATION.into(),
5277 node.into(),
5278 );
5279 ws
5280 }
5281
5282 /// The ticket's acceptance shape: a named node wins over the
5283 /// declaration-order tie-break that would otherwise decide placement.
5284 /// `us-west-002` is declared first and carries every tag, so an inferred
5285 /// placement lands there; the pin must reach `pi5-001` regardless.
5286 #[test]
5287 fn admit_workload_honours_an_explicitly_named_node() {
5288 let cfg = build_worker_fleet();
5289 assert_eq!(
5290 cfg.admit_workload(&ws_with_selector(Some("tag:build-worker")))
5291 .unwrap()
5292 .name,
5293 "us-west-002",
5294 "precondition: inference elects the first-declared node",
5295 );
5296 assert_eq!(
5297 cfg.admit_workload(&ws_pinned_to("pi5-001")).unwrap().name,
5298 "pi5-001",
5299 );
5300 }
5301
5302 /// A pin at a machine that is not declared fails loud, naming the
5303 /// constraint and the pool — the operator mistyped a node, and silently
5304 /// running the build somewhere else is the one outcome that must not
5305 /// happen.
5306 #[test]
5307 fn admit_workload_refuses_a_node_that_is_not_declared() {
5308 let cfg = build_worker_fleet();
5309 let err = cfg
5310 .admit_workload(&ws_pinned_to("us-west-404"))
5311 .unwrap_err()
5312 .to_string();
5313 assert!(err.contains("required.nodes=[us-west-404]"), "{err}");
5314 assert!(err.contains("us-west-002"), "the pool must be named: {err}");
5315 }
5316
5317 /// The pin narrows the candidate set; it does not suspend the other axes.
5318 /// A named node that cannot fit the workload still refuses, rather than
5319 /// being handed work it has no room for.
5320 #[test]
5321 fn a_pinned_node_is_still_checked_against_capacity() {
5322 let mut tiny = make_machine("tiny-001", vec!["tag:build-worker", "arch:arm"]);
5323 tiny.allocatable = Some(NodeAllocatable {
5324 memory_mb: 512,
5325 cpu_millis: 4000,
5326 });
5327 let cfg = make_empty_cfg(vec![tiny]);
5328 assert!(cfg.admit_workload(&ws_pinned_to("tiny-001")).is_err());
5329 }
5330
5331 /// Inference is untouched: with no node annotation the `nodes` axis is
5332 /// empty, which is "no constraint" — every pre-R833-F8 workload is admitted
5333 /// exactly as before.
5334 #[test]
5335 fn an_unpinned_workload_carries_no_node_constraint() {
5336 assert!(node_selector_node(&ws_with_selector(Some("arch:x86"))).is_none());
5337 assert_eq!(
5338 node_selector_node(&ws_pinned_to("us-west-003")).as_deref(),
5339 Some("us-west-003")
5340 );
5341 assert!(RequiredSpec::default().is_unconstrained());
5342 assert!(!RequiredSpec {
5343 nodes: vec!["us-west-003".into()],
5344 ..Default::default()
5345 }
5346 .is_unconstrained());
5347 }
5348
5349 #[test]
5350 fn admit_workload_rejects_node_missing_required_tag() {
5351 // Only an arm worker exists; an x86 build must NOT land on it.
5352 let cfg = make_empty_cfg(vec![make_machine(
5353 "pi5-001",
5354 vec!["tag:build-worker", "arch:arm"],
5355 )]);
5356 let ws = ws_with_selector(Some("tag:build-worker,arch:x86"));
5357 assert!(cfg.admit_workload(&ws).is_err());
5358 }
5359
5360 /// R555-S1 regression: with TWO nodes carrying the same tag set, which one
5361 /// admits must be decided by *declaration order* (file name), which is the
5362 /// contract `admit_workload` documents — not by `read_dir` order, which is
5363 /// filesystem-dependent and can change when an unrelated file appears in
5364 /// the directory. Written creation-order-reversed so a filesystem that
5365 /// yields creation order (rather than sorted order) trips it without the
5366 /// sort in `load_dir`.
5367 ///
5368 /// Live consequence this guards: `.yah/infra/machines/` carries both
5369 /// us-west-002 and us-west-003 on `[tag:build-worker, arch:x86, os:linux]`,
5370 /// so an x86 QED offload has two equal candidates. Unstable selection means
5371 /// a retried build cannot be relied on to land back on the node whose
5372 /// working state it left behind.
5373 #[test]
5374 fn equally_matching_machines_admit_in_file_name_order() {
5375 let tmp = tempfile::TempDir::new().unwrap();
5376 let machines = tmp.path().join(".yah").join("infra").join("machines");
5377 std::fs::create_dir_all(&machines).unwrap();
5378 let toml_for = |name: &str| {
5379 format!(
5380 r#"name = "{name}"
5381provider = "static"
5382mesh_tags = ["tag:build-worker", "arch:x86"]
5383"#
5384 )
5385 };
5386 // Reverse-of-sorted creation order on purpose.
5387 std::fs::write(machines.join("b-second.toml"), toml_for("b-second")).unwrap();
5388 std::fs::write(machines.join("a-first.toml"), toml_for("a-first")).unwrap();
5389
5390 let cfg = CloudConfig::load(tmp.path()).unwrap();
5391 assert_eq!(
5392 cfg.machines.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(),
5393 vec!["a-first", "b-second"],
5394 "machines must load in file-name order, not read_dir order"
5395 );
5396
5397 let ws = ws_with_selector(Some("tag:build-worker,arch:x86"));
5398 assert_eq!(cfg.admit_workload(&ws).unwrap().name, "a-first");
5399
5400 // R605-T14: the same two nodes, seen as the pool they are. The head is
5401 // what `admit_workload` returns, and the tail is what the dispatcher
5402 // fails over to when the head does not answer — so these two views must
5403 // come from one predicate, not two.
5404 assert_eq!(
5405 cfg.admit_workload_candidates(&ws)
5406 .unwrap()
5407 .iter()
5408 .map(|m| m.name.as_str())
5409 .collect::<Vec<_>>(),
5410 vec!["a-first", "b-second"],
5411 "the pool must be every admissible node, in the same declaration order"
5412 );
5413 }
5414
5415 /// A pool of one is still a pool, and a pool of none is an `Err` that reads
5416 /// exactly like `admit_workload`'s — "nothing admits this" is one failure
5417 /// with one wording, not two.
5418 #[test]
5419 fn admit_workload_candidates_matches_admit_workload_on_the_edges() {
5420 let cfg = make_empty_cfg(vec![
5421 make_machine("x86-box", vec!["tag:build-worker", "arch:x86"]),
5422 make_machine("arm-box", vec!["tag:build-worker", "arch:arm"]),
5423 ]);
5424
5425 let one = ws_with_selector(Some("arch:arm"));
5426 assert_eq!(
5427 cfg.admit_workload_candidates(&one)
5428 .unwrap()
5429 .iter()
5430 .map(|m| m.name.as_str())
5431 .collect::<Vec<_>>(),
5432 vec!["arm-box"],
5433 "only one node carries arch:arm, so the pool is that one node"
5434 );
5435
5436 let none = ws_with_selector(Some("arch:riscv"));
5437 let pool_err = cfg.admit_workload_candidates(&none).unwrap_err().to_string();
5438 let single_err = cfg.admit_workload(&none).unwrap_err().to_string();
5439 assert_eq!(
5440 pool_err, single_err,
5441 "an empty pool must be refused in the same words as an unadmitted workload"
5442 );
5443 }
5444
5445 /// R844-B7 — the wrong-root half of the distinction. A directory with no
5446 /// `.yah/` at all used to load as a valid config with zero machines, so a
5447 /// caller pointed at the wrong directory got a green result that measured
5448 /// nothing. Asserting `load` merely *succeeds* is what let that through;
5449 /// the shape that catches it is a non-zero machine count, or — here — an
5450 /// `Err` naming the path that was looked for.
5451 #[test]
5452 fn loading_a_directory_that_is_not_a_yah_workspace_is_an_error() {
5453 let tmp = tempfile::TempDir::new().unwrap();
5454 // A plausible-looking package root: real files, real subdirectories,
5455 // no `.yah/`. This is exactly what `load_cloud(".")` reads when a test
5456 // runs under `cargo test` from a member crate.
5457 std::fs::create_dir_all(tmp.path().join("src")).unwrap();
5458 std::fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
5459
5460 let err = CloudConfig::load(tmp.path()).expect_err(
5461 "a directory with no .yah/ is the WRONG DIRECTORY, not a fleet with no machines",
5462 );
5463 let msg = format!("{err:#}");
5464 assert!(
5465 msg.contains("not a yah workspace"),
5466 "error must say the root is not a workspace, got: {msg}"
5467 );
5468 assert!(
5469 msg.contains(&tmp.path().join(".yah").display().to_string()),
5470 "error must name the path it looked for so an operator sees the \
5471 wrong-root immediately, got: {msg}"
5472 );
5473 }
5474
5475 /// R844-B7 — the other half, and the reason the check is drawn at `.yah/`
5476 /// rather than at the machine list: a camp that declares no machines is a
5477 /// real workspace and must keep loading. Blanket-erroring on an empty
5478 /// fleet would conflate `unknown` with `answered with none`, which is the
5479 /// exact confusion the check exists to remove.
5480 #[test]
5481 fn a_workspace_with_no_machines_declared_still_loads() {
5482 let tmp = tempfile::TempDir::new().unwrap();
5483 std::fs::create_dir_all(tmp.path().join(".yah")).unwrap();
5484
5485 let cfg = CloudConfig::load(tmp.path())
5486 .expect("a `.yah/` with no infra/machines/ is an empty fleet, not a wrong root");
5487 assert!(cfg.machines.is_empty(), "nothing was declared");
5488 assert!(cfg.services.is_empty());
5489 assert!(cfg.providers.is_empty());
5490
5491 // And an existing-but-empty machines dir is the same answer, not a
5492 // second special case.
5493 std::fs::create_dir_all(crate::paths::machines_dir(tmp.path())).unwrap();
5494 let cfg = CloudConfig::load(tmp.path()).expect("an empty machines/ dir still loads");
5495 assert!(cfg.machines.is_empty());
5496 }
5497
5498 #[test]
5499 fn admit_workload_empty_selector_is_unconstrained() {
5500 // Absent annotation ⇒ no mesh-tag constraint ⇒ first declared machine
5501 // (pre-R594 behavior preserved).
5502 let cfg = build_worker_fleet();
5503 let ws = ws_with_selector(None);
5504 let picked = cfg.admit_workload(&ws).unwrap();
5505 assert_eq!(picked.name, "us-west-002");
5506 }
5507
5508 #[test]
5509 fn node_selector_mesh_tags_trims_and_drops_empties() {
5510 let ws = ws_with_selector(Some(" tag:build-worker , arch:x86 ,"));
5511 assert_eq!(
5512 node_selector_mesh_tags(&ws),
5513 vec!["tag:build-worker".to_string(), "arch:x86".to_string()]
5514 );
5515 assert!(node_selector_mesh_tags(&ws_with_selector(None)).is_empty());
5516 }
5517
5518 // ─── F16 topology-aware resolver ────────────────────────────────────────
5519
5520 fn two_region_fleet() -> CloudConfig {
5521 make_empty_cfg(vec![
5522 make_machine_topo(
5523 "us-west-001",
5524 "hetzner",
5525 "us-west",
5526 vec!["tag:cloud-runner"],
5527 ),
5528 make_machine_topo(
5529 "eu-west-001",
5530 "hetzner",
5531 "eu-west",
5532 vec!["tag:cloud-runner"],
5533 ),
5534 ])
5535 }
5536
5537 #[test]
5538 fn resolve_machine_matches_on_region_plus_mesh_tags() {
5539 let cfg = two_region_fleet();
5540 let req = RequiredSpec {
5541 regions: vec!["us-west".into()],
5542 mesh_tags: vec!["tag:cloud-runner".into()],
5543 ..Default::default()
5544 };
5545 let picked = cfg.resolve_machine(&req).unwrap();
5546 assert_eq!(picked.name, "us-west-001");
5547 }
5548
5549 #[test]
5550 fn resolve_machine_region_disambiguates_same_tag() {
5551 // Both boxes carry tag:cloud-runner; the region axis selects eu-west.
5552 let cfg = two_region_fleet();
5553 let req = RequiredSpec {
5554 regions: vec!["eu-west".into()],
5555 mesh_tags: vec!["tag:cloud-runner".into()],
5556 ..Default::default()
5557 };
5558 assert_eq!(cfg.resolve_machine(&req).unwrap().name, "eu-west-001");
5559 }
5560
5561 #[test]
5562 fn resolve_machine_fails_loud_with_constraint_summary() {
5563 let cfg = two_region_fleet();
5564 let req = RequiredSpec {
5565 regions: vec!["us-central".into()],
5566 mesh_tags: vec!["tag:cloud-runner".into()],
5567 ..Default::default()
5568 };
5569 let err = cfg.resolve_machine(&req).unwrap_err().to_string();
5570 assert!(err.contains("required.regions=[us-central]"), "got: {err}");
5571 assert!(
5572 err.contains("required.mesh_tags=[tag:cloud-runner]"),
5573 "got: {err}"
5574 );
5575 // Names the candidates it rejected.
5576 assert!(err.contains("us-west-001"), "got: {err}");
5577 }
5578
5579 #[test]
5580 fn resolve_machine_provider_axis_filters() {
5581 let cfg = make_empty_cfg(vec![
5582 make_machine_topo("aws-west-1", "aws", "us-west", vec!["tag:cloud-runner"]),
5583 make_machine_topo("hz-west-1", "hetzner", "us-west", vec!["tag:cloud-runner"]),
5584 ]);
5585 let req = RequiredSpec {
5586 regions: vec!["us-west".into()],
5587 providers: vec!["hetzner".into()],
5588 ..Default::default()
5589 };
5590 assert_eq!(cfg.resolve_machine(&req).unwrap().name, "hz-west-1");
5591 }
5592
5593 #[test]
5594 fn unconstrained_required_spec_matches_first_machine() {
5595 let cfg = two_region_fleet();
5596 assert!(RequiredSpec::default().is_unconstrained());
5597 assert_eq!(
5598 cfg.resolve_machine(&RequiredSpec::default()).unwrap().name,
5599 "us-west-001"
5600 );
5601 }
5602
5603 #[test]
5604 fn required_spec_parses_topology_axes_from_toml() {
5605 let toml_src = r#"
5606use = "hetzner-primary"
5607[required]
5608regions = ["us-west"]
5609mesh_tags = ["tag:cloud-runner"]
5610"#;
5611 let slot: MirrorProviderSlot = toml::from_str(toml_src).unwrap();
5612 let req = slot.required().expect("required block present");
5613 assert_eq!(req.regions, vec!["us-west"]);
5614 assert_eq!(req.mesh_tags, vec!["tag:cloud-runner"]);
5615 assert!(req.zones.is_empty());
5616 }
5617
5618 // ─── R844-F8 replica count ──────────────────────────────────────────────
5619
5620 fn three_runner_fleet() -> CloudConfig {
5621 make_empty_cfg(vec![
5622 make_machine_topo("us-east-001", "hetzner", "us-east", vec!["tag:cloud-runner"]),
5623 make_machine_topo(
5624 "us-south-001",
5625 "hetzner",
5626 "us-south",
5627 vec!["tag:cloud-runner"],
5628 ),
5629 make_machine_topo(
5630 "us-west-001",
5631 "hetzner",
5632 "us-west",
5633 vec!["tag:cloud-runner"],
5634 ),
5635 ])
5636 }
5637
5638 #[test]
5639 fn an_absent_replica_count_still_places_exactly_one_machine() {
5640 // The migration is additive: every mirror on disk omits `replicas`, and
5641 // must resolve byte-identically to the pre-R844-F8 answer.
5642 let cfg = three_runner_fleet();
5643 let req = RequiredSpec {
5644 mesh_tags: vec!["tag:cloud-runner".into()],
5645 ..Default::default()
5646 };
5647 assert_eq!(req.replica_count(), 1);
5648 let names: Vec<&str> = cfg
5649 .resolve_machines(&req)
5650 .unwrap()
5651 .iter()
5652 .map(|m| m.name.as_str())
5653 .collect();
5654 assert_eq!(names, vec!["us-east-001"]);
5655 assert_eq!(cfg.resolve_machine(&req).unwrap().name, "us-east-001");
5656 }
5657
5658 #[test]
5659 fn a_replica_count_places_that_many_machines_not_every_match() {
5660 // Three machines match; two are asked for; two are placed. Inferring the
5661 // count from the match count would make adding a box to the fleet
5662 // silently scale a production front door.
5663 let cfg = three_runner_fleet();
5664 let req = RequiredSpec {
5665 mesh_tags: vec!["tag:cloud-runner".into()],
5666 replicas: Some(2),
5667 ..Default::default()
5668 };
5669 let names: Vec<&str> = cfg
5670 .resolve_machines(&req)
5671 .unwrap()
5672 .iter()
5673 .map(|m| m.name.as_str())
5674 .collect();
5675 assert_eq!(names, vec!["us-east-001", "us-south-001"]);
5676 }
5677
5678 #[test]
5679 fn fewer_matches_than_replicas_is_an_error_naming_both_numbers() {
5680 // Never a partial placement: one of two reported as success is the
5681 // subset-that-looks-like-it-worked failure in its purest form.
5682 let cfg = three_runner_fleet();
5683 let req = RequiredSpec {
5684 regions: vec!["us-east".into()],
5685 replicas: Some(2),
5686 ..Default::default()
5687 };
5688 let err = cfg.resolve_machines(&req).unwrap_err().to_string();
5689 assert!(err.contains("only 1 of 2"), "got: {err}");
5690 assert!(err.contains("required.regions=[us-east]"), "got: {err}");
5691 // …and names the pool it searched, like every other placement refusal.
5692 assert!(err.contains("declared machines"), "got: {err}");
5693 assert!(err.contains("us-south-001"), "got: {err}");
5694 }
5695
5696 #[test]
5697 fn zero_replicas_is_refused_rather_than_placing_nothing() {
5698 let cfg = three_runner_fleet();
5699 let req = RequiredSpec {
5700 mesh_tags: vec!["tag:cloud-runner".into()],
5701 replicas: Some(0),
5702 ..Default::default()
5703 };
5704 let err = cfg.resolve_machines(&req).unwrap_err().to_string();
5705 assert!(err.contains("replicas = 0"), "got: {err}");
5706 }
5707
5708 #[test]
5709 fn replicas_parses_from_the_inline_required_form() {
5710 // The INLINE form specifically: `[providers.bundle.required]` as a table
5711 // HEADER ends the slot's table and reparents every key below it.
5712 let slot: MirrorProviderSlot = toml::from_str(
5713 r#"
5714use = "hetzner-primary"
5715port = 8080
5716required = { regions = ["us-east"], mesh_tags = ["tag:cloud-runner"], replicas = 2 }
5717"#,
5718 )
5719 .unwrap();
5720 assert_eq!(
5721 slot.fields().get("port").and_then(|v| v.as_integer()),
5722 Some(8080),
5723 "the inline form leaves the slot's other keys where they were"
5724 );
5725 let req = slot.required().expect("required block present");
5726 assert_eq!(req.replicas, Some(2));
5727 assert_eq!(req.replica_count(), 2);
5728 // A count is not a match axis — it says how many, not which.
5729 assert!(!req.is_unconstrained());
5730 assert!(RequiredSpec {
5731 replicas: Some(2),
5732 ..Default::default()
5733 }
5734 .is_unconstrained());
5735 }
5736
5737 #[test]
5738 fn round_trip_machine() {
5739 let cfg = MachineConfig {
5740 name: "test-pdx-1".into(),
5741 provider: "hetzner".into(),
5742 location: Some("pdx".into()),
5743 server_type: Some("cpx22".into()),
5744 hosts_mirrors: vec!["noisetable".into()],
5745 mesh_tags: vec!["region:pdx".into()],
5746 region: Some("us-west".into()),
5747 zone: Some("pdx".into()),
5748 arch: None,
5749 bucket: Some(BucketSpec {
5750 name: "test-assets-pdx-1".into(),
5751 public_read: false,
5752 }),
5753 vendor: None,
5754 nickname: None,
5755 legacy_hostkey_fingerprint: None,
5756 registration: Default::default(),
5757 ssh_keys: vec![],
5758 cloudflared: None,
5759 hosts_operator_bridge: false,
5760 connect: None,
5761 allocatable: None,
5762 taints: vec![],
5763 sovereign_group: None,
5764 sovereign_role: None,
5765 ingress_floating_ip: None,
5766 };
5767 let s = toml::to_string(&cfg).unwrap();
5768 let back: MachineConfig = toml::from_str(&s).unwrap();
5769 assert_eq!(back.name, cfg.name);
5770 assert_eq!(back.location, cfg.location);
5771 assert_eq!(back.region.as_deref(), Some("us-west"));
5772 assert_eq!(back.zone.as_deref(), Some("pdx"));
5773 }
5774
5775 #[test]
5776 fn round_trip_mirror() {
5777 let cfg = LegacyMirrorConfig {
5778 camp: "noisetable".into(),
5779 regions: vec!["pdx".into(), "iad".into()],
5780 workloads: vec!["asset-registry".into()],
5781 cloud_domain: None,
5782 };
5783 let s = toml::to_string(&cfg).unwrap();
5784 let back: LegacyMirrorConfig = toml::from_str(&s).unwrap();
5785 assert_eq!(back.camp, cfg.camp);
5786 assert_eq!(back.regions, cfg.regions);
5787 assert_eq!(back.workloads, cfg.workloads);
5788 }
5789
5790 #[test]
5791 fn mirror_serialises_as_camp_key() {
5792 // Serialised form should use `camp`, not `rig`.
5793 let cfg = LegacyMirrorConfig {
5794 camp: "noisetable".into(),
5795 regions: vec!["pdx".into()],
5796 workloads: vec![],
5797 cloud_domain: None,
5798 };
5799 let s = toml::to_string(&cfg).unwrap();
5800 assert!(
5801 s.contains("camp = "),
5802 "serialised key should be 'camp': {s}"
5803 );
5804 assert!(!s.contains("rig = "), "old key should not appear: {s}");
5805 }
5806
5807 #[test]
5808 fn mirror_rig_alias_still_loads() {
5809 // Old mirrors/*.toml files use `rig = "..."` before the R137 rename;
5810 // the alias keeps them loading until the one-time `sed` migration runs.
5811 let toml_str =
5812 "rig = \"noisetable\"\nregions = [\"pdx\"]\nworkloads = [\"asset-registry\"]\n";
5813 let cfg: LegacyMirrorConfig = toml::from_str(toml_str).unwrap();
5814 assert_eq!(cfg.camp, "noisetable");
5815 }
5816
5817 #[test]
5818 fn mirror_services_alias_still_loads() {
5819 // Old mirrors/*.toml files use `services = [...]`; the alias keeps them
5820 // loading without a migration step.
5821 let toml_str =
5822 "camp = \"noisetable\"\nregions = [\"pdx\"]\nservices = [\"asset-registry\"]\n";
5823 let cfg: LegacyMirrorConfig = toml::from_str(toml_str).unwrap();
5824 assert_eq!(cfg.workloads, vec!["asset-registry"]);
5825 }
5826
5827 #[test]
5828 fn round_trip_service_legacy() {
5829 let cfg = LegacyServiceConfig {
5830 name: "asset-registry".into(),
5831 image: "ghcr.io/noisetable/asset-registry".into(),
5832 version: "v1.0.0".into(),
5833 env: HashMap::new(),
5834 ports: vec![PortMapping {
5835 host: 8080,
5836 container: 8080,
5837 }],
5838 mesh_only: false,
5839 bind_interface: None,
5840 tenant: TenantId::singleton(),
5841 };
5842 let s = toml::to_string(&cfg).unwrap();
5843 let back: LegacyServiceConfig = toml::from_str(&s).unwrap();
5844 assert_eq!(back.name, cfg.name);
5845 assert_eq!(back.image, cfg.image);
5846 }
5847
5848 #[test]
5849 fn service_bind_interface_round_trips() {
5850 let cfg = LegacyServiceConfig {
5851 name: "postgres".into(),
5852 image: "postgres".into(),
5853 version: "16".into(),
5854 env: HashMap::new(),
5855 ports: vec![PortMapping {
5856 host: 5432,
5857 container: 5432,
5858 }],
5859 mesh_only: true,
5860 bind_interface: Some("tailscale0".into()),
5861 tenant: TenantId::singleton(),
5862 };
5863 let s = toml::to_string(&cfg).unwrap();
5864 let back: LegacyServiceConfig = toml::from_str(&s).unwrap();
5865 assert_eq!(back.bind_interface.as_deref(), Some("tailscale0"));
5866 }
5867
5868 #[test]
5869 fn service_bind_interface_absent_is_none() {
5870 let toml_str = "name = \"app\"\nimage = \"app\"\nversion = \"v1\"\n";
5871 let cfg: LegacyServiceConfig = toml::from_str(toml_str).unwrap();
5872 assert!(
5873 cfg.bind_interface.is_none(),
5874 "bind_interface should default to None"
5875 );
5876 }
5877
5878 #[test]
5879 fn service_bind_interface_skipped_when_none() {
5880 let cfg = LegacyServiceConfig {
5881 name: "app".into(),
5882 image: "app".into(),
5883 version: "v1".into(),
5884 env: HashMap::new(),
5885 ports: vec![],
5886 mesh_only: false,
5887 bind_interface: None,
5888 tenant: TenantId::singleton(),
5889 };
5890 let s = toml::to_string(&cfg).unwrap();
5891 assert!(!s.contains("bind_interface"), "None should be skipped: {s}");
5892 }
5893
5894 #[test]
5895 fn load_dir_missing_is_empty() {
5896 let dir = std::path::PathBuf::from("/nonexistent/path");
5897 let result: Vec<MachineConfig> = load_dir(dir).unwrap();
5898 assert!(result.is_empty());
5899 }
5900
5901 #[test]
5902 fn topology_round_trip() {
5903 let topo = TopologyConfig {
5904 assignments: vec![
5905 MirrorAssignment {
5906 mirror: "noisetable-pdx".into(),
5907 machine: "noisetable-pdx-1".into(),
5908 },
5909 MirrorAssignment {
5910 mirror: "noisetable-iad".into(),
5911 machine: "noisetable-iad-1".into(),
5912 },
5913 ],
5914 buckets: vec![],
5915 };
5916 let s = toml::to_string(&topo).unwrap();
5917 let back: TopologyConfig = toml::from_str(&s).unwrap();
5918 assert_eq!(back.assignments.len(), 2);
5919 assert_eq!(back.assignments[0].mirror, "noisetable-pdx");
5920 assert_eq!(back.assignments[1].machine, "noisetable-iad-1");
5921 }
5922
5923 #[test]
5924 fn topology_absent_returns_default() {
5925 let tmp = tempfile::TempDir::new().unwrap();
5926 let path = tmp.path().join("topology.toml");
5927 // file doesn't exist
5928 let topo = load_topology(path).unwrap();
5929 assert!(topo.assignments.is_empty());
5930 }
5931
5932 /// Helper: lay out a `<workspace_root>/.yah/cloud/` legacy tree for the
5933 /// pre-R215 cargo tests below; returns the legacy cloud_dir for writes.
5934 fn make_legacy_cloud_dir(root: &std::path::Path) -> std::path::PathBuf {
5935 let cloud_dir = root.join(".yah").join("cloud");
5936 std::fs::create_dir_all(&cloud_dir).unwrap();
5937 cloud_dir
5938 }
5939
5940 #[test]
5941 fn cloud_config_load_and_lookup() {
5942 let tmp = tempfile::TempDir::new().unwrap();
5943 let root = tmp.path();
5944 let cloud_dir = make_legacy_cloud_dir(root);
5945
5946 let machine = MachineConfig {
5947 name: "noisetable-pdx-1".into(),
5948 provider: "hetzner".into(),
5949 location: Some("pdx".into()),
5950 server_type: Some("cpx22".into()),
5951 hosts_mirrors: vec!["noisetable".into(), "yah".into()],
5952 mesh_tags: vec!["region:pdx".into(), "tier:t2".into()],
5953 region: None,
5954 zone: None,
5955 arch: None,
5956 bucket: Some(BucketSpec {
5957 name: "noisetable-assets-pdx-1".into(),
5958 public_read: false,
5959 }),
5960 vendor: None,
5961 nickname: None,
5962 legacy_hostkey_fingerprint: None,
5963 registration: Default::default(),
5964 ssh_keys: vec![],
5965 cloudflared: None,
5966 hosts_operator_bridge: false,
5967 connect: None,
5968 allocatable: None,
5969 taints: vec![],
5970 sovereign_group: None,
5971 sovereign_role: None,
5972 ingress_floating_ip: None,
5973 };
5974 // Land in the legacy tree so the legacy machine loader picks it up.
5975 machine.save(&cloud_dir).unwrap();
5976
5977 let mirror_toml = "camp = \"noisetable\"\nregions = [\"pdx\", \"iad\", \"fsn\"]\nworkloads = [\"asset-registry\"]\n";
5978 std::fs::create_dir_all(cloud_dir.join("mirrors")).unwrap();
5979 std::fs::write(cloud_dir.join("mirrors/noisetable.toml"), mirror_toml).unwrap();
5980
5981 // Legacy services/ dir (backward compat)
5982 let svc_toml = "name = \"asset-registry\"\nimage = \"ghcr.io/noisetable/asset-registry\"\nversion = \"v1.0.0\"\nmesh_only = false\n";
5983 std::fs::create_dir_all(cloud_dir.join("services")).unwrap();
5984 std::fs::write(cloud_dir.join("services/asset-registry.toml"), svc_toml).unwrap();
5985
5986 let cfg = CloudConfig::load(root).unwrap();
5987
5988 assert_eq!(cfg.machines.len(), 1);
5989 assert_eq!(cfg.legacy_mirrors.len(), 1);
5990 assert_eq!(cfg.legacy_services.len(), 1);
5991 assert_eq!(cfg.workloads.len(), 0); // no workloads/ dir yet
5992 assert!(cfg.services.is_empty(), "no R215+ services/ tree");
5993 assert!(cfg.providers.is_empty(), "no R215+ providers/ tree");
5994
5995 let m = cfg.machine("noisetable-pdx-1").unwrap();
5996 assert_eq!(m.location(), "pdx");
5997 assert_eq!(m.bucket.as_ref().unwrap().name, "noisetable-assets-pdx-1");
5998
5999 let mir = cfg.legacy_mirror("noisetable").unwrap();
6000 assert_eq!(mir.regions, vec!["pdx", "iad", "fsn"]);
6001 assert_eq!(mir.workloads, vec!["asset-registry"]);
6002 }
6003
6004 #[test]
6005 fn mirror_folder_layout_loads() {
6006 // Folder layout: mirrors/<id>/mirror.toml — new preferred form.
6007 let tmp = tempfile::TempDir::new().unwrap();
6008 let root = tmp.path();
6009 let cloud_dir = make_legacy_cloud_dir(root);
6010 let mirror_dir = cloud_dir.join("mirrors").join("yah-com");
6011 std::fs::create_dir_all(&mirror_dir).unwrap();
6012 std::fs::write(
6013 mirror_dir.join("mirror.toml"),
6014 "camp = \"yah\"\nregions = [\"pdx\"]\nworkloads = [\"yah-web\"]\n",
6015 )
6016 .unwrap();
6017
6018 let cfg = CloudConfig::load(root).unwrap();
6019 assert_eq!(cfg.legacy_mirrors.len(), 1);
6020 let mir = cfg.legacy_mirror("yah").unwrap();
6021 assert_eq!(mir.camp, "yah");
6022 assert_eq!(mir.workloads, vec!["yah-web"]);
6023 }
6024
6025 #[test]
6026 fn mirror_folder_and_flat_coexist() {
6027 // Both layouts may coexist in the same mirrors/ directory.
6028 let tmp = tempfile::TempDir::new().unwrap();
6029 let root = tmp.path();
6030 let cloud_dir = make_legacy_cloud_dir(root);
6031 let mirrors_root = cloud_dir.join("mirrors");
6032 std::fs::create_dir_all(&mirrors_root).unwrap();
6033
6034 // Flat legacy mirror
6035 std::fs::write(
6036 mirrors_root.join("noisetable.toml"),
6037 "camp = \"noisetable\"\nregions = [\"pdx\"]\nworkloads = []\n",
6038 )
6039 .unwrap();
6040
6041 // Folder-form mirror
6042 let yah_com_dir = mirrors_root.join("yah-com");
6043 std::fs::create_dir_all(&yah_com_dir).unwrap();
6044 std::fs::write(
6045 yah_com_dir.join("mirror.toml"),
6046 "camp = \"yah\"\nregions = [\"pdx\"]\nworkloads = []\n",
6047 )
6048 .unwrap();
6049
6050 let cfg = CloudConfig::load(root).unwrap();
6051 assert_eq!(cfg.legacy_mirrors.len(), 2);
6052 assert!(cfg.legacy_mirror("noisetable").is_some());
6053 assert!(cfg.legacy_mirror("yah").is_some());
6054 }
6055
6056 #[test]
6057 fn mirror_malformed_fails_with_field_path() {
6058 // A malformed mirror.toml should fail at load with a clear error
6059 // that includes the file path.
6060 let tmp = tempfile::TempDir::new().unwrap();
6061 let root = tmp.path();
6062 let cloud_dir = make_legacy_cloud_dir(root);
6063 let mirror_dir = cloud_dir.join("mirrors").join("bad");
6064 std::fs::create_dir_all(&mirror_dir).unwrap();
6065 // Missing required `camp` field
6066 std::fs::write(
6067 mirror_dir.join("mirror.toml"),
6068 "regions = [\"pdx\"]\nworkloads = []\n",
6069 )
6070 .unwrap();
6071
6072 let err = CloudConfig::load(root).unwrap_err();
6073 let msg = err.to_string();
6074 assert!(
6075 msg.contains("mirror.toml"),
6076 "error should reference the file path, got: {msg}"
6077 );
6078 }
6079
6080 #[test]
6081 fn workload_config_load_and_validate() {
6082 use workload_spec::{
6083 ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
6084 RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
6085 };
6086
6087 let tmp = tempfile::TempDir::new().unwrap();
6088 let root = tmp.path();
6089 let cloud_dir = make_legacy_cloud_dir(root);
6090 std::fs::create_dir_all(cloud_dir.join("workloads")).unwrap();
6091
6092 let spec = WorkloadSpec {
6093 schema_version: SchemaVersion::V1,
6094 name: "asset-registry".into(),
6095 image: ImageRef {
6096 registry: "ghcr.io".into(),
6097 repository: "noisetable/asset-registry".into(),
6098 tag: "v1.0.0".into(),
6099 digest: workload_spec::testing::test_digest(),
6100 },
6101 tier: TierTag("tenant".into()),
6102 replicas: 1,
6103 command: None,
6104 entrypoint: None,
6105 workdir: None,
6106 user: None,
6107 env: vec![],
6108 secrets: vec![],
6109 volumes: vec![],
6110 resources: ResourceLimits {
6111 memory_mb: 256,
6112 cpu_millis: 512,
6113 ephemeral_storage_mb: 512,
6114 },
6115 depends_on: vec![],
6116 requires: vec![],
6117 healthcheck: None,
6118 restart_policy: RestartPolicy::Always,
6119 archetype: None,
6120 stop_policy: StopPolicy {
6121 signal: 15,
6122 grace_period: workload_spec::Millis::from_secs(10),
6123 },
6124 expose: ExposeSpec {
6125 mesh: MeshExpose {
6126 identity: MeshIdent("asset-registry.pdx".into()),
6127 ports: MeshExpose::anonymous_ports([8080]),
6128 allow_from: vec![],
6129 },
6130 public: None,
6131 operator: None,
6132 },
6133 tenant: TenantId::singleton(),
6134 namespace: NamespaceId::singleton(),
6135 labels: Default::default(),
6136 annotations: Default::default(),
6137 };
6138
6139 let toml_str = toml::to_string_pretty(&spec).unwrap();
6140 std::fs::write(cloud_dir.join("workloads/asset-registry.toml"), &toml_str).unwrap();
6141
6142 let cfg = CloudConfig::load(root).unwrap();
6143 assert_eq!(cfg.workloads.len(), 1);
6144 assert_eq!(cfg.workloads[0].spec.name, "asset-registry");
6145 assert_eq!(cfg.workload("asset-registry").unwrap().spec.replicas, 1);
6146 }
6147
6148 /// Minimal valid spec for the R215+ loader tests below. Kept as a helper so
6149 /// the two tests differ only in *where* the file lands, which is the whole
6150 /// thing under test.
6151 #[cfg(test)]
6152 fn minimal_spec(name: &str, replicas: u32) -> workload_spec::WorkloadSpec {
6153 use workload_spec::{
6154 ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
6155 RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
6156 };
6157 WorkloadSpec {
6158 schema_version: SchemaVersion::V1,
6159 name: name.into(),
6160 image: ImageRef {
6161 registry: "cr.yah.dev".into(),
6162 repository: name.into(),
6163 tag: "v1".into(),
6164 digest: workload_spec::testing::test_digest(),
6165 },
6166 tier: TierTag("infra".into()),
6167 replicas,
6168 command: None,
6169 entrypoint: None,
6170 workdir: None,
6171 user: None,
6172 env: vec![],
6173 secrets: vec![],
6174 volumes: vec![],
6175 resources: ResourceLimits {
6176 memory_mb: 256,
6177 cpu_millis: 250,
6178 ephemeral_storage_mb: 128,
6179 },
6180 depends_on: vec![],
6181 requires: vec![],
6182 healthcheck: None,
6183 restart_policy: RestartPolicy::Always,
6184 archetype: None,
6185 stop_policy: StopPolicy {
6186 signal: 15,
6187 grace_period: workload_spec::Millis::from_secs(10),
6188 },
6189 expose: ExposeSpec {
6190 mesh: MeshExpose {
6191 identity: MeshIdent(name.into()),
6192 ports: MeshExpose::anonymous_ports([4325]),
6193 allow_from: vec![],
6194 },
6195 public: None,
6196 operator: None,
6197 },
6198 tenant: TenantId::singleton(),
6199 namespace: NamespaceId::singleton(),
6200 labels: Default::default(),
6201 annotations: Default::default(),
6202 }
6203 }
6204
6205 /// R568-T7. Workloads must load from the R215+ tree.
6206 ///
6207 /// Before the fix this function tested, `CloudConfig::load` read workloads
6208 /// ONLY from the pre-R215 `.yah/cloud/workloads/` — which R222-B1 emptied —
6209 /// so in any modern camp `cfg.workload(name)` returned `None` for every
6210 /// name and the entire `yah cloud workload …` surface was unreachable. The
6211 /// CLI's own error text has said `.yah/infra/workloads/` throughout, so the
6212 /// bug read as "you must have typoed the filename".
6213 ///
6214 /// Note the fixture writes NO legacy `.yah/cloud/` dir at all: that is the
6215 /// shape of a real post-R215 camp, and it is exactly the shape the old code
6216 /// could not serve.
6217 #[test]
6218 fn workloads_load_from_the_infra_tree() {
6219 let tmp = tempfile::TempDir::new().unwrap();
6220 let root = tmp.path();
6221 let dir = crate::paths::workloads_dir(root);
6222 std::fs::create_dir_all(&dir).unwrap();
6223 std::fs::write(
6224 dir.join("yah-cloud-admin.toml"),
6225 toml::to_string_pretty(&minimal_spec("yah-cloud-admin", 1)).unwrap(),
6226 )
6227 .unwrap();
6228
6229 let cfg = CloudConfig::load(root).unwrap();
6230 assert_eq!(cfg.workloads.len(), 1);
6231 assert_eq!(
6232 cfg.workload("yah-cloud-admin").unwrap().spec.replicas,
6233 1,
6234 "a workload declared under .yah/infra/workloads/ must be resolvable by name"
6235 );
6236 }
6237
6238 /// A camp mid-migration can have both trees. R215+ wins on a name
6239 /// collision — same precedence the machine loader applies — so moving a
6240 /// declaration into `.yah/infra/workloads/` takes effect immediately
6241 /// instead of being silently shadowed by the copy left behind.
6242 #[test]
6243 fn infra_workload_shadows_the_legacy_copy_of_the_same_name() {
6244 let tmp = tempfile::TempDir::new().unwrap();
6245 let root = tmp.path();
6246
6247 let legacy = make_legacy_cloud_dir(root);
6248 std::fs::create_dir_all(legacy.join("workloads")).unwrap();
6249 std::fs::write(
6250 legacy.join("workloads/shared.toml"),
6251 toml::to_string_pretty(&minimal_spec("shared", 9)).unwrap(),
6252 )
6253 .unwrap();
6254 // Legacy-only name, to prove the old tree is still read rather than
6255 // replaced wholesale.
6256 std::fs::write(
6257 legacy.join("workloads/legacy-only.toml"),
6258 toml::to_string_pretty(&minimal_spec("legacy-only", 3)).unwrap(),
6259 )
6260 .unwrap();
6261
6262 let infra = crate::paths::workloads_dir(root);
6263 std::fs::create_dir_all(&infra).unwrap();
6264 std::fs::write(
6265 infra.join("shared.toml"),
6266 toml::to_string_pretty(&minimal_spec("shared", 1)).unwrap(),
6267 )
6268 .unwrap();
6269
6270 let cfg = CloudConfig::load(root).unwrap();
6271 assert_eq!(cfg.workloads.len(), 2, "one `shared`, plus `legacy-only`");
6272 assert_eq!(
6273 cfg.workload("shared").unwrap().spec.replicas,
6274 1,
6275 "the .yah/infra/ copy must win over the legacy one"
6276 );
6277 assert_eq!(cfg.workload("legacy-only").unwrap().spec.replicas, 3);
6278 }
6279
6280 #[test]
6281 fn workload_loader_rejects_bad_spec() {
6282 use workload_spec::{
6283 ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
6284 RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
6285 };
6286
6287 let tmp = tempfile::TempDir::new().unwrap();
6288 let root = tmp.path();
6289 let cloud_dir = make_legacy_cloud_dir(root);
6290 std::fs::create_dir_all(cloud_dir.join("workloads")).unwrap();
6291
6292 // Construct a spec that round-trips through TOML but fails shape
6293 // validation: replicas = 200 is above the max of 100.
6294 let mut spec = WorkloadSpec {
6295 schema_version: SchemaVersion::V1,
6296 name: "asset-registry".into(),
6297 image: ImageRef {
6298 registry: "ghcr.io".into(),
6299 repository: "test/app".into(),
6300 tag: "v1".into(),
6301 digest: workload_spec::testing::test_digest(),
6302 },
6303 tier: TierTag("tenant".into()),
6304 replicas: 200, // ← invalid: exceeds max 100
6305 command: None,
6306 entrypoint: None,
6307 workdir: None,
6308 user: None,
6309 env: vec![],
6310 secrets: vec![],
6311 volumes: vec![],
6312 resources: ResourceLimits {
6313 memory_mb: 256,
6314 cpu_millis: 512,
6315 ephemeral_storage_mb: 512,
6316 },
6317 depends_on: vec![],
6318 requires: vec![],
6319 healthcheck: None,
6320 restart_policy: RestartPolicy::Always,
6321 archetype: None,
6322 stop_policy: StopPolicy {
6323 signal: 15,
6324 grace_period: workload_spec::Millis::from_secs(10),
6325 },
6326 expose: ExposeSpec {
6327 mesh: MeshExpose {
6328 identity: MeshIdent("asset-registry.pdx".into()),
6329 ports: MeshExpose::anonymous_ports([8080]),
6330 allow_from: vec![],
6331 },
6332 public: None,
6333 operator: None,
6334 },
6335 tenant: TenantId::singleton(),
6336 namespace: NamespaceId::singleton(),
6337 labels: Default::default(),
6338 annotations: Default::default(),
6339 };
6340
6341 let toml_str = toml::to_string_pretty(&spec).unwrap();
6342 std::fs::write(cloud_dir.join("workloads/bad.toml"), &toml_str).unwrap();
6343
6344 let result = CloudConfig::load(root);
6345 assert!(
6346 result.is_err(),
6347 "loading a WorkloadSpec with replicas=200 should return Err"
6348 );
6349 let msg = result.unwrap_err().to_string();
6350 assert!(
6351 msg.contains("shape validation")
6352 || msg.contains("Replicas")
6353 || msg.contains("replicas"),
6354 "error should mention shape validation or replicas field, got: {msg}"
6355 );
6356
6357 // The `spec` binding is only used for the write — suppress warning.
6358 let _ = &mut spec;
6359 }
6360
6361 #[test]
6362 fn workload_config_save_round_trip() {
6363 use workload_spec::{
6364 ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
6365 RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
6366 };
6367
6368 let tmp = tempfile::TempDir::new().unwrap();
6369 let root = tmp.path();
6370
6371 let spec = WorkloadSpec {
6372 schema_version: SchemaVersion::V1,
6373 name: "signing-service".into(),
6374 image: ImageRef {
6375 registry: "ghcr.io".into(),
6376 repository: "noisetable/signing".into(),
6377 tag: "v2.0.0".into(),
6378 digest: workload_spec::testing::test_digest(),
6379 },
6380 tier: TierTag("private".into()),
6381 replicas: 2,
6382 command: None,
6383 entrypoint: None,
6384 workdir: None,
6385 user: None,
6386 env: vec![],
6387 secrets: vec![],
6388 volumes: vec![],
6389 resources: ResourceLimits {
6390 memory_mb: 128,
6391 cpu_millis: 256,
6392 ephemeral_storage_mb: 256,
6393 },
6394 depends_on: vec![],
6395 requires: vec![],
6396 healthcheck: None,
6397 restart_policy: RestartPolicy::Always,
6398 archetype: None,
6399 stop_policy: StopPolicy {
6400 signal: 15,
6401 grace_period: workload_spec::Millis::from_secs(5),
6402 },
6403 expose: ExposeSpec {
6404 mesh: MeshExpose {
6405 identity: MeshIdent("signing.pdx".into()),
6406 ports: MeshExpose::anonymous_ports([9090]),
6407 allow_from: vec![],
6408 },
6409 public: None,
6410 operator: None,
6411 },
6412 tenant: TenantId::singleton(),
6413 namespace: NamespaceId::singleton(),
6414 labels: Default::default(),
6415 annotations: Default::default(),
6416 };
6417
6418 let wc = WorkloadConfig { spec };
6419 let cloud_dir = make_legacy_cloud_dir(root);
6420 wc.save(&cloud_dir).unwrap();
6421
6422 let loaded = CloudConfig::load(root).unwrap();
6423 assert_eq!(loaded.workloads.len(), 1);
6424 assert_eq!(loaded.workloads[0].spec.name, "signing-service");
6425 assert_eq!(loaded.workloads[0].spec.replicas, 2);
6426 }
6427
6428 #[test]
6429 fn machine_save_write_back_fingerprint() {
6430 let tmp = tempfile::TempDir::new().unwrap();
6431 let root = tmp.path();
6432
6433 let mut machine = MachineConfig {
6434 name: "test-pdx-1".into(),
6435 provider: "hetzner".into(),
6436 location: Some("pdx".into()),
6437 server_type: Some("cpx22".into()),
6438 hosts_mirrors: vec![],
6439 mesh_tags: vec![],
6440 region: None,
6441 zone: None,
6442 arch: None,
6443 bucket: None,
6444 vendor: None,
6445 nickname: None,
6446 legacy_hostkey_fingerprint: None,
6447 registration: Default::default(),
6448 ssh_keys: vec![],
6449 cloudflared: None,
6450 hosts_operator_bridge: false,
6451 connect: None,
6452 allocatable: None,
6453 taints: vec![],
6454 sovereign_group: None,
6455 sovereign_role: None,
6456 ingress_floating_ip: None,
6457 };
6458 machine.save(root).unwrap();
6459
6460 // Simulate A4: write back the hostkey fingerprint after provision.
6461 // R707-T1: registration is the write target; the accessor is the read.
6462 machine.registration.hostkey_fingerprint = Some("SHA256:abc123".into());
6463 machine.save(root).unwrap();
6464
6465 let reloaded: Vec<MachineConfig> = load_dir(root.join("machines")).unwrap();
6466 assert_eq!(reloaded.len(), 1);
6467 assert_eq!(reloaded[0].hostkey_fingerprint(), Some("SHA256:abc123"));
6468 }
6469
6470 // ─── New-shape (R222 B2) parse tests ────────────────────────────────────
6471 //
6472 // These mirror the Phase-A manifests committed under `.yah/services/` and
6473 // `.yah/infra/providers/`. Keeping the test strings inline (rather than
6474 // reading the on-disk files) so the loader stays runnable in any workdir
6475 // and so accidental edits to the on-disk files don't silently change
6476 // schema expectations.
6477
6478 #[test]
6479 fn provider_cloudflare_round_trips() {
6480 let src = r#"
6481schema_version = 1
6482id = "cloudflare"
6483kind = "cloudflare"
6484credentials = "keystore://cloudflare/yah"
6485default_zone = "yah.dev"
6486"#;
6487 let cfg: ProviderConfig = toml::from_str(src).unwrap();
6488 assert_eq!(cfg.id, "cloudflare");
6489 assert_eq!(cfg.kind, Provider::Cloudflare);
6490 assert_eq!(
6491 cfg.credentials.as_deref(),
6492 Some("keystore://cloudflare/yah")
6493 );
6494 assert_eq!(
6495 cfg.fields.get("default_zone").and_then(|v| v.as_str()),
6496 Some("yah.dev"),
6497 );
6498 let back = toml::to_string(&cfg).unwrap();
6499 let again: ProviderConfig = toml::from_str(&back).unwrap();
6500 assert_eq!(again.id, cfg.id);
6501 assert_eq!(again.kind, cfg.kind);
6502 }
6503
6504 #[test]
6505 fn provider_hetzner_round_trips() {
6506 let src = r#"
6507schema_version = 1
6508id = "hetzner"
6509kind = "hetzner"
6510credentials = "keystore://hetzner/yah"
6511default_location = "pdx"
6512default_server_type = "cpx11"
6513ssh_keys = []
6514"#;
6515 let cfg: ProviderConfig = toml::from_str(src).unwrap();
6516 assert_eq!(cfg.kind, Provider::Hetzner);
6517 assert_eq!(
6518 cfg.fields.get("default_location").and_then(|v| v.as_str()),
6519 Some("pdx"),
6520 );
6521 assert!(
6522 cfg.fields
6523 .get("ssh_keys")
6524 .map(|v| v.as_array().unwrap().is_empty())
6525 .unwrap_or(false),
6526 "ssh_keys must round-trip as empty array, got {:?}",
6527 cfg.fields.get("ssh_keys"),
6528 );
6529 }
6530
6531 #[test]
6532 fn provider_orbstack_local_container_round_trips() {
6533 let src = r#"
6534schema_version = 1
6535id = "orbstack"
6536kind = "local-container"
6537runtime = "auto"
6538
6539[discovery]
6540orbstack = "~/.orbstack/run/docker.sock"
6541colima = "~/.colima/default/docker.sock"
6542docker = "/var/run/docker.sock"
6543"#;
6544 let cfg: ProviderConfig = toml::from_str(src).unwrap();
6545 assert_eq!(cfg.kind, Provider::LocalContainer);
6546 assert_eq!(
6547 cfg.fields.get("runtime").and_then(|v| v.as_str()),
6548 Some("auto"),
6549 );
6550 let discovery = cfg
6551 .fields
6552 .get("discovery")
6553 .and_then(|v| v.as_table())
6554 .expect("discovery table");
6555 assert!(discovery.contains_key("orbstack"));
6556 assert!(discovery.contains_key("colima"));
6557 assert!(discovery.contains_key("docker"));
6558 }
6559
6560 #[test]
6561 fn provider_unknown_kind_fails() {
6562 let src = r#"
6563schema_version = 1
6564id = "made-up"
6565kind = "fly-io"
6566"#;
6567 let err = toml::from_str::<ProviderConfig>(src).unwrap_err();
6568 let msg = err.to_string();
6569 assert!(
6570 msg.contains("kind") || msg.contains("variant"),
6571 "unknown provider kind should surface as a serde error, got: {msg}"
6572 );
6573 }
6574
6575 #[test]
6576 fn service_dev_yah_round_trips() {
6577 let src = r#"
6578schema_version = 1
6579name = "dev-yah"
6580domain = "yah.dev"
6581
6582[[components]]
6583id = "site"
6584kind = "mesofact-static"
6585path = "app/yah/web"
6586role = "static"
6587"#;
6588 let cfg: ServiceConfig = toml::from_str(src).unwrap();
6589 assert_eq!(cfg.name, "dev-yah");
6590 assert_eq!(cfg.domain, "yah.dev");
6591 assert_eq!(cfg.components.len(), 1);
6592 let c = &cfg.components[0];
6593 assert_eq!(c.id, "site");
6594 assert_eq!(c.kind, "mesofact-static");
6595 assert_eq!(c.path, "app/yah/web");
6596 assert_eq!(c.role, "static");
6597 assert!(c.publishes.is_none());
6598
6599 let back = toml::to_string(&cfg).unwrap();
6600 let again: ServiceConfig = toml::from_str(&back).unwrap();
6601 assert_eq!(again.name, cfg.name);
6602 assert_eq!(again.components[0].kind, c.kind);
6603 }
6604
6605 #[test]
6606 fn mirror_prod_cloudflare_reference_parses() {
6607 let src = r#"
6608schema_version = 1
6609shape = "single-machine"
6610
6611[providers.static]
6612use = "cloudflare"
6613bucket = "yah-dev"
6614zone = "yah.dev"
6615dns = { record = "@", type = "CNAME" }
6616"#;
6617 let cfg: MirrorConfig = toml::from_str(src).unwrap();
6618 assert_eq!(cfg.shape, MirrorShape::SingleMachine);
6619 let slot = cfg.providers.get("static").expect("static slot");
6620 assert_eq!(slot.provider_id(), Some("cloudflare"));
6621 assert!(slot.inline_kind().is_none());
6622 if let MirrorProviderSlot::Reference { fields, .. } = slot {
6623 assert_eq!(
6624 fields.get("bucket").and_then(|v| v.as_str()),
6625 Some("yah-dev")
6626 );
6627 assert_eq!(fields.get("zone").and_then(|v| v.as_str()), Some("yah.dev"));
6628 let dns = fields
6629 .get("dns")
6630 .and_then(|v| v.as_table())
6631 .expect("dns table");
6632 assert_eq!(dns.get("record").and_then(|v| v.as_str()), Some("@"));
6633 assert_eq!(dns.get("type").and_then(|v| v.as_str()), Some("CNAME"));
6634 } else {
6635 panic!("expected Reference slot");
6636 }
6637 }
6638
6639 #[test]
6640 fn mirror_local_inline_static_and_orbstack_compute_parse() {
6641 let src = r#"
6642schema_version = 1
6643shape = "local"
6644
6645[providers.static]
6646kind = "local-static"
6647port = 4321
6648artifact_dir = ".yah/infra/state/local/static"
6649
6650[providers.compute]
6651use = "orbstack"
6652"#;
6653 let cfg: MirrorConfig = toml::from_str(src).unwrap();
6654 assert_eq!(cfg.shape, MirrorShape::Local);
6655
6656 let static_slot = cfg.providers.get("static").expect("static slot");
6657 assert_eq!(static_slot.inline_kind(), Some(Provider::LocalStatic));
6658 assert!(static_slot.provider_id().is_none());
6659 if let MirrorProviderSlot::Inline { fields, .. } = static_slot {
6660 assert_eq!(fields.get("port").and_then(|v| v.as_integer()), Some(4321));
6661 assert_eq!(
6662 fields.get("artifact_dir").and_then(|v| v.as_str()),
6663 Some(".yah/infra/state/local/static"),
6664 );
6665 } else {
6666 panic!("expected Inline slot for static");
6667 }
6668
6669 let compute_slot = cfg.providers.get("compute").expect("compute slot");
6670 assert_eq!(compute_slot.provider_id(), Some("orbstack"));
6671 }
6672
6673 #[test]
6674 fn mirror_pond_miniflare_minio_parse() {
6675 // pond-tier mirror: miniflare-container + minio, both inline.
6676 // T1 just needs these inline kinds to parse — the reconciler dispatch
6677 // arrives in R256-T3.
6678 let src = r#"
6679schema_version = 1
6680shape = "local"
6681
6682[providers.static]
6683kind = "miniflare-container"
6684port = 4322
6685bucket = "yah-dev"
6686
6687[providers.object_store]
6688kind = "minio-container"
6689api_port = 9000
6690console_port = 9001
6691bucket = "yah-dev"
6692"#;
6693 let cfg: MirrorConfig = toml::from_str(src).unwrap();
6694 assert_eq!(cfg.shape, MirrorShape::Local);
6695
6696 let static_slot = cfg.providers.get("static").expect("static slot");
6697 assert_eq!(
6698 static_slot.inline_kind(),
6699 Some(Provider::MiniflareContainer)
6700 );
6701 if let MirrorProviderSlot::Inline { fields, .. } = static_slot {
6702 assert_eq!(fields.get("port").and_then(|v| v.as_integer()), Some(4322));
6703 assert_eq!(
6704 fields.get("bucket").and_then(|v| v.as_str()),
6705 Some("yah-dev")
6706 );
6707 } else {
6708 panic!("expected Inline slot for miniflare-container static");
6709 }
6710
6711 let object_store_slot = cfg
6712 .providers
6713 .get("object_store")
6714 .expect("object_store slot");
6715 assert_eq!(
6716 object_store_slot.inline_kind(),
6717 Some(Provider::MinioContainer)
6718 );
6719 if let MirrorProviderSlot::Inline { fields, .. } = object_store_slot {
6720 assert_eq!(
6721 fields.get("api_port").and_then(|v| v.as_integer()),
6722 Some(9000)
6723 );
6724 assert_eq!(
6725 fields.get("console_port").and_then(|v| v.as_integer()),
6726 Some(9001)
6727 );
6728 assert_eq!(
6729 fields.get("bucket").and_then(|v| v.as_str()),
6730 Some("yah-dev")
6731 );
6732 } else {
6733 panic!("expected Inline slot for minio-container object_store");
6734 }
6735 }
6736
6737 #[test]
6738 fn provider_miniflare_container_kind_round_trips() {
6739 // Inline-only kind; never declared as a standalone provider file but
6740 // the enum round-trip is still exercised through ProviderConfig because
6741 // schemars/serde share the variant table.
6742 let cfg = MirrorProviderSlot::Inline {
6743 kind: Provider::MiniflareContainer,
6744 fields: BTreeMap::new(),
6745 };
6746 let s = toml::to_string(&cfg).unwrap();
6747 assert!(
6748 s.contains("kind = \"miniflare-container\""),
6749 "kebab-case wire form expected, got: {s}"
6750 );
6751 let back: MirrorProviderSlot = toml::from_str(&s).unwrap();
6752 assert_eq!(back.inline_kind(), Some(Provider::MiniflareContainer));
6753 }
6754
6755 #[test]
6756 fn provider_minio_container_kind_round_trips() {
6757 let cfg = MirrorProviderSlot::Inline {
6758 kind: Provider::MinioContainer,
6759 fields: BTreeMap::new(),
6760 };
6761 let s = toml::to_string(&cfg).unwrap();
6762 assert!(
6763 s.contains("kind = \"minio-container\""),
6764 "kebab-case wire form expected, got: {s}"
6765 );
6766 let back: MirrorProviderSlot = toml::from_str(&s).unwrap();
6767 assert_eq!(back.inline_kind(), Some(Provider::MinioContainer));
6768 }
6769
6770 #[test]
6771 fn mirror_compute_slot_with_machine_reference_parses() {
6772 // The on-disk prod.toml has a commented-out compute slot; this test
6773 // covers the form Phase B will need once yubaba is provisioned.
6774 let src = r#"
6775schema_version = 1
6776shape = "single-machine"
6777
6778[providers.compute]
6779use = "hetzner"
6780machine = "yah-cloud-1"
6781"#;
6782 let cfg: MirrorConfig = toml::from_str(src).unwrap();
6783 let slot = cfg.providers.get("compute").expect("compute slot");
6784 assert_eq!(slot.provider_id(), Some("hetzner"));
6785 if let MirrorProviderSlot::Reference { fields, .. } = slot {
6786 assert_eq!(
6787 fields.get("machine").and_then(|v| v.as_str()),
6788 Some("yah-cloud-1"),
6789 );
6790 }
6791 }
6792
6793 #[test]
6794 fn machine_yah_cloud_1_round_trips_with_existing_shape() {
6795 // The current machine TOML predates B2 — MachineConfig hasn't been
6796 // reshaped yet. This locks the expected shape so we notice if B3
6797 // accidentally regresses it.
6798 let src = r#"
6799name = "yah-cloud-1"
6800provider = "hetzner"
6801location = "pdx"
6802server_type = "cpx11"
6803hosts_mirrors = []
6804mesh_tags = ["tag:tier-scratch", "tag:primary-yah"]
6805ssh_keys = [111513970, 111525493]
6806"#;
6807 let cfg: MachineConfig = toml::from_str(src).unwrap();
6808 assert_eq!(cfg.name, "yah-cloud-1");
6809 assert_eq!(cfg.provider, "hetzner");
6810 assert_eq!(cfg.ssh_keys.len(), 2);
6811 }
6812
6813 #[test]
6814 fn static_node_omits_location_server_type_and_carries_connect() {
6815 // BYO Phase-0: a `static` node we brought up over SSH has no provider
6816 // DC code or SKU; it declares reach in `[connect]` instead. Must load.
6817 let src = r#"
6818name = "us-south-001"
6819provider = "static"
6820region = "us-south"
6821mesh_tags = ["tag:cloud-runner", "tag:voter-candidate"]
6822
6823[connect]
6824address = "45.32.194.254"
6825ssh = "root@45.32.194.254"
6826yubaba = "http://127.0.0.1:7443"
6827arch = "x86_64"
6828"#;
6829 let cfg: MachineConfig = toml::from_str(src).unwrap();
6830 assert_eq!(cfg.provider, "static");
6831 assert!(cfg.location.is_none());
6832 assert!(cfg.server_type.is_none());
6833 assert_eq!(cfg.location(), ""); // accessor defaults empty
6834 let c = cfg.connect.as_ref().expect("connect block");
6835 assert_eq!(c.ssh, "root@45.32.194.254");
6836 // Loopback is a *declared* reach placeholder, so it stays in [connect]
6837 // verbatim and composes straight through (R707-T1).
6838 assert_eq!(c.yubaba.as_deref(), Some("http://127.0.0.1:7443"));
6839 assert_eq!(cfg.yubaba_url().as_deref(), Some("http://127.0.0.1:7443"));
6840 assert_eq!(cfg.mesh_ipv4(), None);
6841 // Static providers have no driver, so validate() is a no-op pass.
6842 assert!(!provider_has_machine_driver(&cfg.provider));
6843 cfg.validate().unwrap();
6844 }
6845
6846 // ─── R707-T1: declaration / registration split ──────────────────────────
6847
6848 /// The pre-split shape — top-level `hostkey_fingerprint`, mesh IP baked
6849 /// into `[connect].yubaba` — must keep parsing, and must read back through
6850 /// the accessors identically. Every machine TOML in the fleet was written
6851 /// this way, and other camps' inventories still are.
6852 #[test]
6853 fn legacy_shape_still_parses_and_reads_through_accessors() {
6854 let src = r#"
6855name = "us-west-001"
6856provider = "static"
6857region = "us-west"
6858arch = "x86_64"
6859mesh_tags = ["tag:cloud-runner"]
6860hostkey_fingerprint = "SHA256:dmpq"
6861
6862[connect]
6863address = "15.204.89.240"
6864ssh = "debian@15.204.89.240"
6865yubaba = "http://100.64.0.1:7443"
6866"#;
6867 let cfg: MachineConfig = toml::from_str(src).unwrap();
6868 assert_eq!(cfg.hostkey_fingerprint(), Some("SHA256:dmpq"));
6869 assert_eq!(cfg.mesh_ipv4(), Some("100.64.0.1"));
6870 assert_eq!(cfg.yubaba_url().as_deref(), Some("http://100.64.0.1:7443"));
6871 }
6872
6873 /// The post-split shape reads identically to the legacy one above — same
6874 /// three accessor answers from a file that separates the two halves. This
6875 /// is the "unchanged in meaning" guarantee the fleet migration rests on.
6876 #[test]
6877 fn split_shape_is_equivalent_to_legacy_shape() {
6878 let legacy = r#"
6879name = "m"
6880provider = "static"
6881mesh_tags = []
6882hostkey_fingerprint = "SHA256:dmpq"
6883
6884[connect]
6885address = "15.204.89.240"
6886ssh = "debian@15.204.89.240"
6887yubaba = "http://100.64.0.1:7443"
6888"#;
6889 let split = r#"
6890name = "m"
6891provider = "static"
6892mesh_tags = []
6893
6894[connect]
6895address = "15.204.89.240"
6896ssh = "debian@15.204.89.240"
6897
6898[registration]
6899hostkey_fingerprint = "SHA256:dmpq"
6900mesh_ipv4 = "100.64.0.1"
6901"#;
6902 let old: MachineConfig = toml::from_str(legacy).unwrap();
6903 let new: MachineConfig = toml::from_str(split).unwrap();
6904 assert_eq!(old.hostkey_fingerprint(), new.hostkey_fingerprint());
6905 assert_eq!(old.mesh_ipv4(), new.mesh_ipv4());
6906 assert_eq!(old.yubaba_url(), new.yubaba_url());
6907 }
6908
6909 /// A non-default `[connect].yubaba_port` is declared reach and composes
6910 /// with the observed mesh address rather than being pinned into a URL.
6911 #[test]
6912 fn declared_port_composes_with_observed_mesh_address() {
6913 let src = r#"
6914name = "m"
6915provider = "static"
6916mesh_tags = []
6917
6918[connect]
6919address = "10.0.0.1"
6920ssh = "yah@10.0.0.1"
6921yubaba_port = 9443
6922
6923[registration]
6924mesh_ipv4 = "100.64.0.9"
6925"#;
6926 let cfg: MachineConfig = toml::from_str(src).unwrap();
6927 assert_eq!(cfg.connect.as_ref().unwrap().yubaba_port(), 9443);
6928 assert_eq!(cfg.yubaba_url().as_deref(), Some("http://100.64.0.9:9443"));
6929 }
6930
6931 /// R605-T10 inverts R707-T6 for the private-literal case, and this is the
6932 /// node it was inverted for: us-west-014's shape, mesh-joined AND declaring
6933 /// a LAN `[connect].yubaba`. R707-T6 made the literal win outright so
6934 /// `rollout::yubaba::membership_to_nodes` could match the dev group's
6935 /// LAN-addressed raft membership — which fused identity into reach and made
6936 /// every automated dial go to an address only bldg-2506 can route.
6937 /// `lan_endpoint()` now serves that match, so the mesh address wins the
6938 /// dial and the literal is inert.
6939 #[test]
6940 fn a_private_literal_loses_to_the_registered_mesh_address() {
6941 let src = r#"
6942name = "us-west-014"
6943provider = "static"
6944mesh_tags = []
6945
6946[connect]
6947address = "192.168.10.14"
6948ssh = "yah@192.168.10.14"
6949yubaba = "http://192.168.10.14:7443"
6950
6951[registration]
6952mesh_ipv4 = "100.64.0.6"
6953"#;
6954 let cfg: MachineConfig = toml::from_str(src).unwrap();
6955 assert_eq!(cfg.mesh_ipv4(), Some("100.64.0.6"), "still mesh-joined");
6956 assert_eq!(
6957 cfg.yubaba_url().as_deref(),
6958 Some("http://100.64.0.6:7443"),
6959 "automation dials the mesh, never the LAN literal"
6960 );
6961 assert_eq!(
6962 cfg.lan_endpoint().as_deref(),
6963 Some("192.168.10.14:7443"),
6964 "the LAN address is still recorded — as identity, not as reach"
6965 );
6966 }
6967
6968 /// The refusal R605-T10 asks for: a node whose ONLY declared reach is a LAN
6969 /// literal is unresolvable, and says so by name rather than returning a URL
6970 /// that will time out. us-west-011's shape before this ticket.
6971 #[test]
6972 fn a_lan_only_node_refuses_with_a_named_reason() {
6973 let src = r#"
6974name = "us-west-011"
6975provider = "static"
6976mesh_tags = []
6977
6978[connect]
6979address = "192.168.10.11"
6980ssh = "yah@192.168.10.11"
6981yubaba = "http://192.168.10.11:7443"
6982"#;
6983 let cfg: MachineConfig = toml::from_str(src).unwrap();
6984 assert_eq!(cfg.yubaba_url(), None);
6985 let err = cfg.reach().unwrap_err();
6986 assert!(err.contains("us-west-011"), "{err}");
6987 assert!(err.contains("192.168.10.11"), "{err}");
6988 assert!(err.contains("mesh_ipv4"), "{err}");
6989 }
6990
6991 /// The loopback placeholder is a genuine declaration ("reach me through the
6992 /// SSH tunnel"), not a LAN literal — 127/8 is not RFC1918. It must keep
6993 /// resolving verbatim; `hub::coordinator::is_loopback_url` is what judges it
6994 /// downstream.
6995 #[test]
6996 fn a_loopback_placeholder_still_resolves_verbatim() {
6997 let src = r#"
6998name = "m"
6999provider = "static"
7000mesh_tags = []
7001
7002[connect]
7003address = "192.168.10.99"
7004ssh = "yah@192.168.10.99"
7005yubaba = "http://127.0.0.1:7443"
7006"#;
7007 let cfg: MachineConfig = toml::from_str(src).unwrap();
7008 assert_eq!(cfg.yubaba_url().as_deref(), Some("http://127.0.0.1:7443"));
7009 }
7010
7011 #[test]
7012 fn private_ranges_are_exactly_rfc1918() {
7013 for lan in [
7014 "http://192.168.10.11:7443",
7015 "http://10.0.0.5:7443",
7016 "http://172.16.4.1:7443",
7017 ] {
7018 assert!(private_ipv4_from_url(lan).is_some(), "{lan}");
7019 }
7020 for not_lan in [
7021 "http://100.64.0.6:7443", // mesh
7022 "http://127.0.0.1:7443", // loopback
7023 "http://172.32.0.1:7443", // just past 172.16/12
7024 "http://45.32.194.254:80", // public
7025 "http://us-west-001:7443", // name, not a literal
7026 ] {
7027 assert!(private_ipv4_from_url(not_lan).is_none(), "{not_lan}");
7028 }
7029 }
7030
7031 /// `normalize` migrates in place: the legacy fingerprint moves into
7032 /// `[registration]`, the mesh IP is lifted out of the URL, and the derived
7033 /// `[connect].yubaba` is cleared so the two halves cannot drift.
7034 #[test]
7035 fn normalize_migrates_legacy_fields_and_is_idempotent() {
7036 let src = r#"
7037name = "m"
7038provider = "static"
7039mesh_tags = []
7040hostkey_fingerprint = "SHA256:dmpq"
7041
7042[connect]
7043address = "15.204.89.240"
7044ssh = "debian@15.204.89.240"
7045yubaba = "http://100.64.0.1:7443"
7046"#;
7047 let mut cfg: MachineConfig = toml::from_str(src).unwrap();
7048 cfg.normalize();
7049 assert!(cfg.legacy_hostkey_fingerprint.is_none());
7050 assert_eq!(
7051 cfg.registration.hostkey_fingerprint.as_deref(),
7052 Some("SHA256:dmpq")
7053 );
7054 assert_eq!(cfg.registration.mesh_ipv4.as_deref(), Some("100.64.0.1"));
7055 assert!(cfg.connect.as_ref().unwrap().yubaba.is_none());
7056 // Accessors still answer the same, and re-running changes nothing.
7057 assert_eq!(cfg.yubaba_url().as_deref(), Some("http://100.64.0.1:7443"));
7058 let once = format!("{cfg:?}");
7059 cfg.normalize();
7060 assert_eq!(once, format!("{cfg:?}"));
7061 }
7062
7063 /// A loopback `[connect].yubaba` is a declaration ("no mesh address yet —
7064 /// reach me through the SSH tunnel"), not a stale observation, so
7065 /// `normalize` must leave it alone. us-west-003/011/013 depend on this.
7066 #[test]
7067 fn normalize_leaves_pre_mesh_loopback_declaration_intact() {
7068 let src = r#"
7069name = "m"
7070provider = "static"
7071mesh_tags = []
7072
7073[connect]
7074address = "192.168.10.11"
7075ssh = "yah@192.168.10.11"
7076yubaba = "http://127.0.0.1:7443"
7077"#;
7078 let mut cfg: MachineConfig = toml::from_str(src).unwrap();
7079 cfg.normalize();
7080 assert_eq!(
7081 cfg.connect.as_ref().unwrap().yubaba.as_deref(),
7082 Some("http://127.0.0.1:7443")
7083 );
7084 assert!(cfg.registration.is_empty());
7085 assert_eq!(cfg.mesh_ipv4(), None);
7086 }
7087
7088 /// `save` normalizes, so a legacy file that round-trips through the writer
7089 /// comes back on the split shape with nothing lost — the property that
7090 /// keeps `yah cloud machine attach` from re-emitting the old layout.
7091 #[test]
7092 fn save_writes_the_split_shape_from_a_legacy_config() {
7093 let tmp = tempfile::TempDir::new().unwrap();
7094 let root = tmp.path();
7095 let src = r#"
7096name = "m"
7097provider = "static"
7098mesh_tags = []
7099hostkey_fingerprint = "SHA256:dmpq"
7100
7101[connect]
7102address = "15.204.89.240"
7103ssh = "debian@15.204.89.240"
7104yubaba = "http://100.64.0.1:7443"
7105"#;
7106 let cfg: MachineConfig = toml::from_str(src).unwrap();
7107 cfg.save(root).unwrap();
7108
7109 let written = std::fs::read_to_string(root.join("machines/m.toml")).unwrap();
7110 let reg_at = written
7111 .find("[registration]")
7112 .unwrap_or_else(|| panic!("no [registration] table: {written}"));
7113 let fp_at = written
7114 .find("hostkey_fingerprint")
7115 .unwrap_or_else(|| panic!("fingerprint dropped: {written}"));
7116 assert!(
7117 fp_at > reg_at,
7118 "legacy top-level field must not be re-emitted: {written}"
7119 );
7120 assert!(
7121 !written.contains("yubaba ="),
7122 "derived URL must not be re-emitted alongside mesh_ipv4: {written}"
7123 );
7124
7125 let reloaded: MachineConfig = toml::from_str(&written).unwrap();
7126 assert_eq!(reloaded.hostkey_fingerprint(), Some("SHA256:dmpq"));
7127 assert_eq!(
7128 reloaded.yubaba_url().as_deref(),
7129 Some("http://100.64.0.1:7443")
7130 );
7131 }
7132
7133 /// `[registration]` is omitted entirely for a machine nothing has been
7134 /// observed about — a scaffolded declaration stays clean.
7135 #[test]
7136 fn empty_registration_is_omitted_on_serialize() {
7137 let src = r#"
7138name = "m"
7139provider = "static"
7140mesh_tags = []
7141"#;
7142 let cfg: MachineConfig = toml::from_str(src).unwrap();
7143 assert!(cfg.registration.is_empty());
7144 let out = toml::to_string_pretty(&cfg).unwrap();
7145 assert!(!out.contains("[registration]"), "{out}");
7146 }
7147
7148 #[test]
7149 fn driver_provider_without_location_fails_validate() {
7150 // A driver-backed provider (hetzner/vultr) still MUST carry location +
7151 // server_type — the driver can't create a server without them. The
7152 // contract moved from load-time (required field) to provision-time
7153 // (validate), so the TOML loads but validate() rejects it.
7154 let src = r#"
7155name = "us-west-001"
7156provider = "hetzner"
7157mesh_tags = []
7158"#;
7159 let cfg: MachineConfig = toml::from_str(src).unwrap();
7160 assert!(provider_has_machine_driver(&cfg.provider));
7161 let err = cfg.validate().unwrap_err().to_string();
7162 assert!(
7163 err.contains("location"),
7164 "expected location complaint: {err}"
7165 );
7166 }
7167
7168 /// Helper for the new-tree integration tests below: lay out
7169 /// `<workspace>/.yah/{infra,services}/` with `dev-yah` + its mirrors and
7170 /// the three Phase-A providers (cloudflare, hetzner, orbstack).
7171 fn make_new_tree_with_dev_yah(root: &std::path::Path) {
7172 let infra = root.join(".yah").join("infra");
7173 let providers = infra.join("providers");
7174 std::fs::create_dir_all(&providers).unwrap();
7175 std::fs::write(
7176 providers.join("cloudflare.toml"),
7177 r#"schema_version = 1
7178id = "cloudflare"
7179kind = "cloudflare"
7180credentials = "keystore://cloudflare/yah"
7181default_zone = "yah.dev"
7182"#,
7183 )
7184 .unwrap();
7185 std::fs::write(
7186 providers.join("hetzner.toml"),
7187 r#"schema_version = 1
7188id = "hetzner"
7189kind = "hetzner"
7190credentials = "keystore://hetzner/yah"
7191default_location = "pdx"
7192default_server_type = "cpx11"
7193ssh_keys = []
7194"#,
7195 )
7196 .unwrap();
7197 std::fs::write(
7198 providers.join("orbstack.toml"),
7199 r#"schema_version = 1
7200id = "orbstack"
7201kind = "local-container"
7202runtime = "auto"
7203
7204[discovery]
7205orbstack = "~/.orbstack/run/docker.sock"
7206"#,
7207 )
7208 .unwrap();
7209
7210 let svc = root.join(".yah").join("services").join("dev-yah");
7211 std::fs::create_dir_all(svc.join("mirrors")).unwrap();
7212 std::fs::write(
7213 svc.join("service.toml"),
7214 r#"schema_version = 1
7215name = "dev-yah"
7216domain = "yah.dev"
7217
7218[[components]]
7219id = "site"
7220kind = "mesofact-static"
7221path = "app/yah/web"
7222role = "static"
7223"#,
7224 )
7225 .unwrap();
7226 std::fs::write(
7227 svc.join("mirrors/prod.toml"),
7228 r#"schema_version = 1
7229shape = "single-machine"
7230
7231[providers.static]
7232use = "cloudflare"
7233bucket = "yah-dev"
7234zone = "yah.dev"
7235"#,
7236 )
7237 .unwrap();
7238 std::fs::write(
7239 svc.join("mirrors/local.toml"),
7240 r#"schema_version = 1
7241shape = "local"
7242
7243[providers.static]
7244kind = "local-static"
7245port = 4321
7246
7247[providers.compute]
7248use = "orbstack"
7249"#,
7250 )
7251 .unwrap();
7252 }
7253
7254 #[test]
7255 fn cloud_config_load_new_tree_populates_providers_and_services() {
7256 let tmp = tempfile::TempDir::new().unwrap();
7257 let root = tmp.path();
7258 make_new_tree_with_dev_yah(root);
7259
7260 let cfg = CloudConfig::load(root).unwrap();
7261 assert_eq!(cfg.providers.len(), 3, "three providers loaded");
7262 assert!(cfg.provider("cloudflare").is_some());
7263 assert!(cfg.provider("hetzner").is_some());
7264 assert!(cfg.provider("orbstack").is_some());
7265
7266 let dev = cfg.service("dev-yah").expect("dev-yah service");
7267 assert_eq!(dev.service.domain, "yah.dev");
7268 assert_eq!(dev.service.components.len(), 1);
7269 assert_eq!(dev.mirrors.len(), 2);
7270 // Legacy file stems "prod" and "local" are normalised to canonical tier names.
7271 assert!(dev.mirrors.contains_key("cloud"), "prod.toml → cloud tier");
7272 assert!(dev.mirrors.contains_key("dev"), "local.toml → dev tier");
7273 assert_eq!(dev.mirrors["cloud"].shape, MirrorShape::SingleMachine);
7274 assert_eq!(dev.mirrors["dev"].shape, MirrorShape::Local);
7275
7276 // Legacy fields stay empty when no .yah/cloud/ exists.
7277 assert!(cfg.legacy_mirrors.is_empty());
7278 assert!(cfg.legacy_services.is_empty());
7279 assert!(cfg.workloads.is_empty());
7280 }
7281
7282 #[test]
7283 fn cloud_config_cross_ref_fails_on_missing_provider() {
7284 // Mirror references a provider id that doesn't exist.
7285 let tmp = tempfile::TempDir::new().unwrap();
7286 let root = tmp.path();
7287 let svc = root.join(".yah").join("services").join("dev-yah");
7288 std::fs::create_dir_all(svc.join("mirrors")).unwrap();
7289 std::fs::write(
7290 svc.join("service.toml"),
7291 "schema_version = 1\nname = \"dev-yah\"\ndomain = \"yah.dev\"\n",
7292 )
7293 .unwrap();
7294 std::fs::write(
7295 svc.join("mirrors/prod.toml"),
7296 "schema_version = 1\nshape = \"single-machine\"\n\n[providers.static]\nuse = \"fly-io\"\n",
7297 ).unwrap();
7298
7299 let err = CloudConfig::load(root).unwrap_err();
7300 let msg = err.to_string();
7301 assert!(
7302 msg.contains("fly-io"),
7303 "error should name the missing provider id, got: {msg}"
7304 );
7305 assert!(
7306 msg.contains("providers/fly-io.toml") || msg.contains("no such provider"),
7307 "error should hint at remedy, got: {msg}"
7308 );
7309 }
7310
7311 #[test]
7312 fn cloud_config_cross_ref_fails_on_missing_provider_named_by_an_ingress_edge() {
7313 // R845: the edge's own `use` is a provider reference like any other, so
7314 // a typo has to fail here rather than at the Cloudflare arm of apply.
7315 let tmp = tempfile::TempDir::new().unwrap();
7316 let root = tmp.path();
7317 let svc = root.join(".yah").join("services").join("dev-yah");
7318 std::fs::create_dir_all(svc.join("mirrors")).unwrap();
7319 std::fs::write(
7320 svc.join("service.toml"),
7321 "schema_version = 1\nname = \"dev-yah\"\ndomain = \"yah.dev\"\n",
7322 )
7323 .unwrap();
7324 std::fs::write(
7325 svc.join("mirrors/prod.toml"),
7326 "schema_version = 1\nshape = \"single-machine\"\n\n\
7327 [providers.compute]\nkind = \"static\"\nmachine = \"borrowed-01\"\n\
7328 zone = \"a.yah.dev\"\nport = 8080\n\n\
7329 [[ingress]]\nprovider = \"cloudflare-tunnel\"\nuse = \"cloudflar\"\n",
7330 )
7331 .unwrap();
7332
7333 let msg = CloudConfig::load(root).unwrap_err().to_string();
7334 assert!(
7335 msg.contains("ingress[0].use") && msg.contains("cloudflar"),
7336 "error should name the edge and the typo'd id, got: {msg}"
7337 );
7338 }
7339
7340 #[test]
7341 fn cloud_config_cross_ref_passes_on_inline_only_mirror() {
7342 // Inline `kind = "local-static"` doesn't require an infra provider.
7343 let tmp = tempfile::TempDir::new().unwrap();
7344 let root = tmp.path();
7345 let svc = root.join(".yah").join("services").join("local-only");
7346 std::fs::create_dir_all(svc.join("mirrors")).unwrap();
7347 std::fs::write(
7348 svc.join("service.toml"),
7349 "schema_version = 1\nname = \"local-only\"\ndomain = \"local.test\"\n",
7350 )
7351 .unwrap();
7352 std::fs::write(
7353 svc.join("mirrors/local.toml"),
7354 "schema_version = 1\nshape = \"local\"\n\n[providers.static]\nkind = \"local-static\"\nport = 8080\n",
7355 ).unwrap();
7356
7357 // Should load fine: no `use=` references, no providers required.
7358 let cfg = CloudConfig::load(root).unwrap();
7359 assert!(cfg.service("local-only").is_some());
7360 }
7361
7362 fn mirror(src: &str) -> MirrorConfig {
7363 toml::from_str(&format!("schema_version = 1\nshape = \"single-machine\"\n{src}"))
7364 .expect("parse mirror")
7365 }
7366
7367 #[test]
7368 fn passway_machines_reads_both_ingress_spellings_the_same_way() {
7369 // The whole reason this is derived in Rust rather than read off a field
7370 // by the UI: these two mirrors say the identical thing, and a consumer
7371 // that reaches for `ingress_machines` sees the second one as empty.
7372 let scalar = mirror("ingress = \"passway\"\ningress_machines = [\"us-east-001\"]\n");
7373 let edges = mirror(
7374 "[[ingress]]\nprovider = \"passway\"\nmachines = [\"us-east-001\"]\n",
7375 );
7376 assert_eq!(scalar.passway_machines(), Some(vec!["us-east-001".into()]));
7377 assert_eq!(scalar.passway_machines(), edges.passway_machines());
7378 }
7379
7380 #[test]
7381 fn passway_machines_skips_a_cloudflare_tunnel_edge() {
7382 // A cloudflared node publishes through Cloudflare's DNS and does not
7383 // serve `GET /domains/{d}/onboarding`, so naming it here would point
7384 // the custom-domain UI at a node that cannot answer.
7385 let cf_only =
7386 mirror("[[ingress]]\nprovider = \"cloudflare-tunnel\"\nmachines = [\"cf-01\"]\n");
7387 assert_eq!(cf_only.passway_machines(), None);
7388
7389 let mixed = mirror(
7390 "[[ingress]]\nprovider = \"cloudflare-tunnel\"\nmachines = [\"cf-01\"]\n\
7391 slots = [\"static\"]\n\n\
7392 [[ingress]]\nprovider = \"passway\"\nmachines = [\"us-east-001\"]\n\
7393 slots = [\"bundle\"]\n",
7394 );
7395 assert_eq!(mixed.passway_machines(), Some(vec!["us-east-001".into()]));
7396 }
7397
7398 #[test]
7399 fn passway_machines_separates_declared_but_unplaced_from_undeclared() {
7400 // Some(vec![]) means "a passway front door exists, but its placement
7401 // falls back to the fronted slot's and is not knowable from the mirror".
7402 // None means there is no passway front door at all. Collapsing the two
7403 // would make a co-located edge indistinguishable from no edge.
7404 assert_eq!(mirror("ingress = \"passway\"\n").passway_machines(), Some(vec![]));
7405 assert_eq!(mirror("").passway_machines(), None);
7406 assert_eq!(mirror("ingress = \"none\"\n").passway_machines(), None);
7407 }
7408
7409 #[test]
7410 fn passway_machines_is_none_for_a_declaration_that_cannot_mean_anything() {
7411 // `ingress_machines` with no `ingress` is an error `ingress_edges` names
7412 // properly; swallowing it to None here is deliberate, because this is
7413 // read while loading every service in the workspace and hard-failing
7414 // would report an unrelated mirror's shape error from the wrong place.
7415 let orphaned = mirror("ingress_machines = [\"us-east-001\"]\n");
7416 assert!(orphaned.ingress_edges().is_err());
7417 assert_eq!(orphaned.passway_machines(), None);
7418 }
7419
7420 #[test]
7421 fn cloud_config_load_derives_passway_machines_only_for_passway_envs() {
7422 let tmp = tempfile::TempDir::new().unwrap();
7423 let root = tmp.path();
7424 let svc = root.join(".yah").join("services").join("dev-yah");
7425 std::fs::create_dir_all(svc.join("mirrors")).unwrap();
7426 std::fs::write(
7427 svc.join("service.toml"),
7428 "schema_version = 1\nname = \"dev-yah\"\ndomain = \"yah.dev\"\n",
7429 )
7430 .unwrap();
7431 std::fs::write(
7432 svc.join("mirrors/cloud.toml"),
7433 "schema_version = 1\nshape = \"single-machine\"\n\
7434 ingress = \"passway\"\ningress_machines = [\"us-east-001\", \"us-west-001\"]\n\n\
7435 [providers.static]\nkind = \"local-static\"\nport = 8080\n",
7436 )
7437 .unwrap();
7438 std::fs::write(
7439 svc.join("mirrors/local.toml"),
7440 "schema_version = 1\nshape = \"local\"\n\n\
7441 [providers.static]\nkind = \"local-static\"\nport = 8080\n",
7442 )
7443 .unwrap();
7444
7445 let cfg = CloudConfig::load(root).unwrap();
7446 let svc = cfg.service("dev-yah").unwrap();
7447 assert_eq!(
7448 svc.passway_machines.get("cloud"),
7449 Some(&vec!["us-east-001".to_string(), "us-west-001".to_string()])
7450 );
7451 assert!(
7452 !svc.passway_machines.contains_key("local"),
7453 "an env with no front door must be absent, not empty: {:?}",
7454 svc.passway_machines
7455 );
7456 }
7457
7458 #[test]
7459 fn cloud_config_load_coexists_legacy_and_new_trees() {
7460 // Both trees present — both fields populated independently.
7461 let tmp = tempfile::TempDir::new().unwrap();
7462 let root = tmp.path();
7463 make_new_tree_with_dev_yah(root);
7464
7465 let cloud_dir = make_legacy_cloud_dir(root);
7466 std::fs::create_dir_all(cloud_dir.join("mirrors")).unwrap();
7467 std::fs::write(
7468 cloud_dir.join("mirrors/noisetable.toml"),
7469 "camp = \"noisetable\"\nregions = [\"pdx\"]\nworkloads = []\n",
7470 )
7471 .unwrap();
7472
7473 let cfg = CloudConfig::load(root).unwrap();
7474 assert_eq!(cfg.providers.len(), 3);
7475 assert!(cfg.service("dev-yah").is_some());
7476 assert_eq!(cfg.legacy_mirrors.len(), 1);
7477 assert!(cfg.legacy_mirror("noisetable").is_some());
7478 }
7479
7480 #[test]
7481 fn web_workload_round_trips() {
7482 // app/yah/web/workload.toml is parsed as a WorkloadSpec via the
7483 // workload-spec crate. The minimum-viable manifest here exercises
7484 // schema_version + kind + build fields.
7485 //
7486 // The on-disk file uses the abbreviated v1 form (kind + build); the
7487 // full WorkloadSpec is verbose, so this test asserts the new
7488 // mesofact-static abbreviated form parses as raw TOML (B3 will plumb
7489 // it through WorkloadSpec proper).
7490 // `routes` above [build] — it is a top-level field, and TOML would
7491 // scope it into that table if written below the header (R658-B1).
7492 let src = r#"
7493schema_version = 1
7494kind = "mesofact-static"
7495
7496routes = "./routes.ts"
7497
7498[build]
7499command = "bun run build"
7500out_dir = "dist"
7501"#;
7502 let v: toml::Value = toml::from_str(src).unwrap();
7503 assert_eq!(
7504 v.get("schema_version").and_then(|x| x.as_integer()),
7505 Some(1)
7506 );
7507 assert_eq!(
7508 v.get("kind").and_then(|x| x.as_str()),
7509 Some("mesofact-static")
7510 );
7511 let build = v
7512 .get("build")
7513 .and_then(|x| x.as_table())
7514 .expect("build table");
7515 assert_eq!(
7516 build.get("command").and_then(|x| x.as_str()),
7517 Some("bun run build")
7518 );
7519 assert_eq!(build.get("out_dir").and_then(|x| x.as_str()), Some("dist"));
7520 }
7521
7522 // ─── Canonical CRUD: ServiceConfig/MirrorConfig save + delete (R323-F1) ──
7523
7524 #[test]
7525 fn service_config_save_creates_canonical_toml_and_round_trips() {
7526 let tmp = tempfile::TempDir::new().unwrap();
7527 let root = tmp.path();
7528
7529 let svc = ServiceConfig {
7530 schema_version: 1,
7531 name: "dev-yah".into(),
7532 domain: "yah.dev".into(),
7533 db: DbCatalog::default(),
7534 components: vec![ServiceComponent {
7535 mount: None,
7536 id: "site".into(),
7537 kind: "mesofact-static".into(),
7538 path: "app/yah/web".into(),
7539 role: "static".into(),
7540 publishes: Some("static".into()),
7541 wave: 0,
7542 git: None,
7543 }],
7544 };
7545 svc.save(root).unwrap();
7546
7547 // Landed at the canonical path.
7548 let path = crate::paths::service_toml(root, "dev-yah");
7549 assert!(
7550 path.exists(),
7551 "service.toml should exist at {}",
7552 path.display()
7553 );
7554
7555 // Reloads through the full CloudConfig loader (no mirrors yet).
7556 let cfg = CloudConfig::load(root).unwrap();
7557 let loaded = cfg.service("dev-yah").expect("dev-yah service");
7558 assert_eq!(loaded.service.domain, "yah.dev");
7559 assert_eq!(loaded.service.components.len(), 1);
7560 assert_eq!(
7561 loaded.service.components[0].publishes.as_deref(),
7562 Some("static")
7563 );
7564 assert!(loaded.mirrors.is_empty());
7565 }
7566
7567 #[test]
7568 fn service_config_save_overwrites_in_place() {
7569 let tmp = tempfile::TempDir::new().unwrap();
7570 let root = tmp.path();
7571
7572 let mut svc = ServiceConfig {
7573 schema_version: 1,
7574 name: "dev-yah".into(),
7575 domain: "yah.dev".into(),
7576 components: vec![],
7577 db: DbCatalog::default(),
7578 };
7579 svc.save(root).unwrap();
7580 svc.domain = "yah.example".into();
7581 svc.save(root).unwrap();
7582
7583 let cfg = CloudConfig::load(root).unwrap();
7584 assert_eq!(
7585 cfg.service("dev-yah").unwrap().service.domain,
7586 "yah.example"
7587 );
7588 }
7589
7590 #[test]
7591 fn mirror_config_save_round_trips_reference_and_inline_slots() {
7592 let tmp = tempfile::TempDir::new().unwrap();
7593 let root = tmp.path();
7594
7595 // A service must exist so the loader walks the mirrors/ dir.
7596 ServiceConfig {
7597 schema_version: 1,
7598 name: "dev-yah".into(),
7599 domain: "yah.dev".into(),
7600 components: vec![],
7601 db: DbCatalog::default(),
7602 }
7603 .save(root)
7604 .unwrap();
7605
7606 // The cloudflare provider the reference slot points at must resolve,
7607 // or CloudConfig::load's cross-ref check rejects the tree.
7608 let providers = crate::paths::providers_dir(root);
7609 std::fs::create_dir_all(&providers).unwrap();
7610 std::fs::write(
7611 providers.join("cloudflare.toml"),
7612 "schema_version = 1\nid = \"cloudflare\"\nkind = \"cloudflare\"\n",
7613 )
7614 .unwrap();
7615
7616 let mut providers_map = BTreeMap::new();
7617 providers_map.insert(
7618 "static".to_string(),
7619 MirrorProviderSlot::Reference {
7620 provider_id: "cloudflare".into(),
7621 fields: {
7622 let mut f = BTreeMap::new();
7623 f.insert("bucket".to_string(), toml::Value::String("yah-dev".into()));
7624 f
7625 },
7626 },
7627 );
7628 providers_map.insert(
7629 "compute".to_string(),
7630 MirrorProviderSlot::Inline {
7631 kind: Provider::LocalStatic,
7632 fields: {
7633 let mut f = BTreeMap::new();
7634 f.insert("port".to_string(), toml::Value::Integer(4321));
7635 f
7636 },
7637 },
7638 );
7639 let mirror = MirrorConfig {
7640 schema_version: 1,
7641 shape: MirrorShape::SingleMachine,
7642 providers: providers_map,
7643 ingress: Default::default(),
7644 ingress_machines: Vec::new(),
7645 drivers: Default::default(),
7646 asset_aliases: Default::default(),
7647 };
7648 // Save with canonical name; legacy "prod" is normalised to "cloud" on load.
7649 mirror.save(root, "dev-yah", "cloud").unwrap();
7650
7651 let path = crate::paths::service_mirror_toml(root, "dev-yah", "cloud");
7652 assert!(
7653 path.exists(),
7654 "mirror toml should exist at {}",
7655 path.display()
7656 );
7657
7658 let cfg = CloudConfig::load(root).unwrap();
7659 let loaded = &cfg.service("dev-yah").unwrap().mirrors["cloud"];
7660 assert_eq!(loaded.shape, MirrorShape::SingleMachine);
7661 assert_eq!(loaded.providers["static"].provider_id(), Some("cloudflare"));
7662 assert_eq!(
7663 loaded.providers["compute"].inline_kind(),
7664 Some(Provider::LocalStatic)
7665 );
7666 }
7667
7668 #[test]
7669 fn service_delete_removes_dir_and_mirrors() {
7670 let tmp = tempfile::TempDir::new().unwrap();
7671 let root = tmp.path();
7672
7673 let svc = ServiceConfig {
7674 schema_version: 1,
7675 name: "dev-yah".into(),
7676 domain: "yah.dev".into(),
7677 components: vec![],
7678 db: DbCatalog::default(),
7679 };
7680 svc.save(root).unwrap();
7681 MirrorConfig {
7682 schema_version: 1,
7683 shape: MirrorShape::Local,
7684 providers: BTreeMap::new(),
7685 ingress: Default::default(),
7686 ingress_machines: Vec::new(),
7687 drivers: Default::default(),
7688 asset_aliases: Default::default(),
7689 }
7690 .save(root, "dev-yah", "local")
7691 .unwrap();
7692
7693 assert!(
7694 ServiceConfig::delete(root, "dev-yah").unwrap(),
7695 "first delete reports true"
7696 );
7697 assert!(!crate::paths::service_dir(root, "dev-yah").exists());
7698 // Idempotent: deleting again is a no-op that reports false.
7699 assert!(!ServiceConfig::delete(root, "dev-yah").unwrap());
7700
7701 let cfg = CloudConfig::load(root).unwrap();
7702 assert!(cfg.service("dev-yah").is_none());
7703 }
7704
7705 #[test]
7706 fn mirror_delete_leaves_other_mirrors_and_service_intact() {
7707 let tmp = tempfile::TempDir::new().unwrap();
7708 let root = tmp.path();
7709
7710 ServiceConfig {
7711 schema_version: 1,
7712 name: "dev-yah".into(),
7713 domain: "yah.dev".into(),
7714 components: vec![],
7715 db: DbCatalog::default(),
7716 }
7717 .save(root)
7718 .unwrap();
7719 for env in ["prod", "local"] {
7720 MirrorConfig {
7721 schema_version: 1,
7722 shape: MirrorShape::Local,
7723 providers: BTreeMap::new(),
7724 ingress: Default::default(),
7725 ingress_machines: Vec::new(),
7726 drivers: Default::default(),
7727 asset_aliases: Default::default(),
7728 }
7729 .save(root, "dev-yah", env)
7730 .unwrap();
7731 }
7732
7733 assert!(MirrorConfig::delete(root, "dev-yah", "prod").unwrap());
7734 assert!(!MirrorConfig::delete(root, "dev-yah", "prod").unwrap());
7735
7736 let cfg = CloudConfig::load(root).unwrap();
7737 let svc = cfg
7738 .service("dev-yah")
7739 .expect("service survives mirror delete");
7740 // Legacy file stems are normalised on load: "prod" → "cloud", "local" → "dev".
7741 assert!(!svc.mirrors.contains_key("cloud"));
7742 assert!(svc.mirrors.contains_key("dev"));
7743 }
7744
7745 // ─── DomainConfig (R347-F2) ────────────────────────────────────────────
7746
7747 fn write_marketing_service(root: &Path) {
7748 let svc = ServiceConfig {
7749 schema_version: 1,
7750 name: "yah-marketing".into(),
7751 domain: "yah.dev".into(),
7752 db: DbCatalog::default(),
7753 components: vec![ServiceComponent {
7754 mount: None,
7755 id: "site".into(),
7756 kind: "mesofact-static".into(),
7757 path: "app/yah/web".into(),
7758 role: "static".into(),
7759 publishes: None,
7760 wave: 0,
7761 git: None,
7762 }],
7763 };
7764 svc.save(root).unwrap();
7765 }
7766
7767 #[test]
7768 fn round_trip_domain_with_each_route_mode() {
7769 let dom = DomainConfig {
7770 schema_version: 1,
7771 name: "yah-dev".into(),
7772 domain: "yah.dev".into(),
7773 front_door: FrontDoor::Worker,
7774 cdn_bucket: "yah-dev".into(),
7775 worker_bundle_path: Some(".yah/workers/yah-dev/".into()),
7776 routes: vec![
7777 DomainRoute {
7778 headers: Default::default(),
7779 path: "/".into(),
7780 mode: RouteMode::Static {
7781 component: "yah-marketing/site".into(),
7782 },
7783 },
7784 DomainRoute {
7785 headers: Default::default(),
7786 path: "/dashboard/api/*".into(),
7787 mode: RouteMode::Backend {
7788 component: "yah-dashboard/api".into(),
7789 origin: "https://api.dashboard.yah.dev".into(),
7790 },
7791 },
7792 DomainRoute {
7793 headers: Default::default(),
7794 path: "/old".into(),
7795 mode: RouteMode::Redirect {
7796 target: "https://yah.dev/blog".into(),
7797 status: 308,
7798 },
7799 },
7800 ],
7801 };
7802 let s = toml::to_string(&dom).unwrap();
7803 let back: DomainConfig = toml::from_str(&s).unwrap();
7804 assert_eq!(back.name, "yah-dev");
7805 assert_eq!(back.routes.len(), 3);
7806 assert!(matches!(back.routes[0].mode, RouteMode::Static { .. }));
7807 assert!(matches!(back.routes[1].mode, RouteMode::Backend { .. }));
7808 assert!(matches!(back.routes[2].mode, RouteMode::Redirect { .. }));
7809 }
7810
7811 #[test]
7812 fn redirect_status_defaults_to_308() {
7813 let src = r#"
7814schema_version = 1
7815name = "yah-dev"
7816domain = "yah.dev"
7817front_door = "worker"
7818cdn_bucket = "yah-dev"
7819
7820[[routes]]
7821path = "/old"
7822mode = "redirect"
7823target = "https://yah.dev/blog"
7824"#;
7825 let dom: DomainConfig = toml::from_str(src).unwrap();
7826 let RouteMode::Redirect { status, .. } = &dom.routes[0].mode else {
7827 panic!("expected redirect");
7828 };
7829 assert_eq!(*status, 308);
7830 }
7831
7832 #[test]
7833 fn missing_domains_dir_is_empty() {
7834 let tmp = tempfile::TempDir::new().unwrap();
7835 // R844-B7: `.yah/` must exist or this is a wrong-root error rather
7836 // than an empty tree. The absent directory under test is `domains/`.
7837 std::fs::create_dir_all(tmp.path().join(".yah")).unwrap();
7838 let cfg = CloudConfig::load(tmp.path()).unwrap();
7839 assert!(cfg.domains.is_empty());
7840 }
7841
7842 #[test]
7843 fn save_reload_roundtrip() {
7844 let tmp = tempfile::TempDir::new().unwrap();
7845 let root = tmp.path();
7846 write_marketing_service(root);
7847
7848 let dom = DomainConfig {
7849 schema_version: 1,
7850 name: "yah-dev".into(),
7851 domain: "yah.dev".into(),
7852 front_door: FrontDoor::Worker,
7853 cdn_bucket: "yah-dev".into(),
7854 worker_bundle_path: None,
7855 routes: vec![DomainRoute {
7856 headers: Default::default(),
7857 path: "/".into(),
7858 mode: RouteMode::Static {
7859 component: "yah-marketing/site".into(),
7860 },
7861 }],
7862 };
7863 dom.save(root).unwrap();
7864
7865 let cfg = CloudConfig::load(root).unwrap();
7866 let loaded = cfg.domain("yah-dev").expect("yah-dev domain");
7867 assert_eq!(loaded.domain, "yah.dev");
7868 assert_eq!(loaded.routes.len(), 1);
7869 }
7870
7871 #[test]
7872 fn delete_returns_false_when_absent() {
7873 let tmp = tempfile::TempDir::new().unwrap();
7874 assert!(!DomainConfig::delete(tmp.path(), "no-such-domain").unwrap());
7875 }
7876
7877 #[test]
7878 fn delete_returns_true_first_time() {
7879 let tmp = tempfile::TempDir::new().unwrap();
7880 let root = tmp.path();
7881 let dom = DomainConfig {
7882 schema_version: 1,
7883 name: "yah-dev".into(),
7884 domain: "yah.dev".into(),
7885 front_door: FrontDoor::BucketDirect,
7886 cdn_bucket: "yah-dev".into(),
7887 worker_bundle_path: None,
7888 routes: vec![],
7889 };
7890 dom.save(root).unwrap();
7891 assert!(DomainConfig::delete(root, "yah-dev").unwrap());
7892 assert!(!DomainConfig::delete(root, "yah-dev").unwrap());
7893 }
7894
7895 // ---- R594-F12: front-door discriminator ------------------------------
7896
7897 /// Write a raw domain manifest so the tests exercise the deserialize +
7898 /// validate path, not a hand-built struct that skipped serde.
7899 fn write_domain_toml(root: &Path, stem: &str, body: &str) {
7900 let dir = root.join(".yah").join("domains");
7901 std::fs::create_dir_all(&dir).unwrap();
7902 std::fs::write(dir.join(format!("{stem}.toml")), body).unwrap();
7903 }
7904
7905 #[test]
7906 fn front_door_is_required() {
7907 let tmp = tempfile::TempDir::new().unwrap();
7908 let root = tmp.path();
7909 write_marketing_service(root);
7910 write_domain_toml(
7911 root,
7912 "yah-dev",
7913 r#"
7914schema_version = 1
7915name = "yah-dev"
7916domain = "yah.dev"
7917cdn_bucket = "yah-dev"
7918[[routes]]
7919path = "/*"
7920mode = "static"
7921component = "yah-marketing/site"
7922"#,
7923 );
7924 let err = CloudConfig::load(root).unwrap_err().to_string();
7925 // serde's own missing-field message; the point is that omitting the
7926 // discriminator is not a silently-defaulted state.
7927 assert!(err.contains("yah-dev.toml"), "{err}");
7928 }
7929
7930 #[test]
7931 fn bucket_direct_with_routes_is_rejected() {
7932 let tmp = tempfile::TempDir::new().unwrap();
7933 let root = tmp.path();
7934 write_marketing_service(root);
7935 write_domain_toml(
7936 root,
7937 "cdn-yah-dev",
7938 r#"
7939schema_version = 1
7940name = "cdn-yah-dev"
7941domain = "cdn.yah.dev"
7942front_door = "bucket-direct"
7943cdn_bucket = "yah-dev"
7944[[routes]]
7945path = "/docs/*"
7946mode = "static"
7947component = "yah-marketing/site"
7948"#,
7949 );
7950 let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
7951 assert!(err.contains("front_door"), "{err}");
7952 assert!(err.contains("/docs/*"), "{err}");
7953 }
7954
7955 #[test]
7956 fn bucket_direct_with_worker_bundle_path_is_rejected() {
7957 let tmp = tempfile::TempDir::new().unwrap();
7958 let root = tmp.path();
7959 write_domain_toml(
7960 root,
7961 "cdn-yah-dev",
7962 r#"
7963schema_version = 1
7964name = "cdn-yah-dev"
7965domain = "cdn.yah.dev"
7966front_door = "bucket-direct"
7967cdn_bucket = "yah-dev"
7968worker_bundle_path = ".yah/workers/cdn-yah-dev/"
7969"#,
7970 );
7971 let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
7972 assert!(err.contains("worker_bundle_path"), "{err}");
7973 }
7974
7975 // ── R746: per-route response headers + component mounts ──────────────────
7976
7977 /// A two-component service: `site` at the root, `app` mounted at `/app`
7978 /// with isolation headers on its route. This is the noisetable.com shape
7979 /// the primitive was built for.
7980 fn write_two_component_service(root: &Path) {
7981 let svc = ServiceConfig {
7982 schema_version: 1,
7983 name: "yah-marketing".into(),
7984 domain: "yah.dev".into(),
7985 db: DbCatalog::default(),
7986 components: vec![
7987 ServiceComponent {
7988 mount: None,
7989 id: "site".into(),
7990 kind: "mesofact-static".into(),
7991 path: "app/yah/web".into(),
7992 role: "static".into(),
7993 publishes: None,
7994 wave: 0,
7995 git: None,
7996 },
7997 ServiceComponent {
7998 mount: Some("/app".into()),
7999 id: "app".into(),
8000 kind: "mesofact-static".into(),
8001 path: "app/browser".into(),
8002 role: "static".into(),
8003 publishes: None,
8004 wave: 0,
8005 git: None,
8006 },
8007 ],
8008 };
8009 svc.save(root).unwrap();
8010 }
8011
8012 const MOUNTED_DOMAIN: &str = r#"
8013schema_version = 1
8014name = "yah-dev"
8015domain = "yah.dev"
8016front_door = "worker"
8017cdn_bucket = "yah-dev"
8018
8019[[routes]]
8020path = "/app/*"
8021mode = "static"
8022component = "yah-marketing/app"
8023headers = { "Cross-Origin-Opener-Policy" = "same-origin", "Cross-Origin-Embedder-Policy" = "require-corp" }
8024
8025[[routes]]
8026path = "/*"
8027mode = "static"
8028component = "yah-marketing/site"
8029"#;
8030
8031 #[test]
8032 fn a_mounted_component_routed_at_its_mount_loads() {
8033 let tmp = tempfile::TempDir::new().unwrap();
8034 let root = tmp.path();
8035 write_two_component_service(root);
8036 write_domain_toml(root, "yah-dev", MOUNTED_DOMAIN);
8037 let cfg = CloudConfig::load(root).unwrap();
8038 let dom = cfg.domain("yah-dev").unwrap();
8039 assert_eq!(dom.routes.len(), 2);
8040 assert_eq!(
8041 dom.routes[0].headers.get("Cross-Origin-Opener-Policy").map(String::as_str),
8042 Some("same-origin")
8043 );
8044 assert!(dom.routes[1].headers.is_empty());
8045 }
8046
8047 /// The header table reaches the Worker in MANIFEST order with headerless
8048 /// routes dropped. Order is the whole contract — the front door applies the
8049 /// first match, so `/app/*` before `/*` is what isolates the app without
8050 /// isolating the marketing site.
8051 #[test]
8052 fn route_headers_json_preserves_order_and_drops_headerless_routes() {
8053 let tmp = tempfile::TempDir::new().unwrap();
8054 let root = tmp.path();
8055 write_two_component_service(root);
8056 write_domain_toml(root, "yah-dev", MOUNTED_DOMAIN);
8057 let cfg = CloudConfig::load(root).unwrap();
8058 let json = cfg.domain("yah-dev").unwrap().route_headers_json();
8059
8060 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
8061 let rules = parsed.as_array().unwrap();
8062 assert_eq!(rules.len(), 1, "the headerless catch-all is dropped: {json}");
8063 assert_eq!(rules[0]["path"], "/app/*");
8064 assert_eq!(rules[0]["headers"]["Cross-Origin-Embedder-Policy"], "require-corp");
8065 }
8066
8067 #[test]
8068 fn route_headers_json_is_an_empty_array_when_nothing_declares_headers() {
8069 let tmp = tempfile::TempDir::new().unwrap();
8070 let root = tmp.path();
8071 write_marketing_service(root);
8072 write_domain_toml(
8073 root,
8074 "yah-dev",
8075 r#"
8076schema_version = 1
8077name = "yah-dev"
8078domain = "yah.dev"
8079front_door = "worker"
8080cdn_bucket = "yah-dev"
8081
8082[[routes]]
8083path = "/*"
8084mode = "static"
8085component = "yah-marketing/site"
8086"#,
8087 );
8088 let cfg = CloudConfig::load(root).unwrap();
8089 assert_eq!(cfg.domain("yah-dev").unwrap().route_headers_json(), "[]");
8090 }
8091
8092 /// The reconciler's own entry point: given a workspace root and a service
8093 /// name, produce the binding value. `"[]"` when nothing routes the service.
8094 #[test]
8095 fn route_headers_for_service_reads_the_workspace_domains() {
8096 let tmp = tempfile::TempDir::new().unwrap();
8097 let root = tmp.path();
8098 write_two_component_service(root);
8099 write_domain_toml(root, "yah-dev", MOUNTED_DOMAIN);
8100 assert!(route_headers_for_service(root, "yah-marketing")
8101 .unwrap()
8102 .contains("require-corp"));
8103 assert_eq!(route_headers_for_service(root, "some-other-svc").unwrap(), "[]");
8104 }
8105
8106 // ---- R749-T5: a broken table fails the DEPLOY, not the edge -----------
8107
8108 /// The manifest's `headers` map is hand-written TOML, so a header name with
8109 /// spaces in it is one keystroke away — and it survives serialization into
8110 /// a structurally-valid table that neither front door can apply. Fail at
8111 /// load, naming the domain, the route and the header, instead of shipping a
8112 /// binding the Worker throws on and an origin that refuses to boot.
8113 #[test]
8114 fn a_route_header_name_that_is_not_a_header_name_fails_the_load() {
8115 let tmp = tempfile::TempDir::new().unwrap();
8116 let root = tmp.path();
8117 write_marketing_service(root);
8118 write_domain_toml(
8119 root,
8120 "yah-dev",
8121 r#"
8122schema_version = 1
8123name = "yah-dev"
8124domain = "yah.dev"
8125front_door = "worker"
8126cdn_bucket = "yah-dev"
8127
8128[[routes]]
8129path = "/*"
8130mode = "static"
8131component = "yah-marketing/site"
8132headers = { "Cross Origin Opener Policy" = "same-origin" }
8133"#,
8134 );
8135 let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
8136 assert!(err.contains("yah-dev"), "{err}");
8137 assert!(err.contains("/*"), "{err}");
8138 assert!(err.contains("Cross Origin Opener Policy"), "{err}");
8139 assert!(err.contains("not a valid HTTP header name"), "{err}");
8140 }
8141
8142 /// A newline in a value is header injection if it ever reached the wire, so
8143 /// both doors reject it and so does this.
8144 #[test]
8145 fn a_route_header_value_that_is_not_a_header_value_fails_the_load() {
8146 let tmp = tempfile::TempDir::new().unwrap();
8147 let root = tmp.path();
8148 write_marketing_service(root);
8149 write_domain_toml(
8150 root,
8151 "yah-dev",
8152 r#"
8153schema_version = 1
8154name = "yah-dev"
8155domain = "yah.dev"
8156front_door = "worker"
8157cdn_bucket = "yah-dev"
8158
8159[[routes]]
8160path = "/*"
8161mode = "static"
8162component = "yah-marketing/site"
8163headers = { "X-Frame-Options" = "DENY\nSet-Cookie: pwned=1" }
8164"#,
8165 );
8166 let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
8167 assert!(err.contains("X-Frame-Options"), "{err}");
8168 assert!(err.contains("not a valid HTTP header value"), "{err}");
8169 }
8170
8171 /// The invariant this gate exists to hold: everything `route_headers_json`
8172 /// emits is applicable. A headerless route contributes no rule, so its path
8173 /// is not the table's business — only rules that ship are checked.
8174 #[test]
8175 fn a_headerless_route_is_not_subject_to_the_route_header_gate() {
8176 let tmp = tempfile::TempDir::new().unwrap();
8177 let root = tmp.path();
8178 write_two_component_service(root);
8179 write_domain_toml(root, "yah-dev", MOUNTED_DOMAIN);
8180 let cfg = CloudConfig::load(root).unwrap();
8181 cfg.domain("yah-dev")
8182 .unwrap()
8183 .validate_route_headers()
8184 .unwrap();
8185 }
8186
8187 /// A `bucket-direct` domain has no front door to set headers on, so it must
8188 /// not be picked up as a service's header source.
8189 #[test]
8190 fn route_headers_ignores_domains_that_are_not_route_driven() {
8191 let doms: BTreeMap<String, DomainConfig> = [(
8192 "cdn".to_string(),
8193 DomainConfig {
8194 schema_version: 1,
8195 name: "cdn".into(),
8196 domain: "cdn.yah.dev".into(),
8197 front_door: FrontDoor::BucketDirect,
8198 cdn_bucket: "yah-dev".into(),
8199 worker_bundle_path: None,
8200 routes: vec![],
8201 },
8202 )]
8203 .into_iter()
8204 .collect();
8205 assert!(domain_serving_service(&doms, "yah-marketing").is_none());
8206 }
8207
8208 #[test]
8209 fn a_mount_that_disagrees_with_its_route_path_is_rejected() {
8210 let tmp = tempfile::TempDir::new().unwrap();
8211 let root = tmp.path();
8212 write_two_component_service(root);
8213 write_domain_toml(
8214 root,
8215 "yah-dev",
8216 r#"
8217schema_version = 1
8218name = "yah-dev"
8219domain = "yah.dev"
8220front_door = "worker"
8221cdn_bucket = "yah-dev"
8222
8223[[routes]]
8224path = "/studio/*"
8225mode = "static"
8226component = "yah-marketing/app"
8227"#,
8228 );
8229 let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
8230 assert!(err.contains("mount = \"/app\""), "{err}");
8231 assert!(err.contains("/studio/*"), "{err}");
8232 }
8233
8234 /// The other direction: routing an unmounted component under a sub-path
8235 /// points requests at a prefix nothing published to.
8236 #[test]
8237 fn routing_an_unmounted_component_under_a_subpath_is_rejected() {
8238 let tmp = tempfile::TempDir::new().unwrap();
8239 let root = tmp.path();
8240 write_marketing_service(root);
8241 write_domain_toml(
8242 root,
8243 "yah-dev",
8244 r#"
8245schema_version = 1
8246name = "yah-dev"
8247domain = "yah.dev"
8248front_door = "worker"
8249cdn_bucket = "yah-dev"
8250
8251[[routes]]
8252path = "/docs/*"
8253mode = "static"
8254component = "yah-marketing/site"
8255"#,
8256 );
8257 let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
8258 assert!(err.contains("no `mount`"), "{err}");
8259 assert!(err.contains("/docs"), "{err}");
8260 }
8261
8262 #[test]
8263 fn mount_and_route_prefix_normalization_agree() {
8264 for m in ["/app", "app", "app/", "/app/"] {
8265 assert_eq!(normalize_mount(m), "app", "mount {m:?}");
8266 }
8267 assert_eq!(normalize_mount("/"), "");
8268 assert_eq!(route_path_prefix("/*"), "");
8269 assert_eq!(route_path_prefix("/app/*"), "app");
8270 assert_eq!(route_path_prefix("/app"), "app");
8271 assert_eq!(route_path_prefix("/"), "");
8272 }
8273
8274 #[test]
8275 fn worker_with_no_routes_is_rejected() {
8276 let tmp = tempfile::TempDir::new().unwrap();
8277 let root = tmp.path();
8278 write_domain_toml(
8279 root,
8280 "yah-dev",
8281 r#"
8282schema_version = 1
8283name = "yah-dev"
8284domain = "yah.dev"
8285front_door = "worker"
8286cdn_bucket = "yah-dev"
8287"#,
8288 );
8289 let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
8290 assert!(err.contains("front_door = \"worker\""), "{err}");
8291 assert!(err.contains("404"), "{err}");
8292 }
8293
8294 #[test]
8295 fn passway_with_no_routes_is_rejected_too() {
8296 let tmp = tempfile::TempDir::new().unwrap();
8297 let root = tmp.path();
8298 write_domain_toml(
8299 root,
8300 "yah-dev",
8301 r#"
8302schema_version = 1
8303name = "yah-dev"
8304domain = "yah.dev"
8305front_door = "passway"
8306cdn_bucket = "yah-dev"
8307"#,
8308 );
8309 let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
8310 assert!(err.contains("front_door = \"passway\""), "{err}");
8311 }
8312
8313 #[test]
8314 fn bucket_direct_without_routes_loads() {
8315 let tmp = tempfile::TempDir::new().unwrap();
8316 let root = tmp.path();
8317 // Exactly the shape .yah/domains/cdn-yah-dev.toml ships (W175: a pure
8318 // asset tier deliberately has no Worker behaviours).
8319 write_domain_toml(
8320 root,
8321 "cdn-yah-dev",
8322 r#"
8323schema_version = 1
8324name = "cdn-yah-dev"
8325domain = "cdn.yah.dev"
8326front_door = "bucket-direct"
8327cdn_bucket = "yah-dev"
8328"#,
8329 );
8330 let cfg = CloudConfig::load(root).unwrap();
8331 let dom = cfg.domain("cdn-yah-dev").expect("cdn-yah-dev domain");
8332 assert_eq!(dom.front_door, FrontDoor::BucketDirect);
8333 assert!(!dom.front_door.is_route_driven());
8334 }
8335
8336 #[test]
8337 fn front_door_round_trips_through_save() {
8338 let tmp = tempfile::TempDir::new().unwrap();
8339 let root = tmp.path();
8340 write_marketing_service(root);
8341 let dom = DomainConfig {
8342 schema_version: 1,
8343 name: "yah-dev".into(),
8344 domain: "yah.dev".into(),
8345 front_door: FrontDoor::Passway,
8346 cdn_bucket: "yah-dev".into(),
8347 worker_bundle_path: None,
8348 routes: vec![DomainRoute {
8349 headers: Default::default(),
8350 path: "/*".into(),
8351 mode: RouteMode::Static {
8352 component: "yah-marketing/site".into(),
8353 },
8354 }],
8355 };
8356 dom.save(root).unwrap();
8357 let cfg = CloudConfig::load(root).unwrap();
8358 assert_eq!(
8359 cfg.domain("yah-dev").unwrap().front_door,
8360 FrontDoor::Passway
8361 );
8362 }
8363
8364 // The four manifests this repo actually ships are asserted in
8365 // `tests/live_workspace_smoke.rs` — that's the only place with a
8366 // depth-agnostic path to the live `.yah/` tree and a skip path for the
8367 // standalone mirror checkout.
8368
8369 #[test]
8370 fn cross_ref_bails_on_missing_service() {
8371 let tmp = tempfile::TempDir::new().unwrap();
8372 let root = tmp.path();
8373 // No services declared at all — component ref must fail to resolve.
8374 let dom = DomainConfig {
8375 schema_version: 1,
8376 name: "yah-dev".into(),
8377 domain: "yah.dev".into(),
8378 front_door: FrontDoor::Worker,
8379 cdn_bucket: "yah-dev".into(),
8380 worker_bundle_path: None,
8381 routes: vec![DomainRoute {
8382 headers: Default::default(),
8383 path: "/".into(),
8384 mode: RouteMode::Static {
8385 component: "yah-marketing/site".into(),
8386 },
8387 }],
8388 };
8389 dom.save(root).unwrap();
8390
8391 let err = CloudConfig::load(root).unwrap_err();
8392 let msg = format!("{err:#}");
8393 assert!(msg.contains("no such service"), "got: {msg}");
8394 assert!(msg.contains("yah-marketing"), "got: {msg}");
8395 }
8396
8397 #[test]
8398 fn cross_ref_bails_on_missing_component() {
8399 let tmp = tempfile::TempDir::new().unwrap();
8400 let root = tmp.path();
8401 write_marketing_service(root); // has component id "site", not "elsewhere"
8402
8403 let dom = DomainConfig {
8404 schema_version: 1,
8405 name: "yah-dev".into(),
8406 domain: "yah.dev".into(),
8407 front_door: FrontDoor::Worker,
8408 cdn_bucket: "yah-dev".into(),
8409 worker_bundle_path: None,
8410 routes: vec![DomainRoute {
8411 headers: Default::default(),
8412 path: "/".into(),
8413 mode: RouteMode::Static {
8414 component: "yah-marketing/elsewhere".into(),
8415 },
8416 }],
8417 };
8418 dom.save(root).unwrap();
8419
8420 let err = CloudConfig::load(root).unwrap_err();
8421 let msg = format!("{err:#}");
8422 assert!(msg.contains("no component with id"), "got: {msg}");
8423 assert!(msg.contains("elsewhere"), "got: {msg}");
8424 }
8425
8426 #[test]
8427 fn cross_ref_bails_on_malformed_ref() {
8428 let tmp = tempfile::TempDir::new().unwrap();
8429 let root = tmp.path();
8430 write_marketing_service(root);
8431
8432 let dom = DomainConfig {
8433 schema_version: 1,
8434 name: "yah-dev".into(),
8435 domain: "yah.dev".into(),
8436 front_door: FrontDoor::Worker,
8437 cdn_bucket: "yah-dev".into(),
8438 worker_bundle_path: None,
8439 routes: vec![DomainRoute {
8440 headers: Default::default(),
8441 path: "/".into(),
8442 mode: RouteMode::Static {
8443 component: "no-slash-here".into(),
8444 },
8445 }],
8446 };
8447 dom.save(root).unwrap();
8448
8449 let err = CloudConfig::load(root).unwrap_err();
8450 let msg = format!("{err:#}");
8451 assert!(msg.contains("expected"), "got: {msg}");
8452 }
8453
8454 #[test]
8455 fn redirect_routes_skip_component_validation() {
8456 let tmp = tempfile::TempDir::new().unwrap();
8457 let root = tmp.path();
8458 // No services at all — redirect must still load cleanly because it
8459 // references nothing.
8460 let dom = DomainConfig {
8461 schema_version: 1,
8462 name: "yah-dev".into(),
8463 domain: "yah.dev".into(),
8464 front_door: FrontDoor::Worker,
8465 cdn_bucket: "yah-dev".into(),
8466 worker_bundle_path: None,
8467 routes: vec![DomainRoute {
8468 headers: Default::default(),
8469 path: "/old".into(),
8470 mode: RouteMode::Redirect {
8471 target: "https://yah.dev/blog".into(),
8472 status: 308,
8473 },
8474 }],
8475 };
8476 dom.save(root).unwrap();
8477
8478 let cfg = CloudConfig::load(root).unwrap();
8479 assert!(cfg.domain("yah-dev").is_some());
8480 }
8481
8482 #[test]
8483 fn name_must_match_file_stem() {
8484 let tmp = tempfile::TempDir::new().unwrap();
8485 let root = tmp.path();
8486 // Hand-write a file whose stem disagrees with its `name`.
8487 let dir = root.join(".yah").join("domains");
8488 std::fs::create_dir_all(&dir).unwrap();
8489 std::fs::write(
8490 dir.join("yah-dev.toml"),
8491 r#"schema_version = 1
8492name = "different-name"
8493domain = "yah.dev"
8494front_door = "bucket-direct"
8495cdn_bucket = "yah-dev"
8496"#,
8497 )
8498 .unwrap();
8499
8500 let err = CloudConfig::load(root).unwrap_err();
8501 let msg = format!("{err:#}");
8502 assert!(msg.contains("must match the file stem"), "got: {msg}");
8503 }
8504
8505 #[test]
8506 fn net_alias_tier_subdomain_manifest_loads_and_cross_refs() {
8507 // R561-F2: a per-tenant subdomain manifest on the net.yah.dev wildcard
8508 // alias tier is just a DomainConfig whose `domain` is `<name>.net.yah.dev`
8509 // and whose static route cross-refs the tenant's service component.
8510 // This is exactly the shape .yah/domains/scrabcake-net-yah-dev.toml ships.
8511 let tmp = tempfile::TempDir::new().unwrap();
8512 let root = tmp.path();
8513 write_marketing_service(root); // service "yah-marketing", component "site"
8514
8515 let dom = DomainConfig {
8516 schema_version: 1,
8517 name: "tenant-net-yah-dev".into(),
8518 domain: "tenant.net.yah.dev".into(),
8519 front_door: FrontDoor::Worker,
8520 cdn_bucket: "net-yah-dev".into(), // shared per-tier bucket
8521 worker_bundle_path: None,
8522 routes: vec![DomainRoute {
8523 headers: Default::default(),
8524 path: "/*".into(),
8525 mode: RouteMode::Static {
8526 component: "yah-marketing/site".into(),
8527 },
8528 }],
8529 };
8530 dom.save(root).unwrap();
8531
8532 let cfg = CloudConfig::load(root).unwrap();
8533 let dom = cfg
8534 .domain("tenant-net-yah-dev")
8535 .expect("net-tier subdomain manifest should load");
8536 assert_eq!(dom.domain, "tenant.net.yah.dev");
8537 assert_eq!(dom.cdn_bucket, "net-yah-dev");
8538 }
8539
8540 // ─── R572-F3: NodeAllocatable + taints ──────────────────────────────────
8541
8542 #[test]
8543 fn machine_allocatable_round_trips() {
8544 let toml_src = r#"
8545name = "us-west-001"
8546provider = "static"
8547mesh_tags = ["tag:cloud-runner"]
8548[allocatable]
8549memory_mb = 3800
8550cpu_millis = 2000
8551"#;
8552 let m: MachineConfig = toml::from_str(toml_src).unwrap();
8553 let a = m.allocatable.as_ref().expect("allocatable should parse");
8554 assert_eq!(a.memory_mb, 3800);
8555 assert_eq!(a.cpu_millis, 2000);
8556
8557 let s = toml::to_string(&m).unwrap();
8558 let back: MachineConfig = toml::from_str(&s).unwrap();
8559 let a2 = back.allocatable.as_ref().unwrap();
8560 assert_eq!(a2.memory_mb, 3800);
8561 assert_eq!(a2.cpu_millis, 2000);
8562 }
8563
8564 #[test]
8565 fn machine_taints_round_trips() {
8566 let toml_src = r#"
8567name = "us-south-001"
8568provider = "static"
8569mesh_tags = ["tag:cloud-runner"]
8570taints = ["no-appliance"]
8571"#;
8572 let m: MachineConfig = toml::from_str(toml_src).unwrap();
8573 assert_eq!(m.taints, vec!["no-appliance"]);
8574
8575 let s = toml::to_string(&m).unwrap();
8576 let back: MachineConfig = toml::from_str(&s).unwrap();
8577 assert_eq!(back.taints, vec!["no-appliance"]);
8578 }
8579
8580 #[test]
8581 fn machine_allocatable_absent_is_none() {
8582 let toml_src = "name = \"node\"\nprovider = \"static\"\nmesh_tags = []\n";
8583 let m: MachineConfig = toml::from_str(toml_src).unwrap();
8584 assert!(m.allocatable.is_none());
8585 assert!(m.taints.is_empty());
8586 }
8587
8588 #[test]
8589 fn machine_allocatable_skipped_when_none() {
8590 let m = make_machine("node", vec![]);
8591 let s = toml::to_string(&m).unwrap();
8592 assert!(
8593 !s.contains("allocatable"),
8594 "None allocatable must be omitted: {s}"
8595 );
8596 assert!(!s.contains("taints"), "empty taints must be omitted: {s}");
8597 }
8598
8599 #[test]
8600 fn machine_multiple_taints_round_trip() {
8601 let toml_src = r#"
8602name = "quarantined"
8603provider = "static"
8604mesh_tags = ["tag:build-worker"]
8605taints = ["no-server", "no-appliance", "no-job"]
8606"#;
8607 let m: MachineConfig = toml::from_str(toml_src).unwrap();
8608 assert_eq!(m.taints.len(), 3);
8609 assert!(m.taints.contains(&"no-server".to_string()));
8610 assert!(m.taints.contains(&"no-appliance".to_string()));
8611 assert!(m.taints.contains(&"no-job".to_string()));
8612 // R742-T4: every key here is one the scheduler reads. This fixture
8613 // used to carry `no-voter`, which none of them is.
8614 assert!(m.inert_taints().is_empty());
8615 }
8616
8617 // ─── R742-T4 (W305): inert-taint classification ─────────────────────────
8618
8619 #[test]
8620 fn every_archetype_repel_key_is_live() {
8621 for arch in LifecycleArchetype::ALL {
8622 let key = format!("no-{}", arch.taint_key());
8623 assert_eq!(
8624 taint_effect(&key),
8625 TaintEffect::Repels(arch),
8626 "{key} must repel {arch:?}"
8627 );
8628 }
8629 }
8630
8631 #[test]
8632 fn public_ip_is_an_affinity_key_not_an_inert_one() {
8633 assert_eq!(
8634 taint_effect(workload_spec::PUBLIC_IP_TAINT),
8635 TaintEffect::Attracts
8636 );
8637 }
8638
8639 #[test]
8640 fn a_free_form_taint_is_inert_and_says_so() {
8641 // W305's headline example: `taints = ["qa"]` parsed clean and did
8642 // nothing. Environment is not expressible as a taint.
8643 assert_eq!(taint_effect("qa"), TaintEffect::Inert);
8644 // And the one that actually cost fleet state: `no-voter` reads as an
8645 // exclusion and excludes nothing — "voter" is not an archetype.
8646 assert_eq!(taint_effect("no-voter"), TaintEffect::Inert);
8647 // A near-miss on a real key is inert too, not silently forgiven.
8648 assert_eq!(taint_effect("no-servers"), TaintEffect::Inert);
8649
8650 let m = make_machine_with_capacity(
8651 "dev-pi",
8652 8192,
8653 4000,
8654 vec!["no-appliance", "no-voter", "qa"],
8655 );
8656 assert_eq!(m.inert_taints(), vec!["no-voter", "qa"]);
8657 }
8658
8659 #[test]
8660 fn an_inert_taint_changes_no_placement_decision() {
8661 // The reason this is a lint and not a behaviour change: the guard's
8662 // whole premise is that these keys are invisible to `matches`.
8663 let clean = make_machine_with_capacity("n", 8192, 4000, vec![]);
8664 let noisy = make_machine_with_capacity("n", 8192, 4000, vec!["no-voter", "qa"]);
8665 for arch in LifecycleArchetype::ALL {
8666 let req = RequiredSpec {
8667 repel_archetypes: vec![arch],
8668 ..Default::default()
8669 };
8670 assert_eq!(req.matches(&clean), req.matches(&noisy));
8671 }
8672 }
8673
8674 #[test]
8675 fn live_taint_keys_lists_the_whole_legal_vocabulary() {
8676 assert_eq!(
8677 live_taint_keys(),
8678 vec!["no-appliance", "no-job", "no-server", "public-ip"]
8679 );
8680 }
8681
8682 // ─── R742-F1 (W305): sovereign groups ───────────────────────────────────
8683
8684 /// A machine in `group`, with the role left unwritten — which is the state
8685 /// of every machine TOML that predates R605-F12 and resolves to `voter`.
8686 fn in_group(name: &str, group: Option<&str>) -> MachineConfig {
8687 MachineConfig {
8688 sovereign_group: group.map(String::from),
8689 ..make_machine(name, vec![])
8690 }
8691 }
8692
8693 /// A machine in `group` with its quorum eligibility stated (R605-F12).
8694 fn in_group_as(name: &str, group: &str, role: SovereignRole) -> MachineConfig {
8695 MachineConfig {
8696 sovereign_group: Some(group.to_string()),
8697 sovereign_role: Some(role),
8698 ..make_machine(name, vec![])
8699 }
8700 }
8701
8702 #[test]
8703 fn a_join_within_one_sovereign_group_is_permitted() {
8704 assert_eq!(
8705 judge_join(
8706 &in_group("us-west-013", Some("dev")),
8707 &in_group("us-west-011", Some("dev")),
8708 ),
8709 JoinVerdict::Permit
8710 );
8711 }
8712
8713 /// The case the field exists for: before it, the only thing standing
8714 /// between a dev Pi and the prod quorum was a comment in a TOML.
8715 #[test]
8716 fn a_cross_group_join_is_refused_naming_both_groups() {
8717 let verdict = judge_join(
8718 &in_group("us-west-011", Some("dev")),
8719 &in_group("us-west-001", Some("prod")),
8720 );
8721 let JoinVerdict::Refuse(msg) = verdict else {
8722 panic!("a dev node joining prod must be refused: {verdict:?}");
8723 };
8724 // A refusal that does not name what it saw is one the operator has to
8725 // go and reconstruct, so it gets worked around instead of fixed.
8726 assert!(msg.contains("us-west-011") && msg.contains("us-west-001"), "{msg}");
8727 assert!(msg.contains("dev") && msg.contains("prod"), "{msg}");
8728 }
8729
8730 /// `None` is a declaration ("standalone, in no group"), not a gap — so
8731 /// growing prod with an unstamped box is a cross-group join too, and the
8732 /// refusal has to say which file makes it legal.
8733 #[test]
8734 fn an_undeclared_node_cannot_join_a_declared_group() {
8735 let verdict = judge_join(
8736 &in_group("us-west-002", None),
8737 &in_group("us-west-001", Some("prod")),
8738 );
8739 let JoinVerdict::Refuse(msg) = verdict else {
8740 panic!("an unstamped node joining prod must be refused: {verdict:?}");
8741 };
8742 assert!(
8743 msg.contains(".yah/infra/machines/us-west-002.toml"),
8744 "the refusal must name the file to stamp: {msg}"
8745 );
8746 }
8747
8748 #[test]
8749 fn a_declared_node_cannot_join_a_standalone_target() {
8750 // us-west-003 is `mode: standalone` on purpose; it is not a group of
8751 // one waiting to be grown.
8752 let verdict = judge_join(
8753 &in_group("us-west-001", Some("prod")),
8754 &in_group("us-west-003", None),
8755 );
8756 assert!(matches!(verdict, JoinVerdict::Refuse(msg) if msg.contains("us-west-003")));
8757 }
8758
8759 #[test]
8760 fn two_undeclared_nodes_cannot_form_an_undeclared_group() {
8761 let verdict = judge_join(
8762 &in_group("us-west-002", None),
8763 &in_group("us-west-015", None),
8764 );
8765 assert!(
8766 matches!(&verdict, JoinVerdict::Refuse(msg) if msg.contains("us-west-002")
8767 && msg.contains("us-west-015")),
8768 "forming a group nobody declared must be refused, naming both: {verdict:?}"
8769 );
8770 }
8771
8772 // ─── R605-F12: the voting axis ──────────────────────────────────────────
8773
8774 /// The whole ticket in one assertion. us-west-003 is a member of prod —
8775 /// same secrets, same upgrade cadence, same destruction — and must never
8776 /// hold a prod raft seat. Before the role axis, the only thing refusing it
8777 /// was its *absent* group stamp, so writing down the truth above would have
8778 /// removed the guard.
8779 #[test]
8780 fn a_non_voting_member_is_refused_into_its_own_group() {
8781 let verdict = judge_join(
8782 &in_group_as("us-west-003", "prod", SovereignRole::NonVoter),
8783 &in_group_as("us-west-001", "prod", SovereignRole::Voter),
8784 );
8785 let JoinVerdict::Refuse(msg) = verdict else {
8786 panic!("a non-voting prod member must not join the prod quorum: {verdict:?}");
8787 };
8788 assert!(msg.contains("us-west-003") && msg.contains("NON-VOTING"), "{msg}");
8789 // The refusal must not blame the group: both sides say "prod", and a
8790 // cross-group message here would read as a bug in the check itself.
8791 assert!(!msg.contains("cross-group"), "{msg}");
8792 assert!(
8793 msg.contains(".yah/infra/machines/us-west-003.toml"),
8794 "the refusal must name the file that decides it: {msg}"
8795 );
8796 }
8797
8798 /// Read from the other end: a box declared non-voting has no quorum seat to
8799 /// be grown, so it cannot be a join target either.
8800 #[test]
8801 fn a_non_voting_target_has_no_quorum_to_grow() {
8802 let verdict = judge_join(
8803 &in_group_as("us-west-001", "prod", SovereignRole::Voter),
8804 &in_group_as("us-west-003", "prod", SovereignRole::NonVoter),
8805 );
8806 assert!(
8807 matches!(&verdict, JoinVerdict::Refuse(msg) if msg.contains("the target")
8808 && msg.contains("us-west-003")),
8809 "{verdict:?}"
8810 );
8811 }
8812
8813 /// A non-voter joining a *standalone* target is refused for two reasons at
8814 /// once, and the message must pick the one whose fix would actually work.
8815 /// Naming the role here would send the operator to flip `sovereign_role`
8816 /// and come back to the same refusal.
8817 #[test]
8818 fn a_refusal_names_the_group_when_fixing_the_role_would_not_help() {
8819 let verdict = judge_join(
8820 &in_group_as("us-west-003", "prod", SovereignRole::NonVoter),
8821 &in_group("us-west-002", None),
8822 );
8823 let JoinVerdict::Refuse(msg) = verdict else {
8824 panic!("a standalone target has no group to join: {verdict:?}");
8825 };
8826 assert!(
8827 msg.contains(".yah/infra/machines/us-west-002.toml"),
8828 "the refusal must point at the target's missing group stamp: {msg}"
8829 );
8830 assert!(!msg.contains("NON-VOTING"), "{msg}");
8831 }
8832
8833 /// The back-compat seam, pinned: the six nodes stamped before R605-F12
8834 /// write no role, and an absent role means what declaring a group has
8835 /// always meant. If this flips, the live prod and dev quorums stop being
8836 /// growable on a config the operator never edited.
8837 #[test]
8838 fn an_unwritten_role_still_joins_its_group() {
8839 let joiner = in_group("us-west-013", Some("dev"));
8840 assert_eq!(joiner.sovereign_role, None);
8841 assert_eq!(
8842 judge_join(&joiner, &in_group("us-west-011", Some("dev"))),
8843 JoinVerdict::Permit
8844 );
8845 assert_eq!(
8846 judge_join(
8847 &joiner,
8848 &in_group_as("us-west-011", "dev", SovereignRole::Voter)
8849 ),
8850 JoinVerdict::Permit
8851 );
8852 }
8853
8854 /// A non-voting member is still a *member*, and the two claims must not be
8855 /// conflated: `sovereign_membership()` reports the group either way, so a
8856 /// consumer asking "is this box in prod's blast radius" gets yes.
8857 #[test]
8858 fn a_non_voter_is_still_in_the_group_it_names() {
8859 let m = in_group_as("us-west-003", "prod", SovereignRole::NonVoter);
8860 assert_eq!(m.sovereign_membership().group, Some("prod"));
8861 assert!(!m.sovereign_membership().role.is_voter());
8862
8863 // …and the group-membership query the fleet reads is unaffected by it.
8864 let cfg = make_empty_cfg(vec![
8865 m,
8866 in_group_as("us-west-001", "prod", SovereignRole::Voter),
8867 ]);
8868 assert_eq!(
8869 cfg.machines_in_group("prod")
8870 .iter()
8871 .map(|m| m.name.as_str())
8872 .collect::<Vec<_>>(),
8873 vec!["us-west-003", "us-west-001"]
8874 );
8875 }
8876
8877 /// The role travels through TOML in one spelling, and an absent one stays
8878 /// absent on the way back out — otherwise every machine file would grow a
8879 /// `sovereign_role = "voter"` line the operator never wrote, and the
8880 /// unroled-member lint would have nothing left to find.
8881 #[test]
8882 fn sovereign_role_round_trips_and_is_omitted_when_unwritten() {
8883 let m: MachineConfig = toml::from_str(
8884 r#"
8885name = "us-west-003"
8886provider = "static"
8887region = "us-west"
8888arch = "x86_64"
8889mesh_tags = []
8890sovereign_group = "prod"
8891sovereign_role = "non-voter"
8892"#,
8893 )
8894 .unwrap();
8895 assert_eq!(m.sovereign_role, Some(SovereignRole::NonVoter));
8896 assert!(toml::to_string(&m)
8897 .unwrap()
8898 .contains(r#"sovereign_role = "non-voter""#));
8899
8900 let unwritten = MachineConfig {
8901 sovereign_role: None,
8902 ..m
8903 };
8904 assert!(!toml::to_string(&unwritten)
8905 .unwrap()
8906 .contains("sovereign_role"));
8907 }
8908
8909 /// The invariant the ticket is most explicit about: a sovereign group is a
8910 /// blast radius, not a filter. If this ever fails, `matches` has grown an
8911 /// axis it must not have and dev-mode workloads have silently become
8912 /// unschedulable on the dev group.
8913 #[test]
8914 fn sovereign_group_is_not_a_placement_input() {
8915 let standalone = in_group("n", None);
8916 let grouped = in_group("n", Some("dev"));
8917 let other = in_group("n", Some("prod"));
8918
8919 for spec in [
8920 RequiredSpec::default(),
8921 RequiredSpec {
8922 regions: vec!["us-west".into()],
8923 ..Default::default()
8924 },
8925 RequiredSpec {
8926 repel_archetypes: vec![LifecycleArchetype::Appliance],
8927 ..Default::default()
8928 },
8929 ] {
8930 let baseline = spec.matches(&standalone);
8931 assert_eq!(spec.matches(&grouped), baseline);
8932 assert_eq!(spec.matches(&other), baseline);
8933 }
8934 }
8935
8936 // ─── R742-F3 (W305): group → machine set, and group-scoped admission ────
8937
8938 /// The primitive `migrate --to` needs and `rollout plan` still lacks
8939 /// (W314 gap 1): a group exists only as the set of machines naming it, so
8940 /// membership has to be derived rather than declared anywhere.
8941 #[test]
8942 fn machines_in_group_derives_membership_from_the_declarations() {
8943 let cfg = make_empty_cfg(vec![
8944 in_group("us-west-001", Some("prod")),
8945 in_group("us-west-011", Some("dev")),
8946 in_group("us-west-013", Some("dev")),
8947 in_group("us-west-002", None),
8948 ]);
8949
8950 let dev: Vec<&str> = cfg
8951 .machines_in_group("dev")
8952 .iter()
8953 .map(|m| m.name.as_str())
8954 .collect();
8955 assert_eq!(dev, vec!["us-west-011", "us-west-013"]);
8956 assert_eq!(cfg.machines_in_group("prod").len(), 1);
8957
8958 // Standalone is "in no group", not "in a group called none" — so an
8959 // unstamped box is never swept into a migration target.
8960 assert!(cfg.machines_in_group("").is_empty());
8961 assert!(cfg.machines_in_group("staging").is_empty());
8962 }
8963
8964 #[test]
8965 fn declared_sovereign_groups_is_the_vocabulary_a_bad_target_is_named_against() {
8966 let cfg = make_empty_cfg(vec![
8967 in_group("a", Some("prod")),
8968 in_group("b", Some("dev")),
8969 in_group("c", Some("prod")),
8970 in_group("d", None),
8971 ]);
8972 // Sorted + deduped, and standalone contributes nothing.
8973 assert_eq!(cfg.declared_sovereign_groups(), vec!["dev", "prod"]);
8974 assert!(make_empty_cfg(vec![in_group("a", None)])
8975 .declared_sovereign_groups()
8976 .is_empty());
8977 }
8978
8979 /// Group-scoped admission must be the SAME predicate as unscoped
8980 /// admission, only over fewer candidates. If it ever diverges, `migrate`
8981 /// becomes a way to place a workload somewhere `yah cloud apply` would
8982 /// refuse — which is exactly the silent routing-around W305 exists to stop.
8983 #[test]
8984 fn admit_workload_in_group_narrows_candidates_without_changing_the_predicate() {
8985 let mut prod = in_group("us-west-001", Some("prod"));
8986 prod.mesh_tags = vec!["tag:cloud-runner".into()];
8987 let mut dev_repels = in_group("us-west-011", Some("dev"));
8988 dev_repels.taints = vec!["no-appliance".into()];
8989 let mut dev_ok = in_group("us-west-013", Some("dev"));
8990 dev_ok.mesh_tags = vec!["tag:cloud-runner".into()];
8991
8992 let cfg = make_empty_cfg(vec![prod, dev_repels, dev_ok]);
8993
8994 let mut ws = ws_with_selector(None);
8995 ws.archetype = Some(LifecycleArchetype::Appliance);
8996
8997 // Unscoped picks the first match in declaration order.
8998 assert_eq!(cfg.admit_workload(&ws).unwrap().name, "us-west-001");
8999 // Scoped skips the repelling dev node and lands on the other one —
9000 // the taint is honoured, not bypassed.
9001 assert_eq!(
9002 cfg.admit_workload_in_group(&ws, "dev").unwrap().name,
9003 "us-west-013"
9004 );
9005 }
9006
9007 #[test]
9008 fn admit_workload_in_group_distinguishes_an_empty_group_from_a_repelling_one() {
9009 let mut dev = in_group("us-west-011", Some("dev"));
9010 dev.taints = vec!["no-appliance".into()];
9011 let cfg = make_empty_cfg(vec![in_group("us-west-001", Some("prod")), dev]);
9012
9013 let mut ws = ws_with_selector(None);
9014 ws.archetype = Some(LifecycleArchetype::Appliance);
9015
9016 // A group nobody declares names the legal vocabulary, because a typo
9017 // is the realistic cause and "no candidates" would send the operator
9018 // hunting for a placement problem that does not exist.
9019 let missing = cfg.admit_workload_in_group(&ws, "stagng").unwrap_err().to_string();
9020 assert!(missing.contains("no machine declares"), "{missing}");
9021 assert!(missing.contains("dev") && missing.contains("prod"), "{missing}");
9022
9023 // A group that exists but refuses names the machines it tried.
9024 let repelled = cfg.admit_workload_in_group(&ws, "dev").unwrap_err().to_string();
9025 assert!(repelled.contains("us-west-011"), "{repelled}");
9026 }
9027
9028 #[test]
9029 fn sovereign_group_round_trips_and_is_omitted_when_standalone() {
9030 let src = r#"
9031name = "us-west-011"
9032provider = "static"
9033mesh_tags = []
9034sovereign_group = "dev"
9035"#;
9036 let m: MachineConfig = toml::from_str(src).unwrap();
9037 assert_eq!(m.sovereign_group.as_deref(), Some("dev"));
9038 assert!(toml::to_string(&m).unwrap().contains("sovereign_group"));
9039
9040 // A machine that predates the field parses as standalone and does not
9041 // grow the key back on write.
9042 let legacy: MachineConfig =
9043 toml::from_str("name = \"us-west-002\"\nprovider = \"static\"\nmesh_tags = []\n")
9044 .unwrap();
9045 assert_eq!(legacy.sovereign_group, None);
9046 assert!(!toml::to_string(&legacy).unwrap().contains("sovereign_group"));
9047 }
9048
9049 // ─── R572-F5: capacity floor + absolute (untolerable) taints ────────────
9050
9051 fn make_machine_with_capacity(
9052 name: &str,
9053 memory_mb: u32,
9054 cpu_millis: u32,
9055 taints: Vec<&str>,
9056 ) -> MachineConfig {
9057 MachineConfig {
9058 allocatable: Some(NodeAllocatable {
9059 memory_mb,
9060 cpu_millis,
9061 }),
9062 taints: taints.into_iter().map(String::from).collect(),
9063 ..make_machine(name, vec![])
9064 }
9065 }
9066
9067 fn server_spec(memory_mb: u32, cpu_millis: u32) -> WorkloadSpec {
9068 use workload_spec::{ImageRef, LifecycleArchetype, ResourceLimits, TierTag};
9069 let mut ws = WorkloadSpec::for_forge(
9070 "f5-test",
9071 ImageRef {
9072 registry: "localhost".into(),
9073 repository: "test".into(),
9074 tag: "latest".into(),
9075 digest: workload_spec::testing::test_digest(),
9076 },
9077 TierTag("infra".into()),
9078 vec![],
9079 );
9080 ws.archetype = Some(LifecycleArchetype::Server);
9081 ws.resources = ResourceLimits {
9082 memory_mb,
9083 cpu_millis,
9084 ephemeral_storage_mb: 0,
9085 };
9086 // These are SERVER specs that borrow `for_forge` as a constructor
9087 // shortcut, so drop the forge memory request it stamps on — otherwise
9088 // every spec here silently requests the forge default instead of the
9089 // `memory_mb` the caller passed, and the capacity-floor tests below
9090 // stop testing their own argument. A server workload declares no
9091 // request, which is the documented fall-back-to-`resources.memory_mb`
9092 // path (`WorkloadSpec::memory_request_mb`).
9093 ws.annotations
9094 .remove(workload_spec::MEMORY_REQUEST_ANNOTATION);
9095 ws
9096 }
9097
9098 fn appliance_spec_ws(memory_mb: u32, cpu_millis: u32) -> WorkloadSpec {
9099 use workload_spec::LifecycleArchetype;
9100 let mut ws = server_spec(memory_mb, cpu_millis);
9101 ws.archetype = Some(LifecycleArchetype::Appliance);
9102 ws
9103 }
9104
9105 #[test]
9106 fn capacity_floor_rejects_undersized_node() {
9107 let cfg = make_empty_cfg(vec![make_machine_with_capacity("small", 256, 500, vec![])]);
9108 let ws = server_spec(512, 1000); // demands more than available
9109 assert!(cfg.admit_workload(&ws).is_err());
9110 }
9111
9112 #[test]
9113 fn capacity_floor_accepts_exact_fit() {
9114 let cfg = make_empty_cfg(vec![make_machine_with_capacity("exact", 512, 1000, vec![])]);
9115 let ws = server_spec(512, 1000);
9116 assert_eq!(cfg.admit_workload(&ws).unwrap().name, "exact");
9117 }
9118
9119 #[test]
9120 fn capacity_floor_passes_when_allocatable_absent() {
9121 // A machine with no allocatable block skips the capacity check (no data).
9122 let cfg = make_empty_cfg(vec![make_machine("no-alloc", vec![])]);
9123 let ws = server_spec(99999, 99999); // would exceed any real node
9124 assert_eq!(cfg.admit_workload(&ws).unwrap().name, "no-alloc");
9125 }
9126
9127 #[test]
9128 fn taint_repulsion_blocks_appliance_on_no_appliance_node() {
9129 let cfg = make_empty_cfg(vec![make_machine_with_capacity(
9130 "south",
9131 1024,
9132 2000,
9133 vec!["no-appliance"],
9134 )]);
9135 let ws = appliance_spec_ws(256, 500);
9136 assert!(
9137 cfg.admit_workload(&ws).is_err(),
9138 "appliance must be repelled by no-appliance taint"
9139 );
9140 }
9141
9142 #[test]
9143 fn taint_repulsion_allows_server_on_no_appliance_node() {
9144 // "no-appliance" only repels Appliance workloads; servers are unaffected.
9145 let cfg = make_empty_cfg(vec![make_machine_with_capacity(
9146 "south",
9147 1024,
9148 2000,
9149 vec!["no-appliance"],
9150 )]);
9151 let ws = server_spec(256, 500);
9152 assert_eq!(cfg.admit_workload(&ws).unwrap().name, "south");
9153 }
9154
9155 #[test]
9156 fn taint_repulsion_job_not_blocked_by_no_server() {
9157 use workload_spec::LifecycleArchetype;
9158 let cfg = make_empty_cfg(vec![make_machine_with_capacity(
9159 "build-box",
9160 8192,
9161 4000,
9162 vec!["no-server", "no-appliance"],
9163 )]);
9164 let mut ws = server_spec(256, 500);
9165 ws.archetype = Some(LifecycleArchetype::Job);
9166 // Job only repelled by "no-job"; "no-server" and "no-appliance" don't affect it.
9167 assert_eq!(cfg.admit_workload(&ws).unwrap().name, "build-box");
9168 }
9169
9170 #[test]
9171 fn requires_taint_affinity_blocks_placement_without_it() {
9172 use workload_spec::{LifecycleArchetype, PUBLIC_IP_TAINT, REQUIRES_TAINT_ANNOTATION};
9173 // Simulate the passway ingress appliance: requires "public-ip" taint.
9174 let mut ws = appliance_spec_ws(256, 512);
9175 ws.archetype = Some(LifecycleArchetype::Appliance);
9176 ws.annotations
9177 .insert(REQUIRES_TAINT_ANNOTATION.into(), PUBLIC_IP_TAINT.into());
9178
9179 // Node without the taint: rejected.
9180 let cfg = make_empty_cfg(vec![make_machine_with_capacity(
9181 "no-pip",
9182 2048,
9183 2000,
9184 vec![],
9185 )]);
9186 assert!(cfg.admit_workload(&ws).is_err());
9187
9188 // Node with the taint: accepted.
9189 let cfg = make_empty_cfg(vec![make_machine_with_capacity(
9190 "pub-node",
9191 2048,
9192 2000,
9193 vec!["public-ip"],
9194 )]);
9195 assert_eq!(cfg.admit_workload(&ws).unwrap().name, "pub-node");
9196 }
9197
9198 #[test]
9199 fn w244_fleet_scenario_appliance_rejected_from_south_and_west002() {
9200 // Full W244 fleet table scenario:
9201 // us-west-001/east-001: no taints, large capacity → appliance lands here
9202 // us-south-001: no-appliance taint → appliance rejected
9203 // us-west-002: no-server, no-appliance → appliance rejected
9204 let cfg = make_empty_cfg(vec![
9205 make_machine_with_capacity("us-south-001", 512, 1000, vec!["no-appliance"]),
9206 make_machine_with_capacity(
9207 "us-west-002",
9208 16384,
9209 8000,
9210 vec!["no-server", "no-appliance"],
9211 ),
9212 make_machine_with_capacity("us-west-001", 4096, 4000, vec![]),
9213 ]);
9214 let ws = appliance_spec_ws(256, 500);
9215 // Skips south (no-appliance) and west-002 (no-appliance), lands on west-001.
9216 assert_eq!(cfg.admit_workload(&ws).unwrap().name, "us-west-001");
9217 }
9218
9219 #[test]
9220 fn w244_fleet_scenario_job_lands_on_west002_first() {
9221 use workload_spec::LifecycleArchetype;
9222 // Jobs should prefer (or at least land on) the job-only box.
9223 let cfg = make_empty_cfg(vec![
9224 make_machine_with_capacity("us-west-001", 4096, 4000, vec![]),
9225 make_machine_with_capacity(
9226 "us-west-002",
9227 16384,
9228 8000,
9229 vec!["no-server", "no-appliance"],
9230 ),
9231 ]);
9232 let mut ws = server_spec(256, 500);
9233 ws.archetype = Some(LifecycleArchetype::Job);
9234 // No fleet node declares `no-job`, so a Job is repelled by nothing;
9235 // west-001 comes first in declaration order (greedy, no preference),
9236 // which is the expected tie-break. Note this is *absence of a repel
9237 // key*, not toleration — no workload can tolerate a taint (W305).
9238 let picked = cfg.admit_workload(&ws).unwrap();
9239 // Both are eligible.
9240 assert!(
9241 picked.name == "us-west-001" || picked.name == "us-west-002",
9242 "job must land on an eligible node, got {}",
9243 picked.name
9244 );
9245 }
9246
9247 #[test]
9248 fn r569_f4_macos_node_taints_keep_cloud_critical_off_but_admit_build_jobs() {
9249 use workload_spec::LifecycleArchetype;
9250 // R569-F4: the headless M2 (us-west-015) joins the fleet as a
9251 // build-worker but must never take cloud-critical load. It carries the
9252 // same repel set as the x86 build-worker (`no-server, no-appliance` —
9253 // see .yah/infra/machines/us-west-015.toml). This pins that intent:
9254 // with a plain cloud node available beside the Mac, every
9255 // cloud-critical archetype lands on the cloud node and never the Mac;
9256 // build Jobs (the Mac's actual purpose) remain eligible on it.
9257 //
9258 // R742-T4: `no-voter` used to sit in this set and in the TOML. It was
9259 // never read here — there is no "voter" workload archetype — and
9260 // R569-F3's learner-only join is what actually keeps the box out of
9261 // quorum. It is now rejected by `yah cloud validate` as inert.
9262 let mac_taints = vec!["no-server", "no-appliance"];
9263 let fleet = || {
9264 make_empty_cfg(vec![
9265 make_machine_with_capacity("us-west-015", 24576, 8000, mac_taints.clone()),
9266 make_machine_with_capacity("us-west-001", 4096, 4000, vec![]),
9267 ])
9268 };
9269
9270 // A cloud-critical Server workload is repelled from the Mac and lands
9271 // on the untainted cloud node.
9272 let cfg = fleet();
9273 assert_eq!(
9274 cfg.admit_workload(&server_spec(256, 500)).unwrap().name,
9275 "us-west-001",
9276 "a Server workload must never land on the no-server Mac node"
9277 );
9278
9279 // Same for an Appliance (pinned/stateful cloud-critical) workload.
9280 let cfg = fleet();
9281 assert_eq!(
9282 cfg.admit_workload(&appliance_spec_ws(256, 500))
9283 .unwrap()
9284 .name,
9285 "us-west-001",
9286 "an Appliance workload must never land on the no-appliance Mac node"
9287 );
9288
9289 // Sharpest repulsion proof: with ONLY the Mac in the fleet, a
9290 // cloud-critical Server workload is rejected outright — the taint keeps
9291 // it off even when that means nowhere to run.
9292 let mac_only = make_empty_cfg(vec![make_machine_with_capacity(
9293 "us-west-015",
9294 24576,
9295 8000,
9296 mac_taints.clone(),
9297 )]);
9298 assert!(
9299 mac_only.admit_workload(&server_spec(256, 500)).is_err(),
9300 "a Server workload must be repelled from a Mac-only fleet, not admitted"
9301 );
9302
9303 // But the Mac's real job — build/forge workloads — IS admitted on it:
9304 // it tolerates every fleet taint (there is no `no-job`).
9305 let mut job = server_spec(256, 500);
9306 job.archetype = Some(LifecycleArchetype::Job);
9307 assert_eq!(
9308 mac_only.admit_workload(&job).unwrap().name,
9309 "us-west-015",
9310 "a build Job must still be admitted on the Mac build-worker"
9311 );
9312 }
9313
9314 // ─── R615-F1: linked infra sources (`.yah/infra/sources.toml`) ─────────
9315
9316 #[test]
9317 fn sources_load_is_empty_when_the_file_is_absent() {
9318 // "Every camp without linked infra has none" — which today is every
9319 // camp — must not be an error.
9320 let tmp = tempfile::TempDir::new().unwrap();
9321 let cfg = SourcesConfig::load(tmp.path()).unwrap();
9322 assert_eq!(cfg, SourcesConfig::default());
9323 assert!(cfg.source.is_empty());
9324 assert_eq!(cfg.schema_version, 1);
9325 }
9326
9327 #[test]
9328 fn sources_parses_a_path_kind_exactly_like_w274s_example() {
9329 let tmp = tempfile::TempDir::new().unwrap();
9330 std::fs::write(
9331 tmp.path().join("sources.toml"),
9332 r#"
9333schema_version = 1
9334
9335[[source]]
9336owner = "yah"
9337kind = "path"
9338path = "../yah"
9339mode = "read-only"
9340"#,
9341 )
9342 .unwrap();
9343 let cfg = SourcesConfig::load(tmp.path()).unwrap();
9344 assert_eq!(cfg.source.len(), 1);
9345 let s = &cfg.source[0];
9346 assert_eq!(s.owner, "yah");
9347 assert_eq!(s.mode, SourceMode::ReadOnly);
9348 assert!(s.select.is_empty());
9349 match &s.kind {
9350 InfraSourceKind::Path { path } => assert_eq!(path, "../yah"),
9351 other => panic!("expected Path, got {other:?}"),
9352 }
9353 }
9354
9355 #[test]
9356 fn sources_parses_a_git_kind_reusing_gitsource_verbatim() {
9357 let tmp = tempfile::TempDir::new().unwrap();
9358 std::fs::write(
9359 tmp.path().join("sources.toml"),
9360 r#"
9361schema_version = 1
9362
9363[[source]]
9364owner = "yah"
9365kind = "git"
9366repo = "git@github.com:yah-ai/infra.git"
9367ref = "main"
9368subdir = "infra"
9369select = ["tag:cloud-runner"]
9370mode = "read-only"
9371"#,
9372 )
9373 .unwrap();
9374 let cfg = SourcesConfig::load(tmp.path()).unwrap();
9375 assert_eq!(cfg.source.len(), 1);
9376 let s = &cfg.source[0];
9377 assert_eq!(s.select, vec!["tag:cloud-runner".to_string()]);
9378 match &s.kind {
9379 InfraSourceKind::Git(git) => {
9380 assert_eq!(git.repo, "git@github.com:yah-ai/infra.git");
9381 assert_eq!(git.r#ref, "main");
9382 assert_eq!(git.subdir.as_deref(), Some("infra"));
9383 }
9384 other => panic!("expected Git, got {other:?}"),
9385 }
9386 }
9387
9388 #[test]
9389 fn sources_mode_defaults_to_read_only_and_manage_is_explicit() {
9390 let tmp = tempfile::TempDir::new().unwrap();
9391 std::fs::write(
9392 tmp.path().join("sources.toml"),
9393 r#"
9394schema_version = 1
9395
9396[[source]]
9397owner = "a"
9398kind = "path"
9399path = "../a"
9400
9401[[source]]
9402owner = "b"
9403kind = "path"
9404path = "../b"
9405mode = "manage"
9406"#,
9407 )
9408 .unwrap();
9409 let cfg = SourcesConfig::load(tmp.path()).unwrap();
9410 assert_eq!(cfg.source[0].mode, SourceMode::ReadOnly, "omitted mode = read-only");
9411 assert_eq!(cfg.source[1].mode, SourceMode::Manage);
9412 }
9413
9414 #[test]
9415 fn sources_preserves_declaration_order() {
9416 // Overlay order matters (R615-F2) when two sources name the same
9417 // machine — the list must round-trip in file order, not be reordered
9418 // by owner or kind.
9419 let tmp = tempfile::TempDir::new().unwrap();
9420 std::fs::write(
9421 tmp.path().join("sources.toml"),
9422 r#"
9423schema_version = 1
9424
9425[[source]]
9426owner = "second"
9427kind = "path"
9428path = "../second"
9429
9430[[source]]
9431owner = "first"
9432kind = "path"
9433path = "../first"
9434"#,
9435 )
9436 .unwrap();
9437 let cfg = SourcesConfig::load(tmp.path()).unwrap();
9438 let owners: Vec<&str> = cfg.source.iter().map(|s| s.owner.as_str()).collect();
9439 assert_eq!(owners, vec!["second", "first"]);
9440 }
9441
9442 #[test]
9443 fn sources_round_trips_through_serialize() {
9444 let cfg = SourcesConfig {
9445 schema_version: 1,
9446 source: vec![
9447 InfraSource {
9448 owner: "yah".into(),
9449 kind: InfraSourceKind::Path {
9450 path: "../yah".into(),
9451 },
9452 mode: SourceMode::ReadOnly,
9453 select: vec![],
9454 },
9455 InfraSource {
9456 owner: "yah".into(),
9457 kind: InfraSourceKind::Git(GitSource {
9458 repo: "git@github.com:yah-ai/infra.git".into(),
9459 r#ref: "main".into(),
9460 subdir: Some("infra".into()),
9461 }),
9462 mode: SourceMode::Manage,
9463 select: vec!["tag:cloud-runner".into()],
9464 },
9465 ],
9466 };
9467 let toml_str = toml::to_string_pretty(&cfg).unwrap();
9468 let reloaded: SourcesConfig = toml::from_str(&toml_str).unwrap();
9469 assert_eq!(reloaded, cfg, "round-trip through TOML must be lossless:\n{toml_str}");
9470 }
9471
9472 // ─── R615-F2: overlay loader in CloudConfig::load ───────────────────────
9473
9474 fn write_min_machine(dir: &Path, name: &str, extra_toml: &str) {
9475 std::fs::create_dir_all(dir).unwrap();
9476 // `extra_toml` supplies `mesh_tags` when the caller cares about it;
9477 // otherwise default to the empty list. Never hardcode `mesh_tags`
9478 // here as well as in `extra_toml` -- TOML rejects a duplicate key.
9479 let mesh_tags = if extra_toml.contains("mesh_tags") {
9480 String::new()
9481 } else {
9482 "mesh_tags = []\n".to_string()
9483 };
9484 std::fs::write(
9485 dir.join(format!("{name}.toml")),
9486 format!("name = \"{name}\"\nprovider = \"static\"\n{mesh_tags}{extra_toml}"),
9487 )
9488 .unwrap();
9489 }
9490
9491 fn write_min_provider(dir: &Path, id: &str) {
9492 std::fs::create_dir_all(dir).unwrap();
9493 std::fs::write(
9494 dir.join(format!("{id}.toml")),
9495 format!("schema_version = 1\nid = \"{id}\"\nkind = \"static\"\n"),
9496 )
9497 .unwrap();
9498 }
9499
9500 fn write_sources_toml(camp_root: &Path, body: &str) {
9501 let dir = camp_root.join(".yah/infra");
9502 std::fs::create_dir_all(&dir).unwrap();
9503 std::fs::write(dir.join("sources.toml"), body).unwrap();
9504 }
9505
9506 #[test]
9507 fn load_with_no_sources_toml_is_unchanged() {
9508 let tmp = tempfile::TempDir::new().unwrap();
9509 write_min_machine(&tmp.path().join(".yah/infra/machines"), "local-1", "");
9510 let cfg = CloudConfig::load(tmp.path()).unwrap();
9511 assert_eq!(cfg.machines.len(), 1);
9512 assert!(cfg.machine_origins.is_empty());
9513 assert!(cfg.provider_origins.is_empty());
9514 }
9515
9516 #[test]
9517 fn path_source_overlays_machines_and_providers_tagged_with_origin() {
9518 let camp = tempfile::TempDir::new().unwrap();
9519 let other = tempfile::TempDir::new().unwrap();
9520 write_min_machine(&other.path().join(".yah/infra/machines"), "borrowed-1", "");
9521 write_min_provider(&other.path().join(".yah/infra/providers"), "borrowed-provider");
9522 write_sources_toml(
9523 camp.path(),
9524 &format!(
9525 "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
9526 other.path().display()
9527 ),
9528 );
9529
9530 let cfg = CloudConfig::load(camp.path()).unwrap();
9531 assert_eq!(cfg.machines.len(), 1);
9532 assert_eq!(cfg.machines[0].name, "borrowed-1");
9533 assert_eq!(cfg.providers.len(), 1);
9534 assert_eq!(cfg.providers[0].id, "borrowed-provider");
9535
9536 let origin = cfg.machine_origins.get("borrowed-1").expect("origin recorded");
9537 assert_eq!(origin.owner, "other");
9538 assert_eq!(origin.mode, SourceMode::ReadOnly);
9539 assert!(origin.source.starts_with("path:"));
9540 assert_eq!(
9541 cfg.provider_origins.get("borrowed-provider").unwrap().owner,
9542 "other"
9543 );
9544 }
9545
9546 #[test]
9547 fn camp_local_wins_on_name_collision_and_carries_no_origin() {
9548 let camp = tempfile::TempDir::new().unwrap();
9549 let other = tempfile::TempDir::new().unwrap();
9550 // Both declare a machine named "shared" -- camp-local's copy must win,
9551 // and it must never gain an origin tag.
9552 write_min_machine(&camp.path().join(".yah/infra/machines"), "shared", "");
9553 write_min_machine(
9554 &other.path().join(".yah/infra/machines"),
9555 "shared",
9556 "nickname = \"the borrowed one\"\n",
9557 );
9558 write_sources_toml(
9559 camp.path(),
9560 &format!(
9561 "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
9562 other.path().display()
9563 ),
9564 );
9565
9566 let cfg = CloudConfig::load(camp.path()).unwrap();
9567 assert_eq!(cfg.machines.len(), 1, "the name collides, so exactly one entry");
9568 assert_eq!(cfg.machines[0].nickname, None, "camp-local's copy, not the borrowed one");
9569 assert!(
9570 !cfg.machine_origins.contains_key("shared"),
9571 "camp-local entries never carry an origin tag"
9572 );
9573 }
9574
9575 #[test]
9576 fn an_earlier_source_wins_over_a_later_one_on_collision() {
9577 let camp = tempfile::TempDir::new().unwrap();
9578 let first = tempfile::TempDir::new().unwrap();
9579 let second = tempfile::TempDir::new().unwrap();
9580 write_min_machine(&first.path().join(".yah/infra/machines"), "dup", "");
9581 write_min_machine(&second.path().join(".yah/infra/machines"), "dup", "");
9582 write_sources_toml(
9583 camp.path(),
9584 &format!(
9585 "schema_version = 1\n\n[[source]]\nowner = \"first\"\nkind = \"path\"\npath = \"{}\"\n\n[[source]]\nowner = \"second\"\nkind = \"path\"\npath = \"{}\"\n",
9586 first.path().display(),
9587 second.path().display()
9588 ),
9589 );
9590
9591 let cfg = CloudConfig::load(camp.path()).unwrap();
9592 assert_eq!(cfg.machines.len(), 1);
9593 assert_eq!(cfg.machine_origins.get("dup").unwrap().owner, "first");
9594 }
9595
9596 #[test]
9597 fn select_filters_borrowed_machines_by_name_or_mesh_tag() {
9598 let camp = tempfile::TempDir::new().unwrap();
9599 let other = tempfile::TempDir::new().unwrap();
9600 write_min_machine(&other.path().join(".yah/infra/machines"), "runner-1", "mesh_tags = [\"tag:cloud-runner\"]\n");
9601 write_min_machine(&other.path().join(".yah/infra/machines"), "excluded-1", "");
9602 write_sources_toml(
9603 camp.path(),
9604 &format!(
9605 "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\nselect = [\"tag:cloud-runner\"]\n",
9606 other.path().display()
9607 ),
9608 );
9609
9610 let cfg = CloudConfig::load(camp.path()).unwrap();
9611 assert_eq!(cfg.machines.len(), 1);
9612 assert_eq!(cfg.machines[0].name, "runner-1");
9613 }
9614
9615 #[test]
9616 fn one_unparseable_foreign_machine_does_not_sink_the_rest_of_the_directory_or_the_load() {
9617 let camp = tempfile::TempDir::new().unwrap();
9618 let other = tempfile::TempDir::new().unwrap();
9619 let dir = other.path().join(".yah/infra/machines");
9620 write_min_machine(&dir, "good", "");
9621 // Schema-skew gotcha: a foreign machine this binary's MachineConfig
9622 // can't parse at all (not just an unknown field -- MachineConfig has
9623 // no deny_unknown_fields, so this has to fail on a TYPE, not a name).
9624 std::fs::write(dir.join("bad.toml"), "name = 1\nprovider = 2\n").unwrap();
9625 write_sources_toml(
9626 camp.path(),
9627 &format!(
9628 "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
9629 other.path().display()
9630 ),
9631 );
9632
9633 // Must not error at all -- camp-local load must never fail because a
9634 // source it doesn't own has one bad file.
9635 let cfg = CloudConfig::load(camp.path()).unwrap();
9636 assert_eq!(cfg.machines.len(), 1, "the good entry still loads");
9637 assert_eq!(cfg.machines[0].name, "good");
9638 }
9639
9640 #[test]
9641 fn an_unsynced_git_source_overlays_nothing_and_is_not_an_error() {
9642 // No `yah infra sync` (R615-T3) has ever run, so the cache dir this
9643 // resolves to doesn't exist. Must be silent, not fatal.
9644 let camp = tempfile::TempDir::new().unwrap();
9645 write_sources_toml(
9646 camp.path(),
9647 "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"git\"\nrepo = \"git@github.com:yah-ai/infra.git\"\nref = \"main\"\n",
9648 );
9649 let cfg = CloudConfig::load(camp.path()).unwrap();
9650 assert!(cfg.machines.is_empty());
9651 assert!(cfg.machine_origins.is_empty());
9652 }
9653
9654 #[test]
9655 fn a_synced_git_source_reads_from_the_cache_dir_not_the_repo_path() {
9656 // No `subdir` declared -- the checkout ROOT is the infra root.
9657 let camp = tempfile::TempDir::new().unwrap();
9658 let cache = crate::paths::infra_source_cache_dir(camp.path(), "yah");
9659 write_min_machine(&cache.join("machines"), "synced-1", "");
9660 write_sources_toml(
9661 camp.path(),
9662 "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"git\"\nrepo = \"git@github.com:yah-ai/infra.git\"\nref = \"main\"\n",
9663 );
9664 let cfg = CloudConfig::load(camp.path()).unwrap();
9665 assert_eq!(cfg.machines.len(), 1);
9666 assert_eq!(cfg.machines[0].name, "synced-1");
9667 assert!(cfg.machine_origins.get("synced-1").unwrap().source.starts_with("git:"));
9668 }
9669
9670 #[test]
9671 fn a_git_sources_subdir_is_honoured_like_the_component_case() {
9672 // W274's own example declares `subdir = "infra"` for a monorepo whose
9673 // registry lives under a subdirectory of the clone rather than at its
9674 // root -- prove `infra_root` actually reads it, not just `.subdir` on
9675 // GitSource parsing (R615-F1 already covers that half).
9676 let camp = tempfile::TempDir::new().unwrap();
9677 let cache = crate::paths::infra_source_cache_dir(camp.path(), "yah");
9678 write_min_machine(&cache.join("infra").join("machines"), "subdir-1", "");
9679 // Also plant a decoy at the checkout root to prove the root itself is
9680 // NOT read when a subdir is declared.
9681 write_min_machine(&cache.join("machines"), "root-decoy", "");
9682 write_sources_toml(
9683 camp.path(),
9684 "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"git\"\nrepo = \"git@github.com:yah-ai/infra.git\"\nref = \"main\"\nsubdir = \"infra\"\n",
9685 );
9686 let cfg = CloudConfig::load(camp.path()).unwrap();
9687 assert_eq!(cfg.machines.len(), 1);
9688 assert_eq!(cfg.machines[0].name, "subdir-1");
9689 }
9690
9691 #[test]
9692 fn load_from_config_dir_never_applies_sources_overlay() {
9693 // R615-F2's explicit decision: multi-root sibling trees don't inherit
9694 // the classic .yah/infra/sources.toml. Prove it rather than assert it
9695 // silently -- a sources.toml sitting at workspace_root/.yah/infra/
9696 // must NOT leak into a load_from_config_dir call even though both
9697 // share the same workspace_root.
9698 let camp = tempfile::TempDir::new().unwrap();
9699 let other = tempfile::TempDir::new().unwrap();
9700 write_min_machine(&other.path().join(".yah/infra/machines"), "borrowed-1", "");
9701 write_sources_toml(
9702 camp.path(),
9703 &format!(
9704 "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
9705 other.path().display()
9706 ),
9707 );
9708 let sibling_config_dir = camp.path().join(".noisetable");
9709 std::fs::create_dir_all(&sibling_config_dir).unwrap();
9710
9711 let cfg = CloudConfig::load_from_config_dir(&sibling_config_dir, camp.path()).unwrap();
9712 assert!(cfg.machines.is_empty(), "sources.toml must not apply here");
9713 assert!(cfg.machine_origins.is_empty());
9714 }
9715
9716 // ─── R615-T5: `inherit_machines` retirement — cutover proof ────────────
9717
9718 /// The successor to R615-T5's parity proof. That earlier pair of tests
9719 /// asserted the legacy `[infra].inherit_machines` redirect and an
9720 /// equivalent `kind = "path"` source resolved the same machine set, and
9721 /// that the two coexisted without duplicating rows. Both claims were about
9722 /// a mechanism that no longer exists, so they retired with it — what has
9723 /// to hold *now* is the other half of the same guarantee: a camp that
9724 /// declares only `sources.toml` resolves the shared root exactly as the
9725 /// redirect used to, and a stale `inherit_machines` key left behind in
9726 /// `camp.toml` changes nothing.
9727 ///
9728 /// That stale-key case is not hypothetical: it is precisely the state a
9729 /// camp is in between the code cutover and someone tidying its
9730 /// `camp.toml`, and a silent re-resolution there would double-count the
9731 /// borrowed nodes or hide their origin badge.
9732 #[test]
9733 fn a_stale_inherit_machines_key_does_not_change_what_sources_toml_resolves() {
9734 let shared = tempfile::TempDir::new().unwrap();
9735 write_min_machine(&shared.path().join(".yah/infra/machines"), "shared-node-1", "");
9736 write_min_machine(&shared.path().join(".yah/infra/machines"), "shared-node-2", "");
9737
9738 let sources_toml = format!(
9739 "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"path\"\npath = \"{}\"\nmode = \"read-only\"\n",
9740 shared.path().display()
9741 );
9742
9743 // Camp A: migrated cleanly — sources.toml only.
9744 let clean = tempfile::TempDir::new().unwrap();
9745 write_sources_toml(clean.path(), &sources_toml);
9746
9747 // Camp B: mid-migration — same source, plus the retired key still
9748 // sitting in camp.toml pointing at the same root.
9749 let stale = tempfile::TempDir::new().unwrap();
9750 std::fs::create_dir_all(stale.path().join(".yah")).unwrap();
9751 std::fs::write(
9752 stale.path().join(".yah/camp.toml"),
9753 format!(
9754 "[infra]\ninherit_machines = \"{}\"\n",
9755 shared.path().display()
9756 ),
9757 )
9758 .unwrap();
9759 write_sources_toml(stale.path(), &sources_toml);
9760
9761 let via_clean = CloudConfig::load(clean.path()).unwrap();
9762 let via_stale = CloudConfig::load(stale.path()).unwrap();
9763
9764 let names = |cfg: &CloudConfig| {
9765 let mut v: Vec<String> = cfg.machines.iter().map(|m| m.name.clone()).collect();
9766 v.sort();
9767 v
9768 };
9769 assert_eq!(
9770 names(&via_clean),
9771 names(&via_stale),
9772 "a leftover inherit_machines key must be inert — the retired redirect is gone"
9773 );
9774 assert_eq!(names(&via_clean), vec!["shared-node-1", "shared-node-2"]);
9775
9776 // And both are *borrowed*, not camp-local. This is the operator-facing
9777 // win the stopgap could never deliver: under the old redirect these
9778 // resolved with no origin at all, indistinguishable from locally-owned
9779 // nodes.
9780 assert_eq!(via_clean.machine_origins.len(), 2);
9781 assert_eq!(via_stale.machine_origins.len(), 2);
9782 for origin in via_stale.machine_origins.values() {
9783 assert_eq!(origin.owner, "yah");
9784 assert_eq!(origin.mode, SourceMode::ReadOnly);
9785 }
9786 }
9787
9788 // ─── R860-T4 (W338): placement groups ───────────────────────────────────
9789
9790 /// One requirement edge, written the way a spec author writes it.
9791 fn requirement(ident: &str, locality: Locality) -> workload_spec::Requirement {
9792 workload_spec::Requirement {
9793 ident: workload_spec::MeshIdent(ident.into()),
9794 locality,
9795 supply: workload_spec::Supply::Wait,
9796 provides: None,
9797 }
9798 }
9799
9800 /// A `minimal_spec` (256 MiB / 250 millicores, Server by inference) that
9801 /// requires the given edges.
9802 fn spec_requiring(name: &str, requires: Vec<workload_spec::Requirement>) -> WorkloadSpec {
9803 WorkloadSpec {
9804 requires,
9805 ..minimal_spec(name, 1)
9806 }
9807 }
9808
9809 /// The declared inventory an ident is resolved against — `.yah/infra/workloads/`.
9810 fn declared(specs: Vec<WorkloadSpec>) -> Vec<WorkloadConfig> {
9811 specs.into_iter().map(|spec| WorkloadConfig { spec }).collect()
9812 }
9813
9814 fn member_names(group: &[WorkloadSpec]) -> Vec<&str> {
9815 group.iter().map(|s| s.name.as_str()).collect()
9816 }
9817
9818 /// The headline case: `local` means "same node", so the two specs are one
9819 /// placement unit and admission has to reason about both.
9820 #[test]
9821 fn a_local_edge_binds_the_provider_into_the_placement_group() {
9822 let inventory = declared(vec![minimal_spec("headscale-replicator", 1)]);
9823 let requirer = spec_requiring(
9824 "headscale",
9825 vec![requirement("headscale-replicator", Locality::Local)],
9826 );
9827
9828 assert_eq!(
9829 member_names(&placement_group(&requirer, &inventory)),
9830 vec!["headscale", "headscale-replicator"]
9831 );
9832 }
9833
9834 /// The edge that must NOT bind. `prefer-local` "never blocks placement"
9835 /// (W338's locality table), and `anywhere` — which is what every legacy
9836 /// `depends_on` folds into — is an ordinary service dependency. Binding
9837 /// either would silently make every dependency in the tree a co-scheduling
9838 /// constraint and start summing unrelated workloads into the capacity floor.
9839 #[test]
9840 fn prefer_local_and_anywhere_edges_do_not_bind_the_group() {
9841 let inventory = declared(vec![
9842 minimal_spec("headscale-db", 1),
9843 minimal_spec("metrics", 1),
9844 minimal_spec("legacy-dep", 1),
9845 ]);
9846
9847 let requirer = WorkloadSpec {
9848 depends_on: vec![workload_spec::MeshIdent("legacy-dep".into())],
9849 ..spec_requiring(
9850 "headscale",
9851 vec![
9852 requirement("headscale-db", Locality::PreferLocal),
9853 requirement("metrics", Locality::Anywhere),
9854 ],
9855 )
9856 };
9857
9858 assert_eq!(
9859 member_names(&placement_group(&requirer, &inventory)),
9860 vec!["headscale"]
9861 );
9862 }
9863
9864 /// Transitive, and via the inline spec a `supply = "self"` requirement
9865 /// carries rather than via an ident lookup — the sidecar shape W338's
9866 /// worked example is built on.
9867 #[test]
9868 fn the_group_is_the_transitive_closure_and_traverses_inline_provides() {
9869 let inline = workload_spec::Requirement {
9870 supply: workload_spec::Supply::SelfProvision,
9871 provides: Some(Box::new(minimal_spec("headscale-restore", 1))),
9872 ..requirement("headscale-restore", Locality::Local)
9873 };
9874 let middle = WorkloadSpec {
9875 requires: vec![requirement("wal-shipper", Locality::Local)],
9876 ..minimal_spec("headscale-replicator", 1)
9877 };
9878 let inventory = declared(vec![middle, minimal_spec("wal-shipper", 1)]);
9879
9880 let requirer = spec_requiring(
9881 "headscale",
9882 vec![
9883 inline,
9884 requirement("headscale-replicator", Locality::Local),
9885 ],
9886 );
9887
9888 assert_eq!(
9889 member_names(&placement_group(&requirer, &inventory)),
9890 vec![
9891 "headscale",
9892 "headscale-restore",
9893 "headscale-replicator",
9894 "wal-shipper"
9895 ]
9896 );
9897 }
9898
9899 /// `validate::check_requires` bounds `provides` nesting to depth 1 but
9900 /// cannot stop two separately-declared specs from naming each other. Without
9901 /// the visited set this closure never terminates, so admission would hang
9902 /// rather than refuse — the worst failure shape for a deploy gate.
9903 #[test]
9904 fn an_ident_cycle_closes_the_group_instead_of_looping_forever() {
9905 let b = spec_requiring("b", vec![requirement("a", Locality::Local)]);
9906 let a = spec_requiring("a", vec![requirement("b", Locality::Local)]);
9907 let inventory = declared(vec![a.clone(), b]);
9908
9909 assert_eq!(member_names(&placement_group(&a, &inventory)), vec!["a", "b"]);
9910 }
9911
9912 /// An unresolvable ident is skipped, not fatal: admission is a pure function
9913 /// of the declared inventory, and refusing every deploy whose provider is
9914 /// not yet declared would make `requires` unusable before R860-T6 lands.
9915 #[test]
9916 fn an_unresolvable_local_ident_is_skipped_rather_than_refused() {
9917 let requirer = spec_requiring("headscale", vec![requirement("not-declared", Locality::Local)]);
9918 assert_eq!(
9919 member_names(&placement_group(&requirer, &[])),
9920 vec!["headscale"]
9921 );
9922 }
9923
9924 /// W338 §Placement consequences 1: the capacity floor is the group's sum.
9925 /// A node that fits the requirer alone must refuse the group — placing it
9926 /// there would oversubscribe the node the moment the provider follows.
9927 #[test]
9928 fn the_capacity_floor_is_the_sum_of_the_group_not_the_requirer_alone() {
9929 let provider = minimal_spec("headscale-replicator", 1);
9930 let requirer = spec_requiring(
9931 "headscale",
9932 vec![requirement("headscale-replicator", Locality::Local)],
9933 );
9934 // Two `minimal_spec`s: 256 MiB + 250 millicores each.
9935 let inventory = declared(vec![provider]);
9936
9937 let too_small = CloudConfig {
9938 workloads: inventory.clone(),
9939 ..make_empty_cfg(vec![make_machine_with_capacity("small", 300, 4000, vec![])])
9940 };
9941 let err = too_small.admit_workload(&requirer).unwrap_err().to_string();
9942 assert!(
9943 err.contains("memory_mb>=512"),
9944 "the floor must name the group's summed demand, got: {err}"
9945 );
9946
9947 let big_enough = CloudConfig {
9948 workloads: inventory,
9949 ..make_empty_cfg(vec![make_machine_with_capacity("roomy", 512, 4000, vec![])])
9950 };
9951 assert_eq!(
9952 big_enough.admit_workload(&requirer).unwrap().name,
9953 "roomy",
9954 "a node covering the sum must still admit the group"
9955 );
9956 }
9957
9958 /// W338 §Placement consequences 2, and the reason repulsion is computed over
9959 /// a set at all: the requirer is a `Server`, so the pre-R860 axis would have
9960 /// let it onto a `no-appliance` dev Pi and dragged its Appliance provider
9961 /// there with it.
9962 #[test]
9963 fn a_server_requiring_an_appliance_locally_is_repelled_by_no_appliance() {
9964 let appliance = WorkloadSpec {
9965 archetype: Some(LifecycleArchetype::Appliance),
9966 ..minimal_spec("headscale", 1)
9967 };
9968 let requirer = spec_requiring("headscale-ui", vec![requirement("headscale", Locality::Local)]);
9969 assert_eq!(
9970 requirer.effective_archetype(),
9971 LifecycleArchetype::Server,
9972 "precondition: the requirer itself must not be an Appliance"
9973 );
9974
9975 let cfg = CloudConfig {
9976 workloads: declared(vec![appliance]),
9977 ..make_empty_cfg(vec![
9978 make_machine_with_capacity("dev-pi", 8192, 4000, vec!["no-appliance"]),
9979 make_machine_with_capacity("us-west-001", 8192, 4000, vec![]),
9980 ])
9981 };
9982
9983 assert_eq!(
9984 cfg.admit_workload(&requirer).unwrap().name,
9985 "us-west-001",
9986 "the dev Pi repels the group's Appliance member"
9987 );
9988
9989 // And with the Appliance gone from the group, the same requirer is
9990 // admissible on the same Pi — proving the repulsion came from the edge.
9991 let alone = minimal_spec("headscale-ui", 1);
9992 assert_eq!(cfg.admit_workload(&alone).unwrap().name, "dev-pi");
9993 }
9994
9995 /// The set-valued form of the per-workload drain skip the node makes in
9996 /// `drain_workloads`: one Appliance member pins the whole group.
9997 #[test]
9998 fn a_group_containing_an_appliance_is_not_drainable() {
9999 let server = minimal_spec("headscale-ui", 1);
10000 let appliance = WorkloadSpec {
10001 archetype: Some(LifecycleArchetype::Appliance),
10002 ..minimal_spec("headscale", 1)
10003 };
10004
10005 assert!(group_is_drainable(std::slice::from_ref(&server)));
10006 assert!(!group_is_drainable(&[server, appliance]));
10007 }
10008
10009 /// The regression that matters most: nothing in the tree declares
10010 /// `requires` yet, so every existing spec's group is exactly itself and its
10011 /// admission axes must be bit-identical to the pre-R860 derivation.
10012 #[test]
10013 fn a_spec_with_no_local_edges_admits_exactly_as_it_did_before() {
10014 let ws = ws_with_selector(Some("tag:build-worker,arch:x86"));
10015 let req = admission_spec(&ws, &[]);
10016
10017 assert_eq!(req.mesh_tags, vec!["tag:build-worker", "arch:x86"]);
10018 assert_eq!(req.memory_mb, ws.memory_request_mb());
10019 assert_eq!(req.cpu_millis, ws.resources.cpu_millis);
10020 assert_eq!(req.repel_archetypes, vec![ws.effective_archetype()]);
10021 }
10022
10023 // ─── R860-T5 (W338 §Placement consequences 3): native-exec capability ────
10024
10025 /// A `minimal_spec` carrying the `yah.exec = native` marker — the only way
10026 /// a workload says "fork+exec me on the host" (`WorkloadSpec::
10027 /// wants_native_exec`). It stays a Container workload on the wire; the
10028 /// marker is the whole difference.
10029 fn native_spec(name: &str) -> WorkloadSpec {
10030 let mut ws = minimal_spec(name, 1);
10031 ws.annotations.insert(
10032 workload_spec::NATIVE_EXEC_ANNOTATION.to_string(),
10033 workload_spec::NATIVE_EXEC_VALUE.to_string(),
10034 );
10035 assert!(ws.wants_native_exec(), "precondition: the marker must read back");
10036 ws
10037 }
10038
10039 /// The R858 failure, now caught at placement instead of at dispatch: a node
10040 /// whose kamaji has no `--native-exec-dir` accepted the election and then
10041 /// refused the deploy, and nothing upstream could see it coming.
10042 #[test]
10043 fn a_node_without_the_native_exec_capability_cannot_host_a_native_workload() {
10044 let native = native_spec("headscale");
10045
10046 let incapable = make_empty_cfg(vec![make_machine("us-south-001", vec![])]);
10047 let err = incapable.admit_workload(&native).unwrap_err().to_string();
10048 assert!(
10049 err.contains(NATIVE_EXEC_MESH_TAG),
10050 "the refusal must name the missing capability, got: {err}"
10051 );
10052
10053 let capable = make_empty_cfg(vec![
10054 make_machine("us-south-001", vec![]),
10055 make_machine("us-west-001", vec![NATIVE_EXEC_MESH_TAG]),
10056 ]);
10057 assert_eq!(
10058 capable.admit_workload(&native).unwrap().name,
10059 "us-west-001",
10060 "a node declaring the capability admits the native workload"
10061 );
10062 }
10063
10064 /// W338's actual sentence: a `supply = "self"` spec "must be placeable where
10065 /// its requirer lands". The requirer here is an ordinary container workload
10066 /// — it is the *provider* reached by a `local` edge that needs the host
10067 /// backend, so the capability has to be required of the group, not of the
10068 /// spec being deployed.
10069 #[test]
10070 fn a_local_edge_to_a_native_provider_makes_the_requirer_need_the_capability() {
10071 let requirer = spec_requiring(
10072 "headscale-ui",
10073 vec![requirement("headscale", Locality::Local)],
10074 );
10075 assert!(
10076 !requirer.wants_native_exec(),
10077 "precondition: the requirer itself is an ordinary container workload"
10078 );
10079
10080 let cfg = CloudConfig {
10081 workloads: declared(vec![native_spec("headscale")]),
10082 ..make_empty_cfg(vec![
10083 make_machine("plain", vec![]),
10084 make_machine("us-west-001", vec![NATIVE_EXEC_MESH_TAG]),
10085 ])
10086 };
10087
10088 assert_eq!(
10089 cfg.admit_workload(&requirer).unwrap().name,
10090 "us-west-001",
10091 "the group's native member pulls the requirer onto a capable node"
10092 );
10093
10094 // Without the edge the same requirer is admissible on the plain node,
10095 // so the constraint provably came from the group and not from the spec.
10096 let alone = minimal_spec("headscale-ui", 1);
10097 assert_eq!(cfg.admit_workload(&alone).unwrap().name, "plain");
10098 }
10099
10100 /// The regression guard: nothing in the tree is native-marked today, so
10101 /// every existing spec's axes must be untouched by this ticket.
10102 #[test]
10103 fn a_group_with_no_native_member_does_not_require_the_capability() {
10104 let inventory = declared(vec![minimal_spec("headscale-replicator", 1)]);
10105 let requirer = spec_requiring(
10106 "headscale",
10107 vec![requirement("headscale-replicator", Locality::Local)],
10108 );
10109
10110 let req = admission_spec(&requirer, &inventory);
10111 assert!(
10112 !req.mesh_tags.iter().any(|t| t == NATIVE_EXEC_MESH_TAG),
10113 "no native member ⇒ no capability axis, got: {:?}",
10114 req.mesh_tags
10115 );
10116
10117 // And it still lands on a node that declares nothing at all.
10118 let cfg = CloudConfig {
10119 workloads: inventory,
10120 ..make_empty_cfg(vec![make_machine("plain", vec![])])
10121 };
10122 assert_eq!(cfg.admit_workload(&requirer).unwrap().name, "plain");
10123 }
10124}