cloud/reconciler/domain.rs
1//! Domain-level reconciler — `.yah/domains/*.toml` → live Cloudflare state.
2//!
3//! Today's only shape is the **R2 custom-domain** binding: a domain that
4//! declares `front_door = "bucket-direct"` (R594-F12) and names an R2 bucket
5//! in `cdn_bucket`. Cloudflare's R2 Custom Domains API binds the hostname to
6//! the bucket and writes the CNAME into the parent zone automatically when
7//! the zone lives on the same account — no DNS-side call is needed here.
8//!
9//! Domains with `front_door = "worker"` (e.g. `app-yah-dev.toml`) are out of
10//! scope for this shape; they get DNS + route management through the Worker
11//! reconciler below. `front_door = "passway"` is the sovereign-ingress path
12//! (W267) and is reconciled by [`ensure_passway_apex`] — R859-F1 — which
13//! renders the apex A record set from the domain manifest plus the workspace
14//! ingress collation, DNS-only, through the provider-agnostic `dns.*` envoy
15//! verbs.
16//!
17//! Before R594-F12 the shape was *inferred* from the absence of `[[routes]]`,
18//! which meant a route-carrying manifest bound straight to R2 was accepted
19//! and silently skipped by this pass. The discriminator is now declared and
20//! validated at load.
21//!
22//! @yah:relay(R859, "Sovereign-edge automation ring: DNS reconciled from declared intent, public addressing follows placement (W267 audit followups)")
23//! @yah:at(2026-09-04T19:06:24Z)
24//! @yah:status(handoff)
25//! @yah:assignee(agent:user-custom-char-gul2)
26//! @arch:see(.yah/docs/working/W267-sovereign-public-ingress.md)
27//! @yah:next("Filed from the 2026-09-04 HA/ingress audit (chat session, operator-reviewed). The grey door is live but the automation ring around it is manual: DNS flips ride scripts/cf-apex-mode.sh by hand, and node loss leaves a dead A record taking ~half the round-robin traffic until a human edits DNS.")
28//! @yah:next("Order: F1 (DNS reconciler) before F2 (floating-IP wiring) — F2's DNS-withdrawal half needs F1's record-rendering to exist. Sibling track R844-F23 (poll-N door discovery) is independent and filed under R844 where door discovery lives.")
29//! @yah:next("Deliberately out of scope here: replica layer (W284/R626), kamaji healthcheck execution, generic appliance backup/migrate (R742-F3), external synthetic prober — each is its own track; this relay is only 'public addressing follows declared intent'.")
30
31use std::path::Path;
32
33use anyhow::{Context, Result};
34use tracing::{debug, info, warn};
35
36use crate::CloudflareClient;
37
38/// Bind `domain` as a custom domain on `bucket_name`. Idempotent — does
39/// nothing when the binding is already present, regardless of `enabled`
40/// state (CF reflects newly-added bindings as `enabled: true` immediately;
41/// disabling is an explicit dashboard action we don't undo here).
42///
43/// Resolves account_id + the Cloudflare API token from the named provider
44/// (`.yah/infra/providers/<provider_id>.toml`, see
45/// [`super::cf_creds::CfProvider`]). Mirrors the list-first pattern of
46/// `static_asset::ensure_r2_bucket` so the apply loop is safe to re-run.
47///
48/// Required token scopes: `Workers R2 Storage: Edit` and `Zone: Read`
49/// (the latter because the API requires the zone id of the parent zone —
50/// CF writes the CNAME there). The caller does NOT need `DNS: Edit`; CF
51/// provisions the record itself when the binding is created.
52pub async fn ensure_r2_custom_domain(
53 workspace_root: &Path,
54 provider_id: &str,
55 bucket_name: &str,
56 domain: &str,
57) -> Result<()> {
58 let cf_provider = super::cf_creds::CfProvider::resolve(workspace_root, provider_id)?;
59 let account_id = cf_provider.account_id.clone();
60 let cf = CloudflareClient::new(cf_provider.api_token()?);
61 let existing = cf
62 .list_r2_custom_domains(&account_id, bucket_name)
63 .await
64 .with_context(|| format!("listing R2 custom domains on bucket {bucket_name:?}"))?;
65 if existing.iter().any(|d| d.domain == domain) {
66 debug!(
67 domain,
68 bucket_name, "R2 custom domain already bound — skipping"
69 );
70 return Ok(());
71 }
72 let zone_name = parent_zone_name(domain);
73 let zone_id = cf
74 .zone_id_for_name(zone_name)
75 .await
76 .with_context(|| format!("resolving zone id for {zone_name:?}"))?;
77 cf.add_r2_custom_domain(&account_id, bucket_name, domain, &zone_id)
78 .await
79 .with_context(|| format!("binding R2 custom domain {domain:?} → bucket {bucket_name:?}"))?;
80 info!(domain, bucket_name, zone_name, "R2 custom domain bound");
81 Ok(())
82}
83
84/// Heuristic: the parent CF zone of `domain` is its last two labels.
85///
86/// `cdn.yah.dev` → `yah.dev`; `yah.dev` → `yah.dev` (already apex). This is
87/// correct for every yah-owned zone today (all are two-label apexes). If a
88/// future workspace registers a three-label zone (e.g. `staging.yah.dev`
89/// as its own CF zone) and binds a subdomain under it, this will resolve
90/// to the wrong zone — swap in a longest-suffix match against
91/// `list_zones()` when that day arrives.
92fn parent_zone_name(domain: &str) -> &str {
93 let last_dot = domain.rfind('.');
94 let Some(last_dot) = last_dot else {
95 return domain; // single-label — let CF surface the bad input
96 };
97 if let Some(prev_dot) = domain[..last_dot].rfind('.') {
98 &domain[prev_dot + 1..]
99 } else {
100 domain // already a two-label apex
101 }
102}
103
104// ─── R561-F3: domain-manifest-driven static Worker ──────────────────────────
105//
106// A per-tenant alias-tier manifest (e.g. scrabcake.net.yah.dev) carries a
107// `static` route → `<service>/<component>`. Serving it means deploying the
108// shared router bundle (`WORKER_SCRIPT`) configured to fetch the tenant's
109// assets from its R2 prefix, then binding the subdomain to that Worker via the
110// Workers Custom Domains API. This is the routed-domain shape the apply loop
111// currently Skips.
112
113use crate::config::{DomainConfig, DomainRoute, FrontDoor, RouteMode};
114use crate::provider::cloudflare::WorkerBinding;
115use crate::reconciler::mesofact_static::{
116 worker_config_bindings, BackendOrigins, ConfigBinding, WorkerMode, WORKER_SCRIPT,
117};
118use crate::route_table::WorkerAssets;
119use std::collections::BTreeMap;
120
121/// The plan for deploying one subdomain's static Worker — everything decided
122/// before any Cloudflare call. Pure output of [`plan_domain_worker`], so the
123/// decision logic is unit-testable offline; [`deploy_domain_worker`] performs
124/// the I/O.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct DomainWorkerPlan {
127 /// Worker script name (the domain manifest's file stem / `name`).
128 pub worker_name: String,
129 /// Hostname to bind via Workers Custom Domains, e.g. `scrabcake.net.yah.dev`.
130 pub custom_domain: String,
131 /// `ASSET_ORIGIN` the Worker fetches from — the fall-through for a path no
132 /// `ROUTE_TABLE` entry claims, i.e. the first component static route's
133 /// `<cdn_base>/<service>/<env>`. `None` when no route is a component route
134 /// (a bucket-only CDN, R560-F13): its entries read R2 bindings, and the
135 /// binding is emitted empty.
136 pub asset_origin: Option<String>,
137 /// Worker bindings — the plain-text config plus one R2 bucket binding per
138 /// distinct bucket route — produced by
139 /// [`mesofact_static::worker_config_bindings`](crate::reconciler::mesofact_static)
140 /// — the same one producer the mirror-driven arm calls (R898-T4), not a
141 /// hand-synced twin of it.
142 pub bindings: Vec<ConfigBinding>,
143}
144
145/// Build the [`DomainWorkerPlan`] for a `front_door = "worker"` manifest.
146///
147/// `assets` says how component static routes resolve. For the alias tier it is
148/// [`WorkerAssets::PerComponent`]: `cdn_base` is the tier's CDN origin (an R2
149/// custom domain bound to the tier bucket, e.g. `https://cdn.net.yah.dev`) and
150/// `env` the mirror env the publisher wrote under (e.g. `prod`). The publisher
151/// lays assets down at `<bucket>/<service>/<env>/<key>` and the Worker fetches
152/// `${origin}/<key>`, so a component route resolves to
153/// `<cdn_base>/<service>/<env>` — per route, through its own component.
154///
155/// **R560-F13: bucket routes need no `assets` at all.** A `bucket = "…"` route
156/// compiles to an entry naming its R2 binding, and the binding set gains one
157/// R2 bucket binding per distinct bucket. A manifest-only Worker — one no
158/// mirror deploys, planned by `yah cloud apply`'s domain pass — passes
159/// [`WorkerAssets::Unplaced`], under which a component route is refused naming
160/// itself rather than resolved to an origin nobody computed.
161///
162/// **R898-T4: every declared route is planned, in manifest order.** This used
163/// to `find_map` the FIRST `RouteMode::Static` route and discard the rest with
164/// a bare `_ => None` ("v1 alias tier serves one site per subdomain"), so a
165/// manifest's second static route and every backend/redirect route were
166/// silently dropped — and a dropped entry does not 503, it falls through to
167/// whichever shorter route matches and serves the wrong thing with a 200. The
168/// routes now compile through [`DomainConfig::route_table`], the same producer
169/// every other consumer reads, with [`WorkerPlacement`] as the placement:
170/// static routes resolve per component, backend routes to the manifest's
171/// declared `origin` (the one door for which that field is the answer).
172///
173/// This also retires `static_worker_bindings`, whose own doc said it was "kept
174/// in lockstep with `mesofact_static::worker_config_bindings` by hand". It was
175/// not: it still emitted `UPLOAD_ORIGIN`, `SSR_ORIGIN`, `SSR_PREFIXES` and
176/// `ROUTE_HEADERS` after R898-F3 deleted all four from the Worker, so an
177/// alias-tier Worker would have deployed with a binding set the bundled router
178/// no longer reads. There is one producer now and this calls it.
179///
180/// Fails fast (before any network call) when the manifest has no `static`
181/// route, or when any route's origin does not resolve.
182pub fn plan_domain_worker(
183 domain: &DomainConfig,
184 assets: WorkerAssets<'_>,
185) -> Result<DomainWorkerPlan> {
186 // R594-F12: refuse to synthesize a Worker for a domain that declared a
187 // different front door. Without this the plan succeeds and deploys a
188 // Worker that never receives traffic (bucket-direct) or that duplicates
189 // the sovereign ingress (passway).
190 if domain.front_door != FrontDoor::Worker {
191 anyhow::bail!(
192 "domain {} ({}) declares front_door = \"{}\" — only \"worker\" \
193 domains get a generated Cloudflare Worker",
194 domain.name,
195 domain.domain,
196 domain.front_door.as_str()
197 );
198 }
199
200 if !domain.routes.iter().any(|r| {
201 matches!(
202 r.mode,
203 RouteMode::Static { .. } | RouteMode::StaticBucket { .. }
204 )
205 }) {
206 anyhow::bail!(
207 "domain {} ({}) has no `static` route — nothing for a static Worker to serve",
208 domain.name,
209 domain.domain
210 );
211 }
212
213 // An unresolvable component origin is refused here, naming the route and
214 // its component.
215 let bindings = worker_config_bindings(
216 &WorkerMode::Static,
217 assets,
218 &BackendOrigins::default(),
219 Some(domain),
220 )?;
221
222 // `ASSET_ORIGIN` is a single binding, so it carries the fall-through for a
223 // path no table entry claims — the first component route's origin. Every
224 // static route still has its own entry in `ROUTE_TABLE`, which is what the
225 // door matches on.
226 let asset_origin = assets.fallback_origin(Some(domain));
227
228 Ok(DomainWorkerPlan {
229 worker_name: domain.name.clone(),
230 custom_domain: domain.domain.clone(),
231 bindings,
232 asset_origin,
233 })
234}
235
236/// Deploy the planned Worker and bind the subdomain (R561-F3, live I/O).
237///
238/// Reuses the shared `WORKER_SCRIPT` bundle + the existing CloudflareClient
239/// deploy/custom-domain methods. Gated on `cloudflare-api-token`. The decision
240/// logic is covered by `plan_domain_worker`'s tests; this I/O path has NOT been
241/// exercised against a live account yet (no creds in CI) — treat as untested
242/// until a live `yah cloud apply` confirms it.
243///
244/// CAVEAT (zone resolution): `parent_zone_name` uses a two-label heuristic, so
245/// for `scrabcake.net.yah.dev` it returns `yah.dev`. But the alias tiers
246/// `net.yah.dev` / `com.yah.dev` are their own Cloudflare zones — the binding
247/// must target `net.yah.dev`, not `yah.dev`. Before this goes live, swap in a
248/// longest-suffix match against the account's zones (the upgrade path
249/// `parent_zone_name`'s doc already names). Tracked on R561-F3.
250pub async fn deploy_domain_worker(
251 workspace_root: &Path,
252 provider_id: &str,
253 plan: &DomainWorkerPlan,
254) -> Result<()> {
255 let cf_provider = super::cf_creds::CfProvider::resolve(workspace_root, provider_id)?;
256 let account_id = cf_provider.account_id.clone();
257 let cf = CloudflareClient::new(cf_provider.api_token()?);
258
259 let worker_bindings: Vec<WorkerBinding<'_>> = plan
260 .bindings
261 .iter()
262 .map(ConfigBinding::as_worker_binding)
263 .collect();
264
265 cf.deploy_worker_script(
266 &account_id,
267 &plan.worker_name,
268 WORKER_SCRIPT,
269 &worker_bindings,
270 )
271 .await
272 .with_context(|| format!("deploying static Worker {}", plan.worker_name))?;
273 info!(worker = %plan.worker_name, "alias-tier Worker deployed");
274
275 let zone = parent_zone_name(&plan.custom_domain);
276 let zone_id = cf
277 .zone_id_for_name(zone)
278 .await
279 .with_context(|| format!("resolving zone id for {zone:?}"))?;
280 cf.upsert_worker_custom_domain(
281 &account_id,
282 &zone_id,
283 &plan.custom_domain,
284 &plan.worker_name,
285 )
286 .await
287 .with_context(|| {
288 format!(
289 "binding {} to Worker {}",
290 plan.custom_domain, plan.worker_name
291 )
292 })?;
293 info!(domain = %plan.custom_domain, worker = %plan.worker_name, "alias-tier custom domain bound");
294 Ok(())
295}
296
297// ─── R561-F4: alias-tier registration ───────────────────────────────────────
298//
299// "Claim <name>.{com,net}.yah.dev": validate the label, check it's free, and
300// produce the per-tenant DomainConfig (the F2 manifest) the registration flow
301// writes. Pure + offline — the SaaS "sign up, get a subdomain" moment.
302
303/// The two wildcard alias tiers. `Com` = managed/commercial, `Net` = community.
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305pub enum AliasTier {
306 Com,
307 Net,
308}
309
310impl AliasTier {
311 /// The tier's Cloudflare zone, e.g. `net.yah.dev`.
312 pub fn zone(self) -> &'static str {
313 match self {
314 AliasTier::Com => "com.yah.dev",
315 AliasTier::Net => "net.yah.dev",
316 }
317 }
318
319 /// The tier's shared R2 bucket (per-tenant `<svc>/<env>` prefixes within).
320 pub fn bucket(self) -> &'static str {
321 match self {
322 AliasTier::Com => "com-yah-dev",
323 AliasTier::Net => "net-yah-dev",
324 }
325 }
326
327 /// Manifest-name infix, e.g. `net` → `<name>-net-yah-dev` file stem.
328 fn slug(self) -> &'static str {
329 match self {
330 AliasTier::Com => "com",
331 AliasTier::Net => "net",
332 }
333 }
334}
335
336/// A valid DNS label: 1–63 chars, lowercase alphanumeric or hyphen, no
337/// leading/trailing hyphen. (Subdomain names a tenant can claim.)
338pub fn valid_subdomain_label(name: &str) -> bool {
339 !name.is_empty()
340 && name.len() <= 63
341 && !name.starts_with('-')
342 && !name.ends_with('-')
343 && name
344 .bytes()
345 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
346}
347
348/// Validate and build the per-tenant alias manifest for a claim.
349///
350/// `name` is the requested subdomain label (e.g. `scrabcake`); `component` is
351/// the tenant's static component ref (`"<service>/<component-id>"`). `existing`
352/// is the current domain map (from `CloudConfig`) — the claim fails if the
353/// resulting host or manifest name is already taken. Returns the
354/// [`DomainConfig`] to write at `.yah/domains/<stem>.toml`.
355pub fn plan_alias_claim(
356 tier: AliasTier,
357 name: &str,
358 component: &str,
359 existing: &BTreeMap<String, DomainConfig>,
360) -> Result<DomainConfig> {
361 if !valid_subdomain_label(name) {
362 anyhow::bail!(
363 "invalid subdomain {name:?} — must be 1–63 chars, lowercase \
364 alphanumeric or hyphen, no leading/trailing hyphen"
365 );
366 }
367 if component.split_once('/').is_none() {
368 anyhow::bail!("component {component:?} — expected \"<service>/<component-id>\"");
369 }
370
371 let domain = format!("{name}.{}", tier.zone());
372 let stem = format!("{name}-{}-yah-dev", tier.slug());
373
374 if existing.values().any(|d| d.domain == domain) {
375 anyhow::bail!("{domain} is already claimed");
376 }
377 if existing.contains_key(&stem) {
378 anyhow::bail!("domain manifest {stem:?} already exists");
379 }
380
381 Ok(DomainConfig {
382 schema_version: 1,
383 name: stem,
384 domain,
385 // R594-F12: every tenant manifest is Worker-served (R561-F3 binds the
386 // subdomain as a custom domain on the generated Worker).
387 front_door: FrontDoor::Worker,
388 cdn_bucket: tier.bucket().to_string(),
389 worker_bundle_path: None,
390 routes: vec![DomainRoute {
391 path: "/*".into(),
392 // A claimed alias serves one bundle at the root with no special
393 // header needs; a tenant that wants some edits its own manifest.
394 headers: BTreeMap::new(),
395 mode: RouteMode::Static {
396 component: component.to_string(),
397 },
398 }],
399 })
400}
401
402// ─── R859-F1: sovereign apex (front_door = "passway") ───────────────────────
403//
404// The two-source flip this dissolves: `.yah/domains/*.toml` DECLARED
405// `front_door`, while `scripts/cf-apex-mode.sh` imperatively MUTATED the apex
406// records, and nothing kept the two in agreement. The declaration drifted from
407// the live zone for 19 days once (R330-B36) and 4 more (R703-B4). Here the
408// manifest is the only source and the reconciler renders the records from it.
409//
410// Same house shape as the Worker arm: a pure planner (`plan_domain_passway` +
411// `diff_apex_records`) that is fully unit-testable offline, and an I/O applier
412// (`deploy_domain_passway`) that only executes the diff.
413
414use crate::config::MachineConfig;
415use crate::envoy::dns_record::{
416 DnsRecordDeleteInput, DnsRecordListInput, DnsRecordUpsertInput,
417};
418use crate::provider::cloudflare_envoy::CloudflareEnvoy;
419use std::net::Ipv4Addr;
420
421/// One declared public origin behind a sovereign apex — a machine that runs a
422/// passway front door and carries a routable IPv4 address.
423#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
424pub struct PasswayOrigin {
425 /// Machine name, as declared in `.yah/infra/machines/<name>.toml`. Carried
426 /// so every error and log line names the box an operator would open.
427 pub machine: String,
428 /// The address the apex A record will publish.
429 pub address: Ipv4Addr,
430}
431
432/// What [`public_origins`] resolved: the origins to publish, and the ones it
433/// deliberately held back because their machine is confirmed down (R859-F2).
434///
435/// Two lists rather than one shorter one, because the difference is
436/// load-bearing downstream: an origin that is *absent* from a declaration and
437/// an origin that is *present and known dead* want opposite treatment when the
438/// declaration itself is untrustworthy. See
439/// [`DomainPasswayPlan::health_withdrawn`].
440#[derive(Debug, Clone, Default, PartialEq, Eq)]
441pub struct ResolvedOrigins {
442 /// Origins the apex should publish.
443 pub origins: Vec<PasswayOrigin>,
444 /// Declared, resolvable origins withheld because their machine is
445 /// confirmed down.
446 pub health_withdrawn: Vec<PasswayOrigin>,
447}
448
449/// The apex record set one passway domain should carry — pure output of
450/// [`plan_domain_passway`], applied by [`deploy_domain_passway`].
451#[derive(Debug, Clone, PartialEq, Eq)]
452pub struct DomainPasswayPlan {
453 /// Parent Cloudflare zone apex name, from [`parent_zone_name`].
454 pub zone: String,
455 /// FQDN the records live at — the manifest's `domain`.
456 pub name: String,
457 /// Desired origins, deduplicated and sorted by address so two planning
458 /// runs over the same declaration are byte-identical.
459 pub origins: Vec<PasswayOrigin>,
460 /// Whether [`origins`](Self::origins) is the **whole** declared set.
461 ///
462 /// `false` when the ingress collation reported problems, because
463 /// [`collate_workspace_ingress`](crate::validate::collate_workspace_ingress)
464 /// returns `Ok` while *skipping* an edge whose declaration fails to plan —
465 /// so a passway edge with a config typo silently drops its machine from
466 /// `front_doors`. An absent machine and a withdrawn machine are then
467 /// indistinguishable from inside this plan, and the two want opposite
468 /// treatment: a withdrawal should prune the record, a typo must not.
469 ///
470 /// So the flag gates exactly one thing —
471 /// [`diff_apex_records`] withholds every prune when it is `false` —
472 /// and nothing else. Upserts still happen: growing the fleet must keep
473 /// working through an unrelated service's broken declaration, and an
474 /// upsert can never make the apex worse. **Fail-closed on withdrawal,
475 /// fail-open on addition.**
476 pub origins_complete: bool,
477 /// Addresses withdrawn because their machine is **confirmed down** —
478 /// R859-F2's cross-provider failover, rendered through R859-F1's existing
479 /// diff rather than through a second entry point.
480 ///
481 /// # Why this is not just "absent from `origins`"
482 ///
483 /// [`origins_complete`](Self::origins_complete) and this field are two
484 /// different facts and it is worth being exact about which is which, because
485 /// conflating them silently disables the failover this ticket exists for:
486 ///
487 /// - `origins_complete` answers **"is the *declaration* picture
488 /// trustworthy?"** `false` means the collation skipped an edge, so an
489 /// origin's absence might be a withdrawal or might be a config typo —
490 /// indistinguishable, so every prune is withheld.
491 /// - `health_withdrawn` answers **"which declared machine do we know is
492 /// down?"** It is *positive* evidence about a machine the collation
493 /// plainly saw, not an inference from an absence.
494 ///
495 /// So a health withdrawal is **not** suppressed by `origins_complete =
496 /// false`. The discriminator `origins_complete` exists to protect is
497 /// exactly "did we see this machine declared?", and for these machines the
498 /// answer is yes — they were resolved, taint-checked and address-checked on
499 /// the way into this list. A broken edge elsewhere in the workspace says
500 /// nothing about a box we watched go down, and letting it veto the
501 /// withdrawal would leave a dead origin taking its share of the
502 /// round-robin for as long as some unrelated service's TOML is wrong.
503 ///
504 /// The fail-closed-on-withdrawal rule is not weakened by this: the gate for
505 /// a health withdrawal is the *quorum* verdict, applied one layer up in
506 /// [`plan_ingress_owner_effect`](crate::provider::floating_ip::plan_ingress_owner_effect),
507 /// which refuses to emit the exclusion at all out of a degraded quorum. Two
508 /// withdrawal paths, each fail-closed on the evidence that is actually
509 /// relevant to it.
510 pub health_withdrawn: Vec<PasswayOrigin>,
511}
512
513/// One A record currently live at the apex, as read back by
514/// `dns.record.list`.
515#[derive(Debug, Clone, PartialEq, Eq)]
516pub struct LiveApexRecord {
517 /// The published address.
518 pub content: String,
519 /// Whether the provider proxies it (Cloudflare orange-cloud).
520 pub proxied: bool,
521}
522
523/// What one apply has to change to converge the apex.
524#[derive(Debug, Clone, Default, PartialEq, Eq)]
525pub struct ApexRecordDiff {
526 /// Addresses to upsert as DNS-only A records.
527 pub upsert: Vec<String>,
528 /// Addresses to remove — surplus A records at the same name.
529 pub prune: Vec<String>,
530 /// Addresses that *would* have been pruned but were left in place because
531 /// [`DomainPasswayPlan::origins_complete`] was `false`.
532 ///
533 /// Kept rather than dropped so the applier can name the exact records it
534 /// declined to touch. A live A record sitting here is the visible symptom
535 /// of a broken ingress declaration somewhere in the workspace — it is
536 /// either a real withdrawal this apply refused to act on, or an origin the
537 /// collation could not see.
538 pub withheld_prune: Vec<String>,
539}
540
541impl ApexRecordDiff {
542 /// `true` when this apply has no write to make. Withheld prunes do not
543 /// count: they are deliberately not writes, and a converged-with-withheld
544 /// diff still has something to warn about.
545 pub fn is_converged(&self) -> bool {
546 self.upsert.is_empty() && self.prune.is_empty()
547 }
548}
549
550/// What [`deploy_domain_passway`] actually did, for the apply summary.
551#[derive(Debug, Clone, Default, PartialEq, Eq)]
552pub struct PasswayApexOutcome {
553 /// Addresses written (created or re-pointed to DNS-only).
554 pub upserted: Vec<String>,
555 /// Addresses withdrawn.
556 pub pruned: Vec<String>,
557 /// Addresses left in place that the declaration no longer names, because
558 /// the ingress collation was incomplete — see
559 /// [`DomainPasswayPlan::origins_complete`]. Surfaced in the outcome (not
560 /// only in the log) so `yah cloud apply` can print it: a withheld prune is
561 /// the operator's cue that some service's ingress declaration is broken.
562 pub withheld_prune: Vec<String>,
563}
564
565impl PasswayApexOutcome {
566 /// `true` when the live apex already matched the declaration and nothing
567 /// was written.
568 pub fn is_noop(&self) -> bool {
569 self.upserted.is_empty() && self.pruned.is_empty()
570 }
571}
572
573/// Parse `address` as a **publicly routable** IPv4, naming `machine` on
574/// failure.
575///
576/// This is the publicness cross-check nothing performs today: the `public-ip`
577/// taint and `connect.address` are two independent operator declarations, and
578/// [`crate::config::ConnectSpec::address`]'s own doc warns it is *whichever*
579/// address the operator chose to dial the box on — public, LAN, or tailnet
580/// (us-west-002 is deliberately pointed at its tailnet IP, R608-F10). Publishing
581/// a `100.64.0.x` or `192.168.x.x` A record on a public apex takes its share of
582/// the round-robin straight to a black hole, so a taint/address disagreement
583/// has to be a hard error rather than a silently-broken record.
584///
585/// Kept local to this arm on purpose (R859-F1 decision 4): a workspace-wide
586/// lint would fire on every machine that carries the taint for placement
587/// reasons while being dialled over the mesh, which is legitimate.
588fn public_ipv4_for(machine: &str, address: &str) -> Result<Ipv4Addr> {
589 let ip: Ipv4Addr = address.parse().map_err(|_| {
590 anyhow::anyhow!(
591 "machine {machine}: connect.address = {address:?} is not an IPv4 address, so it \
592 cannot be published as an apex A record. Either set it to the box's public IPv4 \
593 or drop the `public-ip` taint from {machine}.toml."
594 )
595 })?;
596 let o = ip.octets();
597 // 100.64.0.0/10 — RFC 6598 shared address space, which is also the
598 // tailnet range this fleet's mesh addresses live in.
599 let is_cgnat = o[0] == 100 && (64..=127).contains(&o[1]);
600 let reason = if ip.is_unspecified() {
601 Some("unspecified")
602 } else if ip.is_loopback() {
603 Some("loopback")
604 } else if ip.is_private() {
605 Some("RFC 1918 private")
606 } else if ip.is_link_local() {
607 Some("link-local")
608 } else if is_cgnat {
609 Some("RFC 6598 shared / tailnet")
610 } else if ip.is_multicast() {
611 Some("multicast")
612 } else if ip.is_broadcast() {
613 Some("broadcast")
614 } else {
615 None
616 };
617 if let Some(reason) = reason {
618 anyhow::bail!(
619 "machine {machine}: connect.address = {address:?} is a {reason} address, not a \
620 public one — publishing it at a public apex would black-hole its share of the \
621 round-robin. Set connect.address to the box's public IPv4 or drop the \
622 `public-ip` taint from {machine}.toml."
623 );
624 }
625 Ok(ip)
626}
627
628/// Resolve collated front-door machine names to the public origins the apex
629/// should publish (R859-F1).
630///
631/// Keeps only machines carrying the [`workload_spec::PUBLIC_IP_TAINT`]
632/// affinity taint — the same declaration that makes yubaba willing to place a
633/// public ingress appliance there — and reads each one's
634/// [`ConnectSpec::address`](crate::config::ConnectSpec::address). A named
635/// machine that is absent from `machines` is an error, not a silent drop: the
636/// alternative is an apex that quietly shrinks because a machine TOML was
637/// renamed.
638///
639/// Machines without the taint are skipped silently. That is the intended
640/// filter, not a swallowed error — a passway edge may be collated onto a
641/// mesh-only box that fronts internal hostnames.
642///
643/// # `health_excluded` — R859-F2's withdrawal seam
644///
645/// Machines named in `health_excluded` are declared, resolvable, and dropped
646/// from the origin list anyway, because something confirmed they are down. The
647/// resulting shorter list flows through [`plan_domain_passway`] and
648/// [`diff_apex_records`] unchanged, so the withdrawal renders as an ordinary
649/// prune and needs no second entry point — the whole point of extending this
650/// function rather than adding a parallel one.
651///
652/// Exclusions are returned separately (see [`ResolvedOrigins`]) rather than
653/// silently vanishing, because "declared but withheld for health" is a
654/// different fact from "never declared", and [`diff_apex_records`] has to be
655/// able to tell them apart. See [`DomainPasswayPlan::health_withdrawn`].
656///
657/// An excluded machine is still *resolved* first: it must exist, carry the
658/// taint and have a public address, exactly as if it were staying. Skipping
659/// those checks would let a health exclusion paper over a config error, and a
660/// machine we cannot resolve is one we cannot honestly say we are withdrawing.
661///
662/// A name in `health_excluded` that is not a front door at all is ignored — the
663/// caller's liveness view covers the whole fleet, and most of it never fronts
664/// anything.
665pub fn public_origins(
666 front_door_machines: &[String],
667 machines: &[MachineConfig],
668 health_excluded: &[String],
669) -> Result<ResolvedOrigins> {
670 let mut resolved = ResolvedOrigins::default();
671 for name in front_door_machines {
672 let machine = machines
673 .iter()
674 .find(|m| &m.name == name)
675 .with_context(|| {
676 format!(
677 "front door collated onto machine {name:?}, which is in neither this \
678 camp's own .yah/infra/machines/*.toml nor any fleet it borrows through \
679 .yah/infra/sources.toml — cannot resolve its public address"
680 )
681 })?;
682 if !machine
683 .taints
684 .iter()
685 .any(|t| t == workload_spec::PUBLIC_IP_TAINT)
686 {
687 debug!(
688 machine = %name,
689 "front-door machine has no `public-ip` taint — not an apex origin"
690 );
691 continue;
692 }
693 let address = machine
694 .connect
695 .as_ref()
696 .map(|c| c.address.as_str())
697 .with_context(|| {
698 format!(
699 "machine {name} carries the `public-ip` taint but declares no \
700 [connect] address — nothing to publish at the apex"
701 )
702 })?;
703 let origin = PasswayOrigin {
704 machine: name.clone(),
705 address: public_ipv4_for(name, address)?,
706 };
707 if health_excluded.iter().any(|m| m == name) {
708 debug!(
709 machine = %name,
710 address = %origin.address,
711 "front-door machine confirmed down — withheld from the apex origin set"
712 );
713 resolved.health_withdrawn.push(origin);
714 } else {
715 resolved.origins.push(origin);
716 }
717 }
718 Ok(resolved)
719}
720
721/// Build the apex record plan for a `front_door = "passway"` manifest.
722///
723/// Refuses two shapes outright:
724///
725/// 1. A manifest declaring a different front door — the same guard
726/// [`plan_domain_worker`] carries, for the same reason.
727/// 2. An **empty** origin set. The desired set is what the applier prunes
728/// against, so an empty one would not mean "leave it alone", it would mean
729/// "delete every A record at the apex" — a live outage rendered from a
730/// collation that simply found nothing. Nothing about "no machine is
731/// declared" says "take the site down", so it is an error.
732///
733/// That empty-set guard is checked **before** `origins_complete` is consulted:
734/// an empty set is unusable whether or not the collation was clean, so it stays
735/// the louder failure.
736///
737/// `origins_complete` is the caller's answer to "did the collation see the
738/// whole picture?" — `false` withholds every prune downstream. See
739/// [`DomainPasswayPlan::origins_complete`].
740///
741/// # The empty-set guard is also the health failover's backstop (R859-F2)
742///
743/// Guard 2 is checked against the origins that **survive** health exclusion, and
744/// that is the load-bearing interaction of this whole feature: if every declared
745/// front door is confirmed down, `origins` is empty and this refuses. "All our
746/// front doors are down" must never render as "withdraw every A record and take
747/// the site down" — a dead origin still in DNS is a partial outage, an empty
748/// apex is a total one, and between those the first is strictly better. So the
749/// health withdrawal is capped at "all but the last origin" by construction,
750/// with no separate rule to keep in sync.
751///
752/// An address that is *also* served by a surviving origin is dropped from
753/// [`health_withdrawn`](DomainPasswayPlan::health_withdrawn) for the same
754/// reason, one level finer: two machines can share a floating IP (the dedup
755/// comment below names that case), and pruning the record because one of them
756/// died would withdraw an address the other is still answering on.
757pub fn plan_domain_passway(
758 domain: &DomainConfig,
759 resolved: ResolvedOrigins,
760 origins_complete: bool,
761) -> Result<DomainPasswayPlan> {
762 let ResolvedOrigins {
763 origins,
764 health_withdrawn,
765 } = resolved;
766 if domain.front_door != FrontDoor::Passway {
767 anyhow::bail!(
768 "domain {} ({}) declares front_door = \"{}\" — only \"passway\" domains \
769 get a sovereign apex rendered here",
770 domain.name,
771 domain.domain,
772 domain.front_door.as_str()
773 );
774 }
775
776 // By address, not by machine name: the address is what lands in DNS, so
777 // ordering on it is what makes two planning runs byte-identical even if a
778 // box is renamed. Dedup on the same key — two edges collated onto one
779 // machine (or two machines sharing a floating IP) publish one record.
780 let mut origins = origins;
781 origins.sort_by(|a, b| (a.address, &a.machine).cmp(&(b.address, &b.machine)));
782 origins.dedup_by(|a, b| a.address == b.address);
783
784 if origins.is_empty() {
785 anyhow::bail!(
786 "domain {} ({}) declares front_door = \"passway\" but no declared front-door \
787 machine carries the `public-ip` taint with a public address — refusing to \
788 render an empty apex, which would withdraw every A record and take the site \
789 down. Declare the ingress edge's machines (or their taints) first.",
790 domain.name,
791 domain.domain
792 );
793 }
794
795 // Same normalisation as `origins`, plus the survivor filter: an address a
796 // live origin still answers on is not withdrawn, however many of the
797 // machines sharing it went down.
798 let mut health_withdrawn = health_withdrawn;
799 health_withdrawn.retain(|w| !origins.iter().any(|o| o.address == w.address));
800 health_withdrawn.sort_by(|a, b| (a.address, &a.machine).cmp(&(b.address, &b.machine)));
801 health_withdrawn.dedup_by(|a, b| a.address == b.address);
802
803 Ok(DomainPasswayPlan {
804 zone: parent_zone_name(&domain.domain).to_string(),
805 name: domain.domain.clone(),
806 origins,
807 origins_complete,
808 health_withdrawn,
809 })
810}
811
812/// Diff the planned apex against the A records live at that name.
813///
814/// `live` must already be narrowed to **type A at `plan.name`** — MX, TXT and
815/// AAAA records share the apex and are never this arm's to touch.
816///
817/// A live record whose content is desired but which is *proxied* still lands in
818/// `upsert`: orange-cloud is a break-glass state
819/// (`scripts/cf-apex-mode.sh orange`) and this reconciler declares DNS-only, so
820/// re-writing the record with `proxied = false` is convergence, not churn.
821///
822/// When [`plan.origins_complete`](DomainPasswayPlan::origins_complete) is
823/// `false` the surplus records move to
824/// [`withheld_prune`](ApexRecordDiff::withheld_prune) instead of `prune`, and
825/// `upsert` is unaffected. An incomplete collation cannot tell a withdrawn
826/// origin from one whose declaration failed to plan, and only one of those two
827/// wants a DNS withdrawal.
828///
829/// # …with one exception, and it is the point of R859-F2
830///
831/// A surplus address that appears in
832/// [`plan.health_withdrawn`](DomainPasswayPlan::health_withdrawn) is pruned
833/// **regardless of `origins_complete`**. The two flags are not two strengths of
834/// the same doubt, they are answers to different questions, and the four cases
835/// come out like this:
836///
837/// | `origins_complete` | in `health_withdrawn` | verdict |
838/// |---|---|---|
839/// | `true` | no | `prune` — an ordinary withdrawal from a trusted declaration |
840/// | `true` | yes | `prune` — a health withdrawal from a trusted declaration |
841/// | `false` | no | `withheld_prune` — might be a withdrawal, might be a typo |
842/// | `false` | yes | `prune` — we saw this machine declared *and* saw it die |
843///
844/// The bottom-right cell is the one that matters. `origins_complete = false`
845/// protects against mistaking an absence for a withdrawal; a health withdrawal
846/// is not an absence, it is a positive observation about a machine the
847/// collation resolved. An unrelated service's broken TOML is not evidence about
848/// a box we watched go down, and letting it withhold the prune would leave a
849/// dead origin serving its share of the round-robin for as long as that typo
850/// lives.
851pub fn diff_apex_records(plan: &DomainPasswayPlan, live: &[LiveApexRecord]) -> ApexRecordDiff {
852 let desired: Vec<String> = plan
853 .origins
854 .iter()
855 .map(|o| o.address.to_string())
856 .collect();
857 let upsert = desired
858 .iter()
859 .filter(|ip| {
860 !live
861 .iter()
862 .any(|r| &&r.content == ip && !r.proxied)
863 })
864 .cloned()
865 .collect();
866 let surplus: Vec<String> = live
867 .iter()
868 .filter(|r| !desired.contains(&r.content))
869 .map(|r| r.content.clone())
870 .collect();
871 if plan.origins_complete {
872 return ApexRecordDiff {
873 upsert,
874 prune: surplus,
875 withheld_prune: Vec::new(),
876 };
877 }
878 // Incomplete declaration: withhold every prune EXCEPT the health
879 // withdrawals, which rest on a positive observation rather than on an
880 // absence. See this function's doc table.
881 let withdrawn: Vec<String> = plan
882 .health_withdrawn
883 .iter()
884 .map(|o| o.address.to_string())
885 .collect();
886 let (prune, withheld_prune) = surplus
887 .into_iter()
888 .partition(|content| withdrawn.contains(content));
889 ApexRecordDiff {
890 upsert,
891 prune,
892 withheld_prune,
893 }
894}
895
896/// Resolve the DNS adapter one passway apply talks to.
897///
898/// Its own function only so the read path and the write path cannot drift onto
899/// different credentials or a different provider seam.
900fn passway_envoy(workspace_root: &Path, provider_id: &str) -> Result<CloudflareEnvoy> {
901 let cf_provider = super::cf_creds::CfProvider::resolve(workspace_root, provider_id)?;
902 Ok(CloudflareEnvoy::new(
903 cf_provider.api_token()?,
904 cf_provider.account_id.clone(),
905 ))
906}
907
908/// The `list` leg of [`deploy_domain_passway`] — the live A records at one
909/// name, as `dns.record.list` reports them.
910///
911/// Takes `zone`/`name` rather than a [`DomainPasswayPlan`] because the
912/// no-door-declared branch of [`ensure_passway_apex`] has to read the apex
913/// *without* a plan — there are no origins to build one from, and whether the
914/// apex is currently serving is exactly the question it needs answered.
915async fn read_live_apex(
916 envoy: &CloudflareEnvoy,
917 zone: &str,
918 name: &str,
919) -> Result<Vec<LiveApexRecord>> {
920 let listed = envoy
921 .dns_record_list(DnsRecordListInput {
922 zone: zone.to_string(),
923 name: Some(name.to_string()),
924 record_type: Some("A".to_string()),
925 })
926 .await
927 .with_context(|| format!("listing A records at {name}"))?;
928 Ok(listed
929 .records
930 .into_iter()
931 .map(|r| LiveApexRecord {
932 content: r.content,
933 proxied: r.proxied,
934 })
935 .collect())
936}
937
938/// Read the live apex A records **without being able to write any** — the
939/// read-only half of [`deploy_domain_passway`], exposed so convergence can be
940/// checked against a real Cloudflare account by something that has no write
941/// path at all (R859-F1's live acceptance check,
942/// `tests/passway_apex_live.rs`).
943///
944/// Pair it with [`plan_passway_apex`] and [`diff_apex_records`] to answer "what
945/// would `yah cloud apply` change?" against live DNS. That is the *whole* of
946/// what an apply decides — same planner, same list verb, same diff — minus the
947/// two `dns.record.upsert` / `dns.record.delete` calls, which this function
948/// cannot reach. A caller can therefore assert convergence against the real
949/// zone without a live-DNS blast radius, which is what makes the check safe to
950/// leave runnable rather than described in a handoff.
951pub async fn list_live_apex_records(
952 workspace_root: &Path,
953 provider_id: &str,
954 plan: &DomainPasswayPlan,
955) -> Result<Vec<LiveApexRecord>> {
956 let envoy = passway_envoy(workspace_root, provider_id)?;
957 read_live_apex(&envoy, &plan.zone, &plan.name).await
958}
959
960/// Apply a [`DomainPasswayPlan`] against live DNS (R859-F1, live I/O).
961///
962/// **First production consumer of the `dns.*` envoy verbs** — every read and
963/// write here goes through [`CloudflareEnvoy`]'s typed verb handlers rather
964/// than the `CloudflareClient` methods the older arms call directly, so
965/// swapping the DNS provider is a matter of resolving a different adapter here
966/// and nothing else.
967///
968/// Two invariants, both borrowed from `scripts/cf-apex-mode.sh`, whose
969/// behaviour this replaces for the routine case:
970///
971/// - **List first, skip when converged.** Same shape as
972/// [`ensure_r2_custom_domain`], so a re-run of `yah cloud apply` writes
973/// nothing.
974/// - **Upsert before prune.** Desired records are written first and surplus
975/// ones removed last, so the apex is never momentarily recordless. Only
976/// `A` records at the name are ever pruned; the apex's MX and TXT records
977/// (mail routing, SPF, site verification) are outside the filter by
978/// construction.
979/// - **Fail-closed on withdrawal, fail-open on addition.** When the plan says
980/// the origin set is incomplete
981/// ([`origins_complete = false`](DomainPasswayPlan::origins_complete)) the
982/// upserts still run but every prune is withheld and warned about — an
983/// absent origin might be a withdrawal or might be a config typo upstream,
984/// and only one of those should take a live record away.
985///
986/// Always writes `proxied = false`: the manifest cannot express grey vs
987/// orange, and orange stays break-glass in the script (W267 tier ladder).
988pub async fn deploy_domain_passway(
989 workspace_root: &Path,
990 provider_id: &str,
991 plan: &DomainPasswayPlan,
992) -> Result<PasswayApexOutcome> {
993 let envoy = passway_envoy(workspace_root, provider_id)?;
994 let live = read_live_apex(&envoy, &plan.zone, &plan.name).await?;
995
996 let diff = diff_apex_records(plan, &live);
997
998 // Warned before the converged early-return: a withheld prune is worth
999 // saying out loud even on an apply that writes nothing, because the record
1000 // it names stays live until someone fixes the declaration.
1001 if !diff.withheld_prune.is_empty() {
1002 warn!(
1003 domain = %plan.name,
1004 withheld = %diff.withheld_prune.join(", "),
1005 "apex A records NOT withdrawn: the ingress collation reported problems, so an \
1006 origin missing from it may be a broken declaration rather than a withdrawal. \
1007 Run `yah cloud validate` and fix the reported ingress declaration; these \
1008 records stay live until the collation is clean."
1009 );
1010 }
1011
1012 if diff.is_converged() {
1013 debug!(
1014 domain = %plan.name,
1015 origins = plan.origins.len(),
1016 "sovereign apex already matches the declaration — skipping"
1017 );
1018 return Ok(PasswayApexOutcome {
1019 withheld_prune: diff.withheld_prune,
1020 ..Default::default()
1021 });
1022 }
1023
1024 // Writes first: never leave the apex without a routing record.
1025 for ip in &diff.upsert {
1026 envoy
1027 .dns_record_upsert(DnsRecordUpsertInput {
1028 zone: plan.zone.clone(),
1029 name: plan.name.clone(),
1030 record_type: "A".to_string(),
1031 content: ip.clone(),
1032 ttl: 1,
1033 proxied: false,
1034 // A round-robin apex is a multi-valued RRset: without this the
1035 // second origin would overwrite the first.
1036 match_content: true,
1037 })
1038 .await
1039 .with_context(|| format!("upserting A {} -> {ip}", plan.name))?;
1040 info!(domain = %plan.name, origin = %ip, "sovereign apex A record written");
1041 }
1042
1043 // Prune last, and only ever type A carrying an undeclared value.
1044 for ip in &diff.prune {
1045 envoy
1046 .dns_record_delete(DnsRecordDeleteInput {
1047 zone: plan.zone.clone(),
1048 name: plan.name.clone(),
1049 record_type: Some("A".to_string()),
1050 content: Some(ip.clone()),
1051 })
1052 .await
1053 .with_context(|| format!("pruning surplus A {} -> {ip}", plan.name))?;
1054 info!(domain = %plan.name, origin = %ip, "surplus apex A record withdrawn");
1055 }
1056
1057 Ok(PasswayApexOutcome {
1058 upserted: diff.upsert,
1059 pruned: diff.prune,
1060 withheld_prune: diff.withheld_prune,
1061 })
1062}
1063
1064/// Reconcile one `front_door = "passway"` domain end to end — the entry point
1065/// `yah cloud apply` calls (R859-F1).
1066///
1067/// Collates the workspace's declared ingress edges, keeps the passway front
1068/// doors, resolves their machines to public addresses, plans, and applies.
1069/// Every input is *declared* config: no network read decides which origins the
1070/// apex publishes, which is what makes the manifest the single source the
1071/// two-source flip lacked.
1072///
1073/// The planning half is [`plan_passway_apex`] (which also carries the
1074/// `report.problems` prune gate); this function is that plan handed to
1075/// [`deploy_domain_passway`].
1076///
1077/// # `Ok(None)`: the door is declared but not stood up yet
1078///
1079/// A camp can legitimately declare `front_door = "passway"` on a domain whose
1080/// passway edge does not exist yet — the manifest field is how you *say* which
1081/// door you intend, and R859-F1 made it the mechanism as well, so it is written
1082/// before the edge is. The noisetable camp is exactly this: `noisetable.com`
1083/// declares `passway`, its only ingress edge is a `cloudflare-tunnel` on a
1084/// different domain, and its apex is deliberately NXDOMAIN pending a node.
1085///
1086/// Treating that as an error would have been wrong twice over. It fails the
1087/// whole `yah cloud apply` — the domain loop `bail!`s at the first failure
1088/// without `--continue-on-error` — so one not-yet-built door takes down the
1089/// publish chain for every service and every other domain in the camp. And the
1090/// empty-apex guard it would trip
1091/// ([`plan_domain_passway`]'s "refusing to render an empty apex") exists to
1092/// prevent a *wipe*, which skipping prevents equally well: this arm writes
1093/// nothing at all on this path.
1094///
1095/// So "no passway edge collated" is `Ok(None)` — but only after checking that
1096/// the apex is not **currently serving**. Those are two different worlds and
1097/// only a DNS read tells them apart:
1098///
1099/// - Nothing live at the name → the door was never stood up. Skip.
1100/// - Records live at the name → an edge that WAS fronting this apex has
1101/// vanished from the collation, and the next apply would otherwise silently
1102/// leave orphaned records pointing at whatever used to serve. That is a hard
1103/// error, and it is the case the empty-apex guard was really written for.
1104///
1105/// The read costs nothing extra in practice: this arm only runs inside the
1106/// domain pass, which already resolved a Cloudflare provider and an
1107/// `account_id` before entering the loop.
1108pub async fn ensure_passway_apex(
1109 workspace_root: &Path,
1110 provider_id: &str,
1111 domain: &DomainConfig,
1112) -> Result<Option<PasswayApexOutcome>> {
1113 let Some(plan) = plan_passway_apex(workspace_root, domain)? else {
1114 let zone = parent_zone_name(&domain.domain).to_string();
1115 let envoy = passway_envoy(workspace_root, provider_id)?;
1116 let live = read_live_apex(&envoy, &zone, &domain.domain).await?;
1117 if live.is_empty() {
1118 debug!(
1119 domain = %domain.domain,
1120 "declares front_door = \"passway\" with no passway ingress edge collated, and \
1121 nothing is live at the apex — nothing to render, skipping"
1122 );
1123 return Ok(None);
1124 }
1125 anyhow::bail!(
1126 "domain {} ({}) declares front_door = \"passway\" and {} A record(s) are live at \
1127 the apex ({}), but no passway ingress edge collates onto it as a public origin. \
1128 Either an edge that was fronting this apex has disappeared from the declaration, \
1129 and these records now point at whatever used to serve — restore the edge, or move \
1130 the domain off `passway` — or every door fronting it now sits behind a cloudflare \
1131 tunnel (`via = \"passway\"`, R910), whose hostname is a CNAME the tunnel owns: \
1132 delete these A records, since Cloudflare refuses a CNAME beside them.",
1133 domain.name,
1134 domain.domain,
1135 live.len(),
1136 live.iter()
1137 .map(|r| r.content.as_str())
1138 .collect::<Vec<_>>()
1139 .join(", "),
1140 );
1141 };
1142 deploy_domain_passway(workspace_root, provider_id, &plan)
1143 .await
1144 .map(Some)
1145}
1146
1147/// The pure half of [`ensure_passway_apex`]: collate, resolve, plan — no
1148/// network, no credential, no write path.
1149///
1150/// Split out so the apex a given workspace *would* publish can be computed by a
1151/// caller that must not be able to change it. `yah cloud apply` reaches it only
1152/// through [`ensure_passway_apex`]; the live acceptance check
1153/// (`tests/passway_apex_live.rs`) calls it directly and pairs it with
1154/// [`list_live_apex_records`], so the check exercises this exact planner rather
1155/// than a second copy of its logic that could agree with the live zone while
1156/// production disagreed.
1157///
1158/// Every input is *declared* config: no network read decides which origins the
1159/// apex publishes, which is what makes the manifest the single source the
1160/// two-source flip lacked.
1161///
1162/// ## Why `report.problems` gates the prune
1163///
1164/// [`collate_workspace_ingress`](crate::validate::collate_workspace_ingress)
1165/// returns `Ok` while **skipping** an edge whose declaration fails to plan
1166/// (`validate.rs`, the `IngressProblem::Declaration` arms). A passway edge with
1167/// a config typo therefore drops its machine out of `front_doors` silently, and
1168/// from inside the plan that is indistinguishable from the operator having
1169/// withdrawn the node — so without this gate a typo would render as a DNS
1170/// withdrawal and take that origin's share of the apex offline.
1171///
1172/// Any non-empty `problems` therefore sets `origins_complete = false`, which
1173/// withholds every prune for this apply. Deliberately *not* narrowed to the
1174/// problems that mention this domain's edge: attributing a problem to an edge
1175/// requires the very planning that failed, and "non-empty means the picture is
1176/// incomplete" is the whole of what the prune path needs to know. Equally
1177/// deliberately, problems do **not** fail the arm — one service's broken
1178/// ingress declaration must not block another's DNS apply, and upserts can
1179/// only ever add reachable origins.
1180///
1181/// ## `Ok(None)` — no passway edge collates at all
1182///
1183/// Distinct from every error this can return, and the distinction is the whole
1184/// point: a camp that declares `front_door = "passway"` before building the
1185/// door has made no mistake, and must not have its apply failed for it. See
1186/// [`ensure_passway_apex`], which decides what to do about it (and is the one
1187/// that can tell "never stood up" from "the door vanished", because that takes
1188/// a DNS read this pure function must not make).
1189///
1190/// A passway door a cloudflare tunnel stacks in front of for this hostname
1191/// (R910, [`NodeFrontDoor::stacked`](crate::reconciler::NodeFrontDoor::stacked))
1192/// counts as no door here: its hostname is a CNAME to the tunnel, so it has no
1193/// apex origin to publish, and a domain served only through such doors plans
1194/// to `None`.
1195///
1196/// Note the narrowness: `None` means the *collation* produced no passway edge.
1197/// A declared edge whose machine lacks the `public-ip` taint, or carries a
1198/// private address, still reaches [`plan_domain_passway`] and still trips its
1199/// empty-apex guard as an error — an intended door that resolves to nothing is
1200/// a misconfiguration, not an absence.
1201pub fn plan_passway_apex(
1202 workspace_root: &Path,
1203 domain: &DomainConfig,
1204) -> Result<Option<DomainPasswayPlan>> {
1205 let report = crate::validate::collate_workspace_ingress(workspace_root)
1206 .context("collating workspace ingress to find the passway front doors")?;
1207
1208 let origins_complete = report.problems.is_empty();
1209 if !origins_complete {
1210 for problem in &report.problems {
1211 warn!(
1212 domain = %domain.domain,
1213 "ingress collation problem — apex prunes withheld this apply: {}",
1214 problem.message()
1215 );
1216 }
1217 }
1218
1219 // Only the doors that front THIS domain's hostname (R858-B27). This used to
1220 // take every passway door in the camp, so a domain inherited doors declared
1221 // for a different hostname entirely: staging.noisetable.com, whose own edge
1222 // is on a LAN box with no public address, was published at production's
1223 // doors and served production's site from there.
1224 let mut front_door_machines: Vec<String> = report
1225 .collation
1226 .front_doors
1227 .iter()
1228 .filter(|fd| fd.provider == crate::config::IngressProvider::Passway)
1229 .filter(|fd| fd.rules.iter().any(|r| r.hostname == domain.domain))
1230 // R910: a door a cloudflare tunnel stacks in front of for this hostname
1231 // is not an apex origin. The world reaches it through the tunnel's
1232 // CNAME, so an A record for its machine would sit beside that CNAME —
1233 // and a tunnel-fronted node is usually one with no public address at
1234 // all, where `public_origins` would refuse the private one outright
1235 // (us-west-011's 192.168.10.11 is the case that filed this).
1236 .filter(|fd| !fd.stacked.contains(&domain.domain))
1237 .map(|fd| fd.machine.clone())
1238 .collect();
1239 front_door_machines.sort();
1240 front_door_machines.dedup();
1241
1242 // The door is declared but not built yet. Answered here rather than left to
1243 // plan_domain_passway's empty-apex guard, because an absent edge and an
1244 // edge that resolves to no public address want opposite treatment.
1245 if front_door_machines.is_empty() {
1246 return Ok(None);
1247 }
1248
1249 // R870-B13: the camp's resolved fleet inventory, not its camp-local
1250 // machine files. A camp that BORROWS another camp's fleet (empty
1251 // `.yah/infra/machines/`, one `[[source]]` link in
1252 // `.yah/infra/sources.toml`) declares no machines of its own, so reading
1253 // the camp-local loader here failed the apex render on a front-door
1254 // machine that was declared all along — in the owner's tree, which the
1255 // link names. The inventory is the one reader every name lookup shares.
1256 let inventory = crate::config::resolve_fleet_inventory(workspace_root)
1257 .context("resolving the fleet inventory to place the apex origins")?;
1258
1259 // R859-F2: no health exclusions on the `yah cloud apply` path, deliberately
1260 // and not as a stub. The confirmed-down fact is a *fleet-runtime* one —
1261 // it comes from a leader's lease hysteresis, gated on a healthy quorum
1262 // (`plan_ingress_owner_effect`) — and `yah cloud apply` is an operator
1263 // running a declarative converge from a laptop, which holds no such view.
1264 // Feeding it a liveness guess here would let a laptop with a flaky uplink
1265 // withdraw a live origin. The exclusion set enters through the effector
1266 // that *has* the fact; this path renders the declaration as declared.
1267 //
1268 // The source accounting rides along on the error rather than the success
1269 // path: "no such machine" reads identically whether this camp declared no
1270 // link, aimed one at a directory that is not a camp, or filtered the
1271 // machine out with `select`, and the operator needs to know which.
1272 let resolved =
1273 public_origins(&front_door_machines, &inventory.machines, &[]).map_err(|e| {
1274 let sources = inventory.describe_sources();
1275 if sources.is_empty() {
1276 e
1277 } else {
1278 e.context(sources)
1279 }
1280 })?;
1281 plan_domain_passway(domain, resolved, origins_complete).map(Some)
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286 use super::parent_zone_name;
1287
1288 #[test]
1289 fn parent_zone_strips_one_label_off_subdomain() {
1290 assert_eq!(parent_zone_name("cdn.yah.dev"), "yah.dev");
1291 assert_eq!(parent_zone_name("app.yah.dev"), "yah.dev");
1292 }
1293
1294 #[test]
1295 fn parent_zone_returns_self_for_apex() {
1296 assert_eq!(parent_zone_name("yah.dev"), "yah.dev");
1297 }
1298
1299 #[test]
1300 fn parent_zone_strips_only_first_label_for_deeper_subdomain() {
1301 // Two-label heuristic: yah-side zones are all two-label apexes today.
1302 assert_eq!(parent_zone_name("a.b.yah.dev"), "yah.dev");
1303 }
1304
1305 use super::{plan_domain_worker, DomainWorkerPlan};
1306 use crate::config::{DomainConfig, DomainRoute, FrontDoor, RouteMode};
1307 use crate::reconciler::mesofact_static::ConfigBinding;
1308 use crate::route_table::WorkerAssets;
1309
1310 const NET_TIER: WorkerAssets<'static> = WorkerAssets::PerComponent {
1311 cdn_base: "https://cdn.net.yah.dev",
1312 env: "prod",
1313 };
1314
1315 /// The plain-text half of a plan's bindings, by name.
1316 fn plain_text(plan: &DomainWorkerPlan) -> std::collections::BTreeMap<String, String> {
1317 plan.bindings
1318 .iter()
1319 .filter_map(|b| match b {
1320 ConfigBinding::PlainText { name, text } => Some((name.clone(), text.clone())),
1321 ConfigBinding::R2Bucket { .. } => None,
1322 })
1323 .collect()
1324 }
1325
1326 /// The R2 half of a plan's bindings: `(binding name, bucket)`, in order.
1327 fn r2_buckets(plan: &DomainWorkerPlan) -> Vec<(&str, &str)> {
1328 plan.bindings
1329 .iter()
1330 .filter_map(|b| match b {
1331 ConfigBinding::R2Bucket { name, bucket_name } => {
1332 Some((name.as_str(), bucket_name.as_str()))
1333 }
1334 ConfigBinding::PlainText { .. } => None,
1335 })
1336 .collect()
1337 }
1338
1339 /// cdn.noisetable.com's decided shape, abridged: several prefixes over
1340 /// three buckets, one bucket named twice, the default bucket last.
1341 fn multi_bucket_manifest() -> DomainConfig {
1342 let bucket_route = |path: &str, bucket: &str| DomainRoute {
1343 headers: Default::default(),
1344 path: path.into(),
1345 mode: RouteMode::StaticBucket {
1346 bucket: bucket.into(),
1347 },
1348 };
1349 DomainConfig {
1350 schema_version: 1,
1351 name: "cdn-noisetable-com".into(),
1352 domain: "cdn.noisetable.com".into(),
1353 front_door: FrontDoor::Worker,
1354 cdn_bucket: "noisetable-marketing".into(),
1355 worker_bundle_path: None,
1356 routes: vec![
1357 bucket_route("/engine/*", "noisetable-releases"),
1358 bucket_route("/nt-cas/*", "noisetable-assets"),
1359 bucket_route("/dev/*", "noisetable-releases"),
1360 bucket_route("/*", "noisetable-marketing"),
1361 ],
1362 }
1363 }
1364
1365 /// R560-F13 acceptance: one Worker domain over several buckets compiles
1366 /// every route, in manifest order, to an entry naming the R2 binding it
1367 /// reads — distinct per bucket, shared by routes naming the same bucket,
1368 /// and carrying NO origin (these buckets have no public hostname).
1369 #[test]
1370 fn a_multi_bucket_domain_compiles_each_route_to_its_buckets_binding_in_manifest_order() {
1371 let plan = plan_domain_worker(&multi_bucket_manifest(), WorkerAssets::Unplaced).unwrap();
1372 let table = table_of(&plan);
1373 let got: Vec<(&str, &str, &str)> = table
1374 .iter()
1375 .map(|e| {
1376 assert_eq!(e["mode"], "static", "{e}");
1377 assert!(
1378 e.get("origin").is_none() && e.get("component").is_none(),
1379 "a bucket entry is read through its binding, never fetched: {e}"
1380 );
1381 (
1382 e["path"].as_str().unwrap(),
1383 e["bucket"].as_str().unwrap(),
1384 e["binding"].as_str().unwrap(),
1385 )
1386 })
1387 .collect();
1388 assert_eq!(
1389 got,
1390 [
1391 ("/engine/*", "noisetable-releases", "R2_NOISETABLE_RELEASES"),
1392 ("/nt-cas/*", "noisetable-assets", "R2_NOISETABLE_ASSETS"),
1393 ("/dev/*", "noisetable-releases", "R2_NOISETABLE_RELEASES"),
1394 ("/*", "noisetable-marketing", "R2_NOISETABLE_MARKETING"),
1395 ]
1396 );
1397 }
1398
1399 /// The binding list carries exactly one R2 bucket binding per DISTINCT
1400 /// bucket, in first-use order, and every binding a table entry names is
1401 /// one the upload creates — a name the upload lacks is a 502 at the door.
1402 #[test]
1403 fn the_binding_list_carries_one_r2_bucket_binding_per_distinct_bucket() {
1404 let plan = plan_domain_worker(&multi_bucket_manifest(), WorkerAssets::Unplaced).unwrap();
1405 let r2 = r2_buckets(&plan);
1406 assert_eq!(
1407 r2,
1408 [
1409 ("R2_NOISETABLE_RELEASES", "noisetable-releases"),
1410 ("R2_NOISETABLE_ASSETS", "noisetable-assets"),
1411 ("R2_NOISETABLE_MARKETING", "noisetable-marketing"),
1412 ]
1413 );
1414 for entry in table_of(&plan) {
1415 let binding = entry["binding"].as_str().unwrap();
1416 assert!(r2.iter().any(|(name, _)| *name == binding), "{binding} not bound");
1417 }
1418 // No component route, so nothing falls through to an HTTP origin.
1419 assert_eq!(plan.asset_origin, None);
1420 assert_eq!(plain_text(&plan)["ASSET_ORIGIN"], "");
1421 assert_eq!(
1422 plan.bindings.iter().map(ConfigBinding::name).collect::<Vec<_>>(),
1423 [
1424 "ASSET_ORIGIN",
1425 "POINTER_ORIGIN",
1426 "WORKER_MODE",
1427 "ROUTE_TABLE",
1428 "R2_NOISETABLE_RELEASES",
1429 "R2_NOISETABLE_ASSETS",
1430 "R2_NOISETABLE_MARKETING",
1431 ]
1432 );
1433 }
1434
1435 /// The alias tier must not move: a component-only domain deploys exactly
1436 /// the binding set it did before R560-F13 — no R2 binding, and a
1437 /// `ROUTE_TABLE` byte-identical to R898-T4's.
1438 #[test]
1439 fn a_component_only_domain_deploys_exactly_todays_bindings() {
1440 let plan = plan_domain_worker(&net_tier_manifest(), NET_TIER).unwrap();
1441 let plain = |name: &str, text: &str| ConfigBinding::PlainText {
1442 name: name.into(),
1443 text: text.into(),
1444 };
1445 assert_eq!(
1446 plan.bindings,
1447 vec![
1448 plain("ASSET_ORIGIN", "https://cdn.net.yah.dev/scrabcake/prod"),
1449 plain("POINTER_ORIGIN", "https://cdn.net.yah.dev/scrabcake/prod"),
1450 plain("WORKER_MODE", "static"),
1451 plain(
1452 "ROUTE_TABLE",
1453 r#"[{"path":"/*","mode":"static","component":"scrabcake/site","origin":"https://cdn.net.yah.dev/scrabcake/prod","auth":"anonymous"}]"#,
1454 ),
1455 ]
1456 );
1457 }
1458
1459 /// A manifest-only Worker has no CDN tier to resolve a component against,
1460 /// so a component route is refused naming itself — never deployed with an
1461 /// origin nobody computed.
1462 #[test]
1463 fn an_unplaced_worker_refuses_a_component_static_route() {
1464 let err = plan_domain_worker(&net_tier_manifest(), WorkerAssets::Unplaced).unwrap_err();
1465 let msg = format!("{err:#}");
1466 assert!(msg.contains("scrabcake/site"), "{msg}");
1467 assert!(msg.contains("no resolved origin"), "{msg}");
1468 }
1469
1470 fn net_tier_manifest() -> DomainConfig {
1471 // Mirrors .yah/domains/scrabcake-net-yah-dev.toml.
1472 DomainConfig {
1473 schema_version: 1,
1474 name: "scrabcake-net-yah-dev".into(),
1475 domain: "scrabcake.net.yah.dev".into(),
1476 front_door: FrontDoor::Worker,
1477 cdn_bucket: "net-yah-dev".into(),
1478 worker_bundle_path: None,
1479 routes: vec![DomainRoute {
1480 headers: Default::default(),
1481 path: "/*".into(),
1482 mode: RouteMode::Static {
1483 component: "scrabcake/site".into(),
1484 },
1485 }],
1486 }
1487 }
1488
1489 /// The compiled `ROUTE_TABLE` binding, parsed.
1490 fn table_of(plan: &DomainWorkerPlan) -> Vec<serde_json::Value> {
1491 let raw = plain_text(plan)
1492 .remove("ROUTE_TABLE")
1493 .expect("ROUTE_TABLE binding");
1494 serde_json::from_str(&raw).expect("ROUTE_TABLE is JSON")
1495 }
1496
1497 /// R898-T4 REWROTE THIS TEST, and what it used to pin is the reason.
1498 ///
1499 /// It asserted the literal six-binding list `static_worker_bindings`
1500 /// emitted — `UPLOAD_ORIGIN`, `SSR_ORIGIN`, `SSR_PREFIXES`,
1501 /// `ROUTE_HEADERS` included. R898-F3 deleted all four from the Worker,
1502 /// and because that binding set was a hand-kept twin of
1503 /// `worker_config_bindings` rather than a call to it, this test kept
1504 /// passing while pinning a set the bundled router no longer reads. The
1505 /// binding set is now the one producer's output, so this pins that.
1506 #[test]
1507 fn plan_resolves_worker_name_domain_and_the_one_producers_bindings() {
1508 let plan =
1509 plan_domain_worker(&net_tier_manifest(), NET_TIER).unwrap();
1510 assert_eq!(plan.worker_name, "scrabcake-net-yah-dev");
1511 assert_eq!(plan.custom_domain, "scrabcake.net.yah.dev");
1512 assert_eq!(
1513 plan.asset_origin.as_deref(),
1514 Some("https://cdn.net.yah.dev/scrabcake/prod")
1515 );
1516 assert_eq!(
1517 plan.bindings.iter().map(ConfigBinding::name).collect::<Vec<_>>(),
1518 ["ASSET_ORIGIN", "POINTER_ORIGIN", "WORKER_MODE", "ROUTE_TABLE"],
1519 "the alias tier must deploy the same binding set the mirror-driven \
1520 arm does — a twin is how it drifted last time"
1521 );
1522 let map = plain_text(&plan);
1523 assert_eq!(map["ASSET_ORIGIN"], "https://cdn.net.yah.dev/scrabcake/prod");
1524 assert_eq!(map["POINTER_ORIGIN"], "https://cdn.net.yah.dev/scrabcake/prod");
1525 assert_eq!(map["WORKER_MODE"], "static");
1526 let table = table_of(&plan);
1527 assert_eq!(table.len(), 1);
1528 assert_eq!(table[0]["path"], "/*");
1529 assert_eq!(table[0]["mode"], "static");
1530 assert_eq!(table[0]["component"], "scrabcake/site");
1531 assert_eq!(table[0]["origin"], "https://cdn.net.yah.dev/scrabcake/prod");
1532 }
1533
1534 /// R898-T4, the ticket's own acceptance: EVERY static route is planned, in
1535 /// manifest order, each resolved through its OWN component.
1536 ///
1537 /// This is also R560-F13's first verify line ("one Worker domain fronting
1538 /// several buckets" at the unit level). Before this, `plan_domain_worker`
1539 /// `find_map`-ed the first `RouteMode::Static` and dropped the rest with a
1540 /// bare `_ => None`, so the second site here was served by the first's
1541 /// catch-all — a wrong body with a 200, never a 404.
1542 #[test]
1543 fn plan_carries_every_static_route_in_manifest_order_with_its_own_origin() {
1544 let mut dom = net_tier_manifest();
1545 dom.routes = vec![
1546 DomainRoute {
1547 headers: Default::default(),
1548 path: "/docs/*".into(),
1549 mode: RouteMode::Static {
1550 component: "handbook/site".into(),
1551 },
1552 },
1553 DomainRoute {
1554 headers: Default::default(),
1555 path: "/*".into(),
1556 mode: RouteMode::Static {
1557 component: "scrabcake/site".into(),
1558 },
1559 },
1560 ];
1561 let plan = plan_domain_worker(&dom, NET_TIER).unwrap();
1562 let table = table_of(&plan);
1563 assert_eq!(table.len(), 2, "both static routes, not just the first");
1564 assert_eq!(table[0]["path"], "/docs/*");
1565 assert_eq!(table[0]["origin"], "https://cdn.net.yah.dev/handbook/prod");
1566 assert_eq!(table[1]["path"], "/*");
1567 assert_eq!(table[1]["origin"], "https://cdn.net.yah.dev/scrabcake/prod");
1568 // ASSET_ORIGIN is the single fall-through binding, so it stays the
1569 // first static route's origin; the per-route origins ride the table.
1570 assert_eq!(
1571 plan.asset_origin.as_deref(),
1572 Some("https://cdn.net.yah.dev/handbook/prod")
1573 );
1574 }
1575
1576 /// A `front_door = "worker"` domain's non-static routes are planned too —
1577 /// they were dropped entirely by the old `_ => None`. The backend origin is
1578 /// the manifest's DECLARED one, which R898-F3 established is the right
1579 /// answer for the Worker arm specifically.
1580 #[test]
1581 fn plan_carries_backend_and_redirect_routes_the_old_planner_dropped() {
1582 let mut dom = net_tier_manifest();
1583 dom.routes.insert(
1584 0,
1585 DomainRoute {
1586 headers: Default::default(),
1587 path: "/old/*".into(),
1588 mode: RouteMode::Redirect {
1589 target: "https://elsewhere.example".into(),
1590 status: 308,
1591 },
1592 },
1593 );
1594 let plan = plan_domain_worker(&dom, NET_TIER).unwrap();
1595 let table = table_of(&plan);
1596 assert_eq!(table.len(), 2);
1597 assert_eq!(table[0]["mode"], "redirect");
1598 assert_eq!(table[0]["target"], "https://elsewhere.example");
1599 assert_eq!(table[0]["status"], 308);
1600 assert_eq!(table[1]["mode"], "static");
1601 }
1602
1603 /// R746: headers declared on a route reach the deployed Worker. They rode
1604 /// the `ROUTE_HEADERS` binding until R898-F3 folded that table into
1605 /// `ROUTE_TABLE`; this asserts the same thing about the binding that
1606 /// survived. Without it the manifest could declare them and the Worker
1607 /// would serve without them — the exact silent gap the primitive exists to
1608 /// close.
1609 #[test]
1610 fn plan_carries_declared_route_headers_into_the_bindings() {
1611 let mut dom = net_tier_manifest();
1612 dom.routes[0].headers = [
1613 ("Cross-Origin-Opener-Policy".to_string(), "same-origin".to_string()),
1614 (
1615 "Cross-Origin-Embedder-Policy".to_string(),
1616 "require-corp".to_string(),
1617 ),
1618 ]
1619 .into_iter()
1620 .collect();
1621 let plan = plan_domain_worker(&dom, NET_TIER).unwrap();
1622 let table = table_of(&plan);
1623 assert_eq!(table[0]["path"], "/*");
1624 assert_eq!(
1625 table[0]["headers"]["Cross-Origin-Opener-Policy"],
1626 "same-origin"
1627 );
1628 assert_eq!(
1629 table[0]["headers"]["Cross-Origin-Embedder-Policy"],
1630 "require-corp"
1631 );
1632 }
1633
1634 /// A static route whose component ref is not `<service>/<component-id>`
1635 /// refuses the plan, naming the route — it does not quietly resolve to a
1636 /// truncated origin. The refusal moved from `plan_domain_worker`'s own
1637 /// `split_once` into `DomainConfig::route_table`, which names more.
1638 #[test]
1639 fn plan_refuses_a_malformed_component_ref() {
1640 let mut dom = net_tier_manifest();
1641 dom.routes[0].mode = RouteMode::Static {
1642 component: "no-slash".into(),
1643 };
1644 let err = plan_domain_worker(&dom, NET_TIER).unwrap_err();
1645 let msg = format!("{err:#}");
1646 assert!(msg.contains("no-slash"), "{msg}");
1647 assert!(msg.contains("no resolved origin"), "{msg}");
1648 }
1649
1650 #[test]
1651 fn plan_trims_trailing_slash_on_cdn_base() {
1652 let plan =
1653 plan_domain_worker(
1654 &net_tier_manifest(),
1655 WorkerAssets::PerComponent {
1656 cdn_base: "https://cdn.net.yah.dev/",
1657 env: "prod",
1658 },
1659 )
1660 .unwrap();
1661 assert_eq!(
1662 plan.asset_origin.as_deref(),
1663 Some("https://cdn.net.yah.dev/scrabcake/prod")
1664 );
1665 }
1666
1667 /// R594-F12: a manifest that declares a different front door must not
1668 /// get a Cloudflare Worker synthesized behind its back — the Worker
1669 /// would deploy and then never receive traffic.
1670 #[test]
1671 fn plan_bails_when_front_door_is_not_worker() {
1672 for door in [FrontDoor::BucketDirect, FrontDoor::Passway] {
1673 let mut dom = net_tier_manifest();
1674 dom.front_door = door;
1675 let err = plan_domain_worker(&dom, NET_TIER).unwrap_err();
1676 let msg = format!("{err:#}");
1677 assert!(msg.contains("front_door"), "{msg}");
1678 assert!(msg.contains(door.as_str()), "{msg}");
1679 }
1680 }
1681
1682 #[test]
1683 fn plan_bails_when_no_static_route() {
1684 let mut dom = net_tier_manifest();
1685 dom.routes = vec![DomainRoute {
1686 headers: Default::default(),
1687 path: "/old".into(),
1688 mode: RouteMode::Redirect {
1689 target: "https://elsewhere".into(),
1690 status: 308,
1691 },
1692 }];
1693 let err = plan_domain_worker(&dom, NET_TIER).unwrap_err();
1694 assert!(
1695 format!("{err:#}").contains("no `static` route"),
1696 "got: {err:#}"
1697 );
1698 }
1699
1700 // ── R561-F4: alias-tier registration ──
1701 use super::{plan_alias_claim, valid_subdomain_label, AliasTier};
1702 use std::collections::BTreeMap;
1703
1704 #[test]
1705 fn label_validation_rules() {
1706 assert!(valid_subdomain_label("scrabcake"));
1707 assert!(valid_subdomain_label("my-repo-1"));
1708 assert!(!valid_subdomain_label("")); // empty
1709 assert!(!valid_subdomain_label("-lead")); // leading hyphen
1710 assert!(!valid_subdomain_label("trail-")); // trailing hyphen
1711 assert!(!valid_subdomain_label("Caps")); // uppercase
1712 assert!(!valid_subdomain_label("under_score")); // underscore
1713 }
1714
1715 #[test]
1716 fn claim_builds_net_tier_manifest() {
1717 let dom = plan_alias_claim(
1718 AliasTier::Net,
1719 "scrabcake",
1720 "scrabcake/site",
1721 &BTreeMap::new(),
1722 )
1723 .unwrap();
1724 assert_eq!(dom.name, "scrabcake-net-yah-dev");
1725 assert_eq!(dom.domain, "scrabcake.net.yah.dev");
1726 assert_eq!(dom.cdn_bucket, "net-yah-dev");
1727 assert_eq!(dom.routes.len(), 1);
1728 assert!(
1729 matches!(&dom.routes[0].mode, RouteMode::Static { component } if component == "scrabcake/site")
1730 );
1731 }
1732
1733 #[test]
1734 fn claim_uses_com_tier_zone_and_bucket() {
1735 let dom = plan_alias_claim(AliasTier::Com, "acme", "acme/site", &BTreeMap::new()).unwrap();
1736 assert_eq!(dom.domain, "acme.com.yah.dev");
1737 assert_eq!(dom.cdn_bucket, "com-yah-dev");
1738 }
1739
1740 #[test]
1741 fn claim_bails_on_duplicate_host() {
1742 let existing: BTreeMap<String, DomainConfig> =
1743 [("scrabcake-net-yah-dev".to_string(), net_tier_manifest())]
1744 .into_iter()
1745 .collect();
1746 let err =
1747 plan_alias_claim(AliasTier::Net, "scrabcake", "scrabcake/site", &existing).unwrap_err();
1748 assert!(format!("{err:#}").contains("already"), "got: {err:#}");
1749 }
1750
1751 #[test]
1752 fn claim_bails_on_invalid_label() {
1753 let err =
1754 plan_alias_claim(AliasTier::Net, "Bad_Name", "x/y", &BTreeMap::new()).unwrap_err();
1755 assert!(
1756 format!("{err:#}").contains("invalid subdomain"),
1757 "got: {err:#}"
1758 );
1759 }
1760
1761 // ── R859-F1: sovereign apex (front_door = "passway") ──
1762 //
1763 // Everything here is the pure half. The blast radius of this arm is LIVE
1764 // DNS on a public apex, so the decision logic is exercised offline in full
1765 // and `deploy_domain_passway` only executes the diff these produce.
1766
1767 use super::{
1768 diff_apex_records, plan_domain_passway, plan_passway_apex, public_origins, ApexRecordDiff,
1769 DomainPasswayPlan, LiveApexRecord, PasswayOrigin, ResolvedOrigins,
1770 };
1771 use crate::config::{ConnectSpec, MachineConfig};
1772 use std::net::Ipv4Addr;
1773
1774 /// Mirrors `.yah/domains/yah-dev.toml` — the only `front_door = "passway"`
1775 /// manifest in the camp today.
1776 fn passway_manifest() -> DomainConfig {
1777 DomainConfig {
1778 schema_version: 1,
1779 name: "yah-dev".into(),
1780 domain: "yah.dev".into(),
1781 front_door: FrontDoor::Passway,
1782 cdn_bucket: "yah-dev".into(),
1783 worker_bundle_path: None,
1784 routes: vec![DomainRoute {
1785 headers: Default::default(),
1786 path: "/*".into(),
1787 mode: RouteMode::Static {
1788 component: "yah-marketing/site".into(),
1789 },
1790 }],
1791 }
1792 }
1793
1794 fn machine(name: &str, address: Option<&str>, taints: &[&str]) -> MachineConfig {
1795 MachineConfig {
1796 name: name.into(),
1797 provider: "ovh".into(),
1798 location: None,
1799 server_type: None,
1800 hosts_mirrors: vec![],
1801 mesh_tags: vec![],
1802 region: None,
1803 zone: None,
1804 arch: None,
1805 bucket: None,
1806 vendor: None,
1807 nickname: None,
1808 legacy_hostkey_fingerprint: None,
1809 registration: Default::default(),
1810 ssh_keys: vec![],
1811 cloudflared: None,
1812 hosts_operator_bridge: false,
1813 connect: address.map(|a| ConnectSpec {
1814 address: a.into(),
1815 ssh: format!("root@{a}"),
1816 identity_file: "~/.ssh/yah".into(),
1817 yubaba_port: None,
1818 yubaba: None,
1819 }),
1820 allocatable: None,
1821 taints: taints.iter().map(|t| t.to_string()).collect(),
1822 sovereign_group: None,
1823 sovereign_role: None,
1824 ingress_floating_ip: None,
1825 }
1826 }
1827
1828 /// The live fleet shape: us-east-001 and us-west-001 both carry the
1829 /// `public-ip` taint with a public address (machines/*.toml), a third box
1830 /// fronts internal hostnames over the mesh and must not reach the apex.
1831 fn fleet() -> Vec<MachineConfig> {
1832 vec![
1833 machine("us-east-001", Some("51.81.85.145"), &["public-ip"]),
1834 machine("us-west-001", Some("15.204.89.240"), &["public-ip"]),
1835 machine("us-west-002", Some("100.64.0.4"), &[]),
1836 ]
1837 }
1838
1839 fn origins(names: &[&str]) -> ResolvedOrigins {
1840 origins_excluding(names, &[])
1841 }
1842
1843 /// R859-F2: the same resolution with `down` confirmed dead — declared and
1844 /// resolvable, but held out of the published set.
1845 fn origins_excluding(names: &[&str], down: &[&str]) -> ResolvedOrigins {
1846 let fleet = fleet();
1847 let names: Vec<String> = names.iter().map(|n| n.to_string()).collect();
1848 let down: Vec<String> = down.iter().map(|n| n.to_string()).collect();
1849 public_origins(&names, &fleet, &down).unwrap()
1850 }
1851
1852 /// A plan whose origin set is COMPLETE — the clean-collation case.
1853 fn plan_of(names: &[&str]) -> DomainPasswayPlan {
1854 plan_domain_passway(&passway_manifest(), origins(names), true).unwrap()
1855 }
1856
1857 /// The same plan, but built from a collation that reported problems, so an
1858 /// origin's absence cannot be read as a withdrawal.
1859 fn plan_of_incomplete(names: &[&str]) -> DomainPasswayPlan {
1860 plan_domain_passway(&passway_manifest(), origins(names), false).unwrap()
1861 }
1862
1863 fn live(records: &[(&str, bool)]) -> Vec<LiveApexRecord> {
1864 records
1865 .iter()
1866 .map(|(content, proxied)| LiveApexRecord {
1867 content: (*content).into(),
1868 proxied: *proxied,
1869 })
1870 .collect()
1871 }
1872
1873 #[test]
1874 fn public_origins_keeps_tainted_machines_and_skips_the_rest() {
1875 let got = origins(&["us-east-001", "us-west-001", "us-west-002"]);
1876 assert!(
1877 got.health_withdrawn.is_empty(),
1878 "no exclusions were passed, so nothing may be withheld"
1879 );
1880 assert_eq!(
1881 got.origins,
1882 vec![
1883 PasswayOrigin {
1884 machine: "us-east-001".into(),
1885 address: Ipv4Addr::new(51, 81, 85, 145),
1886 },
1887 PasswayOrigin {
1888 machine: "us-west-001".into(),
1889 address: Ipv4Addr::new(15, 204, 89, 240),
1890 },
1891 ],
1892 "an untainted mesh-only front door is not an apex origin"
1893 );
1894 }
1895
1896 /// The publicness cross-check nothing did before R859-F1: the `public-ip`
1897 /// taint and `connect.address` are independent declarations, and a machine
1898 /// dialled over its tailnet address would otherwise publish `100.64.0.x`
1899 /// at a public apex and black-hole its share of the round-robin.
1900 #[test]
1901 fn public_origins_rejects_a_non_public_address_and_names_the_machine() {
1902 for bad in ["100.64.0.4", "10.0.0.7", "192.168.1.20", "127.0.0.1"] {
1903 let fleet = vec![machine("us-west-002", Some(bad), &["public-ip"])];
1904 let err = public_origins(&["us-west-002".to_string()], &fleet, &[]).unwrap_err();
1905 let msg = format!("{err:#}");
1906 assert!(msg.contains("us-west-002"), "{msg}");
1907 assert!(msg.contains(bad), "{msg}");
1908 assert!(msg.contains("public"), "{msg}");
1909 }
1910 }
1911
1912 #[test]
1913 fn public_origins_rejects_an_unparseable_address_and_names_the_machine() {
1914 let fleet = vec![machine("us-east-001", Some("edge.example.net"), &["public-ip"])];
1915 let err = public_origins(&["us-east-001".to_string()], &fleet, &[]).unwrap_err();
1916 let msg = format!("{err:#}");
1917 assert!(msg.contains("us-east-001"), "{msg}");
1918 assert!(msg.contains("not an IPv4 address"), "{msg}");
1919 }
1920
1921 #[test]
1922 fn public_origins_rejects_a_tainted_machine_with_no_connect_block() {
1923 let fleet = vec![machine("us-east-001", None, &["public-ip"])];
1924 let err = public_origins(&["us-east-001".to_string()], &fleet, &[]).unwrap_err();
1925 let msg = format!("{err:#}");
1926 assert!(msg.contains("us-east-001"), "{msg}");
1927 assert!(msg.contains("[connect]"), "{msg}");
1928 }
1929
1930 #[test]
1931 fn public_origins_rejects_a_machine_with_no_toml() {
1932 let err = public_origins(&["ghost-001".to_string()], &fleet(), &[]).unwrap_err();
1933 let msg = format!("{err:#}");
1934 assert!(msg.contains("ghost-001"), "{msg}");
1935 }
1936
1937 #[test]
1938 fn plan_sorts_and_dedups_origins_and_resolves_the_apex_zone() {
1939 let mut o = origins(&["us-west-001", "us-east-001"]);
1940 o.origins.push(o.origins[0].clone()); // same machine collated twice
1941 let plan = plan_domain_passway(&passway_manifest(), o, true).unwrap();
1942 assert_eq!(plan.zone, "yah.dev");
1943 assert_eq!(plan.name, "yah.dev");
1944 assert_eq!(
1945 plan.origins
1946 .iter()
1947 .map(|o| o.address.to_string())
1948 .collect::<Vec<_>>(),
1949 vec!["15.204.89.240", "51.81.85.145"],
1950 );
1951 }
1952
1953 /// A camp may declare `front_door = "passway"` BEFORE the passway edge
1954 /// exists — the field is how you say which door you intend, and R859-F1
1955 /// made it the mechanism as well, so it gets written first. The noisetable
1956 /// camp is exactly this: `noisetable.com` declares `passway`, its only
1957 /// ingress edge is a `cloudflare-tunnel` on a different domain, and its
1958 /// apex is deliberately NXDOMAIN pending a node of its own (passway serves
1959 /// one cert per listener — R777).
1960 ///
1961 /// That must plan to `None`, never to an `Err`. The domain loop in `yah
1962 /// cloud apply` bails at the first domain failure without
1963 /// `--continue-on-error`, so an error here would take down the publish
1964 /// chain of every service in the camp over a door nobody has built yet.
1965 #[test]
1966 fn a_passway_domain_with_no_ingress_edge_plans_to_none_rather_than_erroring() {
1967 let dir = tempfile::tempdir().unwrap();
1968 let root = dir.path();
1969 // A workspace with a services tree but nothing fronting anything —
1970 // the shape of a camp whose door is declared but not stood up.
1971 std::fs::create_dir_all(root.join(".yah/services")).unwrap();
1972
1973 let planned = plan_passway_apex(root, &passway_manifest())
1974 .expect("an undeclared door is not an error");
1975 assert!(
1976 planned.is_none(),
1977 "expected None (nothing to render), got {planned:?}"
1978 );
1979 }
1980
1981 /// R870-B13, end to end: a camp that BORROWS another camp's fleet renders
1982 /// its sovereign apex from the owner's machine declaration.
1983 ///
1984 /// This is the ticket's defect in one test. The borrowing camp's own
1985 /// `.yah/infra/machines/` is empty — it declares one `[[source]]` link in
1986 /// `.yah/infra/sources.toml` and pins its front door by name. Before the
1987 /// fix, `plan_passway_apex` read a camp-local-only loader, so the pinned
1988 /// name resolved against an empty fleet and the render died with "has no
1989 /// .yah/infra/machines/*.toml — cannot resolve its public address" on a
1990 /// machine that was declared all along, one directory over.
1991 ///
1992 /// The camp-local control assertion is what makes this non-vacuous: the
1993 /// borrowing camp genuinely holds no copy of the inventory, so a pass here
1994 /// can only come from the link being followed.
1995 #[test]
1996 fn a_borrowing_camp_renders_its_apex_from_the_owners_machine_declaration() {
1997 let dir = tempfile::tempdir().unwrap();
1998
1999 // The owner camp — the ONE copy of the inventory.
2000 let owner = dir.path().join("owner");
2001 std::fs::create_dir_all(owner.join(".yah/infra/machines")).unwrap();
2002 std::fs::write(
2003 owner.join(".yah/infra/machines/us-east-001.toml"),
2004 "name = \"us-east-001\"\nprovider = \"ovh\"\nmesh_tags = []\n\
2005 taints = [\"public-ip\"]\n\
2006 [connect]\naddress = \"51.81.85.145\"\nssh = \"root@51.81.85.145\"\n\
2007 identity_file = \"~/.ssh/yah\"\n",
2008 )
2009 .unwrap();
2010
2011 // The borrowing camp: empty machines dir, one link, one pinned edge.
2012 let borrower = dir.path().join("borrower");
2013 std::fs::create_dir_all(borrower.join(".yah/infra/machines")).unwrap();
2014 std::fs::write(
2015 borrower.join(".yah/infra/sources.toml"),
2016 "schema_version = 1\n[[source]]\nowner = \"owner\"\nkind = \"path\"\n\
2017 path = \"../owner\"\nmode = \"read-only\"\n",
2018 )
2019 .unwrap();
2020 let svc = borrower.join(".yah/services/marketing");
2021 std::fs::create_dir_all(svc.join("mirrors")).unwrap();
2022 std::fs::write(
2023 svc.join("service.toml"),
2024 "schema_version = 1\nname = \"marketing\"\n[address]\nkind = \"front-door\"\ndomain = \"yah.dev\"\n\
2025 [[components]]\nid = \"site\"\nkind = \"static-asset\"\n\
2026 path = \"marketing/site\"\nrole = \"static\"\n",
2027 )
2028 .unwrap();
2029 std::fs::write(
2030 svc.join("mirrors/cloud.toml"),
2031 "schema_version = 1\nshape = \"single-machine\"\n\
2032 ingress = \"passway\"\ningress_machines = [\"us-east-001\"]\n\
2033 [providers.compute]\nuse = \"hetzner\"\nzone = \"yah.dev\"\n\
2034 port = 8080\nupstream_host = \"100.64.0.5\"\n",
2035 )
2036 .unwrap();
2037
2038 // Control: the camp holds no machine declaration of its own.
2039 assert!(crate::validate::load_camp_local_machine_tomls(&borrower)
2040 .unwrap()
2041 .is_empty());
2042
2043 let plan = plan_passway_apex(&borrower, &passway_manifest())
2044 .expect("a borrowed front-door machine must resolve")
2045 .expect("the passway edge collates, so this is not the no-door case");
2046 assert_eq!(
2047 plan.origins
2048 .iter()
2049 .map(|o| o.address.to_string())
2050 .collect::<Vec<_>>(),
2051 vec!["51.81.85.145".to_string()],
2052 );
2053 }
2054
2055 /// R858-B27: a domain publishes only the doors that front ITS hostname.
2056 ///
2057 /// The camp below has two passway edges on two public machines: the apex
2058 /// (`yah.dev`) on us-east-001 and a staging hostname on us-west-001. Before
2059 /// the fix, `plan_passway_apex` took every passway door in the collation, so
2060 /// the apex would have published us-west-001 too. The live instance was
2061 /// staging.noisetable.com being published at production's doors.
2062 #[test]
2063 fn a_domain_publishes_only_the_doors_fronting_its_own_hostname() {
2064 let dir = tempfile::tempdir().unwrap();
2065 let root = dir.path();
2066 std::fs::create_dir_all(root.join(".yah/infra/machines")).unwrap();
2067 for (name, addr) in [("us-east-001", "51.81.85.145"), ("us-west-001", "15.204.89.240")] {
2068 std::fs::write(
2069 root.join(format!(".yah/infra/machines/{name}.toml")),
2070 format!(
2071 "name = \"{name}\"\nprovider = \"ovh\"\nmesh_tags = []\n\
2072 taints = [\"public-ip\"]\n\
2073 [connect]\naddress = \"{addr}\"\nssh = \"root@{addr}\"\n\
2074 identity_file = \"~/.ssh/yah\"\n"
2075 ),
2076 )
2077 .unwrap();
2078 }
2079 for (svc_name, mirror, machine, zone) in [
2080 ("marketing", "cloud", "us-east-001", "yah.dev"),
2081 ("staging-site", "staging", "us-west-001", "staging.yah.dev"),
2082 ] {
2083 let svc = root.join(format!(".yah/services/{svc_name}"));
2084 std::fs::create_dir_all(svc.join("mirrors")).unwrap();
2085 std::fs::write(
2086 svc.join("service.toml"),
2087 format!(
2088 "schema_version = 1\nname = \"{svc_name}\"\n[address]\nkind = \"front-door\"\ndomain = \"yah.dev\"\n\
2089 [[components]]\nid = \"site\"\nkind = \"static-asset\"\n\
2090 path = \"{svc_name}/site\"\nrole = \"static\"\n"
2091 ),
2092 )
2093 .unwrap();
2094 std::fs::write(
2095 svc.join(format!("mirrors/{mirror}.toml")),
2096 format!(
2097 "schema_version = 1\nshape = \"single-machine\"\n\
2098 ingress = \"passway\"\ningress_machines = [\"{machine}\"]\n\
2099 [providers.compute]\nuse = \"hetzner\"\nzone = \"{zone}\"\n\
2100 port = 8080\nupstream_host = \"100.64.0.5\"\n"
2101 ),
2102 )
2103 .unwrap();
2104 }
2105
2106 let plan = plan_passway_apex(root, &passway_manifest())
2107 .expect("both edges plan")
2108 .expect("the apex has a door of its own");
2109 assert_eq!(
2110 plan.origins
2111 .iter()
2112 .map(|o| o.address.to_string())
2113 .collect::<Vec<_>>(),
2114 vec!["51.81.85.145".to_string()],
2115 "the staging hostname's door must not be published at the apex"
2116 );
2117 }
2118
2119 /// R910: a passway door a cloudflare tunnel stacks in front of is not an
2120 /// apex origin. The machine is the one that filed it — `public-ip`-tainted
2121 /// but on a LAN address — so without the filter `public_origins` refuses
2122 /// 192.168.10.11 and the apply fails, instead of planning no apex at all.
2123 #[test]
2124 fn a_door_behind_a_cloudflare_tunnel_is_not_an_apex_origin() {
2125 let dir = tempfile::tempdir().unwrap();
2126 let root = dir.path();
2127 std::fs::create_dir_all(root.join(".yah/infra/machines")).unwrap();
2128 std::fs::write(
2129 root.join(".yah/infra/machines/us-west-011.toml"),
2130 "name = \"us-west-011\"\nprovider = \"ovh\"\nmesh_tags = []\n\
2131 taints = [\"public-ip\"]\n\
2132 [connect]\naddress = \"192.168.10.11\"\nssh = \"root@192.168.10.11\"\n\
2133 identity_file = \"~/.ssh/yah\"\n",
2134 )
2135 .unwrap();
2136 let svc = root.join(".yah/services/staging");
2137 std::fs::create_dir_all(svc.join("mirrors")).unwrap();
2138 std::fs::write(
2139 svc.join("service.toml"),
2140 "schema_version = 1\nname = \"staging\"\n[address]\nkind = \"front-door\"\ndomain = \"yah.dev\"\n\
2141 [[components]]\nid = \"site\"\nkind = \"static-asset\"\n\
2142 path = \"staging/site\"\nrole = \"static\"\n",
2143 )
2144 .unwrap();
2145 std::fs::write(
2146 svc.join("mirrors/staging.toml"),
2147 "schema_version = 1\nshape = \"single-machine\"\n\
2148 [[ingress]]\nprovider = \"passway\"\nmachines = [\"us-west-011\"]\n\
2149 hostnames = [\"yah.dev\"]\n\
2150 [ingress.tunnel_door]\ncontact_email = \"ops@yah.dev\"\nzone_id = \"z\"\n\
2151 token_secret = \"yah/cf-dns\"\nports = { \"yah.dev\" = 8445 }\n\
2152 [[ingress]]\nprovider = \"cloudflare-tunnel\"\nvia = \"passway\"\n\
2153 machines = [\"us-west-011\"]\nhostnames = [\"yah.dev\"]\n\
2154 [providers.compute]\nuse = \"hetzner\"\nzone = \"yah.dev\"\n\
2155 port = 8080\nupstream_host = \"100.64.0.5\"\n",
2156 )
2157 .unwrap();
2158
2159 let report = crate::validate::collate_workspace_ingress(root).unwrap();
2160 assert!(report.problems.is_empty(), "the pair plans cleanly");
2161 assert!(
2162 report
2163 .collation
2164 .front_doors
2165 .iter()
2166 .any(|d| d.provider == crate::config::IngressProvider::Passway
2167 && d.stacked == ["yah.dev"]),
2168 "the passway door collates, marked stacked"
2169 );
2170 assert!(
2171 plan_passway_apex(root, &passway_manifest())
2172 .expect("a tunnel-fronted door must not trip the private-address guard")
2173 .is_none(),
2174 "no apex origin for a door reached through the tunnel"
2175 );
2176 }
2177
2178 /// The failure that remains a failure, with the diagnosis attached: a
2179 /// pinned machine no linked source supplies must still error — and the
2180 /// error must name the links that were consulted, since "no such machine"
2181 /// alone cannot tell an undeclared link from a broken one.
2182 #[test]
2183 fn an_unresolvable_front_door_names_the_links_that_were_consulted() {
2184 let dir = tempfile::tempdir().unwrap();
2185 let borrower = dir.path().join("borrower");
2186 std::fs::create_dir_all(borrower.join(".yah/infra/machines")).unwrap();
2187 std::fs::write(
2188 borrower.join(".yah/infra/sources.toml"),
2189 "schema_version = 1\n[[source]]\nowner = \"owner\"\nkind = \"path\"\n\
2190 path = \"../not-a-camp\"\n",
2191 )
2192 .unwrap();
2193 let svc = borrower.join(".yah/services/marketing");
2194 std::fs::create_dir_all(svc.join("mirrors")).unwrap();
2195 std::fs::write(
2196 svc.join("service.toml"),
2197 "schema_version = 1\nname = \"marketing\"\n[address]\nkind = \"front-door\"\ndomain = \"yah.dev\"\n\
2198 [[components]]\nid = \"site\"\nkind = \"static-asset\"\n\
2199 path = \"marketing/site\"\nrole = \"static\"\n",
2200 )
2201 .unwrap();
2202 std::fs::write(
2203 svc.join("mirrors/cloud.toml"),
2204 "schema_version = 1\nshape = \"single-machine\"\n\
2205 ingress = \"passway\"\ningress_machines = [\"us-east-001\"]\n\
2206 [providers.compute]\nuse = \"hetzner\"\nzone = \"yah.dev\"\n\
2207 port = 8080\nupstream_host = \"100.64.0.5\"\n",
2208 )
2209 .unwrap();
2210
2211 let err = plan_passway_apex(&borrower, &passway_manifest()).unwrap_err();
2212 let msg = format!("{err:#}");
2213 assert!(msg.contains("us-east-001"), "{msg}");
2214 assert!(msg.contains("sources.toml"), "{msg}");
2215 assert!(msg.contains("owner"), "{msg}");
2216 assert!(msg.contains("ABSENT"), "{msg}");
2217 }
2218
2219 /// The other side of that discriminator, and the reason it is drawn at the
2220 /// COLLATION rather than at the resolved-address set: a machine that IS
2221 /// declared as a front door but yields no public address is a
2222 /// misconfiguration of an intended door, and stays an error.
2223 #[test]
2224 fn a_declared_front_door_that_resolves_to_no_public_address_still_errors() {
2225 // Declared as a front door, but carries no `public-ip` taint — so the
2226 // taint filter empties the set even though the edge exists.
2227 let machines = vec![machine("us-east-001", Some("51.81.85.145"), &[])];
2228 let resolved = public_origins(&["us-east-001".to_string()], &machines, &[]).unwrap();
2229 let err = plan_domain_passway(&passway_manifest(), resolved, true).unwrap_err();
2230 let msg = format!("{err:#}");
2231 assert!(msg.contains("empty apex"), "{msg}");
2232 }
2233
2234 /// The live-DNS blast radius this arm exists to bound: an empty desired
2235 /// set is what the applier prunes against, so rendering it would withdraw
2236 /// every A record at the apex. Nothing about "no machine declared it"
2237 /// means "take the site down" — it must be an error, not a wipe.
2238 #[test]
2239 fn empty_origin_set_is_an_error_not_an_apex_wipe() {
2240 let err = plan_domain_passway(&passway_manifest(), ResolvedOrigins::default(), true).unwrap_err();
2241 let msg = format!("{err:#}");
2242 assert!(msg.contains("refusing to render an empty apex"), "{msg}");
2243 assert!(msg.contains("yah.dev"), "{msg}");
2244 }
2245
2246 #[test]
2247 fn plan_bails_when_front_door_is_not_passway() {
2248 for door in [FrontDoor::BucketDirect, FrontDoor::Worker] {
2249 let mut dom = passway_manifest();
2250 dom.front_door = door;
2251 let err = plan_domain_passway(&dom, origins(&["us-east-001"]), true).unwrap_err();
2252 let msg = format!("{err:#}");
2253 assert!(msg.contains("front_door"), "{msg}");
2254 assert!(msg.contains(door.as_str()), "{msg}");
2255 }
2256 }
2257
2258 #[test]
2259 fn converged_apex_is_a_no_op() {
2260 let plan = plan_of(&["us-east-001", "us-west-001"]);
2261 let diff = diff_apex_records(
2262 &plan,
2263 &live(&[("51.81.85.145", false), ("15.204.89.240", false)]),
2264 );
2265 assert_eq!(diff, ApexRecordDiff::default());
2266 assert!(diff.is_converged());
2267 }
2268
2269 #[test]
2270 fn adding_a_machine_yields_exactly_one_added_record_and_no_prune() {
2271 let plan = plan_of(&["us-east-001", "us-west-001"]);
2272 let diff = diff_apex_records(&plan, &live(&[("51.81.85.145", false)]));
2273 assert_eq!(diff.upsert, vec!["15.204.89.240".to_string()]);
2274 assert!(diff.prune.is_empty(), "{diff:?}");
2275 }
2276
2277 #[test]
2278 fn removing_a_machine_yields_exactly_one_prune_and_leaves_the_survivor() {
2279 let plan = plan_of(&["us-east-001"]);
2280 let diff = diff_apex_records(
2281 &plan,
2282 &live(&[("51.81.85.145", false), ("15.204.89.240", false)]),
2283 );
2284 assert_eq!(diff.prune, vec!["15.204.89.240".to_string()]);
2285 assert!(
2286 diff.upsert.is_empty(),
2287 "the survivor is already correct: {diff:?}"
2288 );
2289 }
2290
2291 /// `scripts/cf-apex-mode.sh orange` is break-glass; the manifest declares
2292 /// DNS-only. A proxied record carrying a desired address is therefore
2293 /// drift, and re-upserting it (`proxied = false`) is the convergence.
2294 #[test]
2295 fn a_proxied_record_at_a_desired_address_is_rewritten_not_left_alone() {
2296 let plan = plan_of(&["us-east-001"]);
2297 let diff = diff_apex_records(&plan, &live(&[("51.81.85.145", true)]));
2298 assert_eq!(diff.upsert, vec!["51.81.85.145".to_string()]);
2299 assert!(
2300 diff.prune.is_empty(),
2301 "the address is declared — it must not be pruned: {diff:?}"
2302 );
2303 }
2304
2305 /// **Fail-closed on withdrawal.** `collate_workspace_ingress` returns `Ok`
2306 /// while SKIPPING an edge whose declaration fails to plan, so a passway
2307 /// edge with a config typo silently drops its machine from `front_doors` —
2308 /// which, from inside the plan, is indistinguishable from the operator
2309 /// withdrawing that node. Without this gate a typo would render as a DNS
2310 /// withdrawal and take that origin offline. Upserts must still run: an
2311 /// unrelated service's broken declaration cannot be allowed to block
2312 /// growing the fleet, and an upsert can never make the apex worse.
2313 #[test]
2314 fn an_incomplete_collation_upserts_but_withholds_every_prune() {
2315 // us-west-001 is missing from the front-door set, and the collation
2316 // reported problems — so its absence is not trustworthy evidence of a
2317 // withdrawal. us-east-001 is newly declared and must still be written.
2318 let plan = plan_of_incomplete(&["us-east-001"]);
2319 let diff = diff_apex_records(&plan, &live(&[("15.204.89.240", false)]));
2320
2321 assert_eq!(
2322 diff.upsert,
2323 vec!["51.81.85.145".to_string()],
2324 "additions must still land through a dirty collation"
2325 );
2326 assert!(
2327 diff.prune.is_empty(),
2328 "a live record must never be withdrawn on an incomplete collation: {diff:?}"
2329 );
2330 assert_eq!(
2331 diff.withheld_prune,
2332 vec!["15.204.89.240".to_string()],
2333 "the withheld record is reported so the applier can name it"
2334 );
2335
2336 // Same inputs, clean collation: the prune is real. This is the
2337 // control that proves the gate is what changed the outcome.
2338 let trusted = plan_of(&["us-east-001"]);
2339 let diff = diff_apex_records(&trusted, &live(&[("15.204.89.240", false)]));
2340 assert_eq!(diff.prune, vec!["15.204.89.240".to_string()]);
2341 assert!(diff.withheld_prune.is_empty(), "{diff:?}");
2342 }
2343
2344 /// The empty-set guard stays AHEAD of the incompleteness gate: an empty
2345 /// origin set is unusable whether or not the collation was clean, so it
2346 /// remains the louder failure rather than degrading into a silent
2347 /// everything-withheld apply.
2348 #[test]
2349 fn empty_origin_set_still_errors_on_an_incomplete_collation() {
2350 let err = plan_domain_passway(&passway_manifest(), ResolvedOrigins::default(), false).unwrap_err();
2351 assert!(
2352 format!("{err:#}").contains("refusing to render an empty apex"),
2353 "got: {err:#}"
2354 );
2355 }
2356
2357 // ── R859-F2: health-excluded origins ──────────────────────────────────
2358
2359 /// The exclusion happens after full resolution, and reports what it held
2360 /// back rather than dropping it silently — `health_withdrawn` is what makes
2361 /// "declared but dead" distinguishable from "never declared" downstream.
2362 #[test]
2363 fn a_confirmed_down_machine_is_resolved_then_withheld_not_dropped() {
2364 let got = origins_excluding(&["us-east-001", "us-west-001"], &["us-east-001"]);
2365 assert_eq!(
2366 got.origins,
2367 vec![PasswayOrigin {
2368 machine: "us-west-001".into(),
2369 address: Ipv4Addr::new(15, 204, 89, 240),
2370 }]
2371 );
2372 assert_eq!(
2373 got.health_withdrawn,
2374 vec![PasswayOrigin {
2375 machine: "us-east-001".into(),
2376 address: Ipv4Addr::new(51, 81, 85, 145),
2377 }],
2378 "a withheld origin must still be reported, with the address it would have published"
2379 );
2380 }
2381
2382 /// An exclusion naming a machine that fronts nothing is not an error. The
2383 /// caller's liveness view covers the whole fleet and most of it never
2384 /// fronts anything, so requiring the sets to line up would make every
2385 /// unrelated node failure an apex-render failure.
2386 #[test]
2387 fn excluding_a_machine_that_fronts_nothing_is_a_no_op() {
2388 let got = origins_excluding(&["us-east-001"], &["us-west-001", "ghost-001"]);
2389 assert_eq!(got.origins.len(), 1);
2390 assert!(got.health_withdrawn.is_empty());
2391 }
2392
2393 /// The catastrophic case, and the reason the empty-set guard is checked
2394 /// against the *survivors*: "every front door is down" must not render as
2395 /// "withdraw every A record". A dead origin still in DNS is a partial
2396 /// outage; an empty apex is a total one.
2397 #[test]
2398 fn excluding_every_origin_is_an_error_not_an_apex_wipe() {
2399 let resolved = origins_excluding(
2400 &["us-east-001", "us-west-001"],
2401 &["us-east-001", "us-west-001"],
2402 );
2403 assert_eq!(resolved.health_withdrawn.len(), 2);
2404 let err = plan_domain_passway(&passway_manifest(), resolved, true).unwrap_err();
2405 assert!(
2406 format!("{err:#}").contains("refusing to render an empty apex"),
2407 "got: {err:#}"
2408 );
2409 }
2410
2411 /// Two machines can share one address (a floating IP mid-move, or two edges
2412 /// collated onto one box). Losing one of them must not withdraw a record
2413 /// the other still answers on.
2414 #[test]
2415 fn an_address_a_live_origin_still_serves_is_not_withdrawn() {
2416 let fleet = vec![
2417 machine("us-east-001", Some("51.81.85.145"), &["public-ip"]),
2418 machine("us-east-002", Some("51.81.85.145"), &["public-ip"]),
2419 ];
2420 let resolved = public_origins(
2421 &["us-east-001".to_string(), "us-east-002".to_string()],
2422 &fleet,
2423 &["us-east-001".to_string()],
2424 )
2425 .unwrap();
2426 let plan = plan_domain_passway(&passway_manifest(), resolved, true).unwrap();
2427 assert_eq!(
2428 plan.origins
2429 .iter()
2430 .map(|o| o.address.to_string())
2431 .collect::<Vec<_>>(),
2432 vec!["51.81.85.145"]
2433 );
2434 assert!(
2435 plan.health_withdrawn.is_empty(),
2436 "us-east-002 still answers on that address, so it must not be pruned"
2437 );
2438 }
2439
2440 /// **The cross-product.** `origins_complete` and `health_withdrawn` are two
2441 /// different facts, and the bottom-right cell is the whole point of R859-F2:
2442 /// an unrelated service's broken declaration must not veto a withdrawal
2443 /// resting on a box we watched go down.
2444 #[test]
2445 fn health_withdrawal_and_declaration_completeness_are_independent() {
2446 let live = live(&[("51.81.85.145", false), ("15.204.89.240", false)]);
2447
2448 // complete + healthy: an ordinary withdrawal from a trusted declaration.
2449 let complete_healthy = plan_domain_passway(
2450 &passway_manifest(),
2451 origins(&["us-west-001"]),
2452 true,
2453 )
2454 .unwrap();
2455 let d = diff_apex_records(&complete_healthy, &live);
2456 assert_eq!(d.prune, vec!["51.81.85.145"]);
2457 assert!(d.withheld_prune.is_empty());
2458
2459 // complete + down: the same prune, now on health grounds.
2460 let complete_down = plan_domain_passway(
2461 &passway_manifest(),
2462 origins_excluding(&["us-east-001", "us-west-001"], &["us-east-001"]),
2463 true,
2464 )
2465 .unwrap();
2466 let d = diff_apex_records(&complete_down, &live);
2467 assert_eq!(d.prune, vec!["51.81.85.145"]);
2468 assert!(d.withheld_prune.is_empty());
2469
2470 // incomplete + healthy: might be a withdrawal, might be a typo — withheld.
2471 let incomplete_healthy = plan_domain_passway(
2472 &passway_manifest(),
2473 origins(&["us-west-001"]),
2474 false,
2475 )
2476 .unwrap();
2477 let d = diff_apex_records(&incomplete_healthy, &live);
2478 assert!(
2479 d.prune.is_empty(),
2480 "an absence under an incomplete collation must never prune"
2481 );
2482 assert_eq!(d.withheld_prune, vec!["51.81.85.145"]);
2483
2484 // incomplete + down: PRUNED ANYWAY. We saw this machine declared and we
2485 // saw it die; the incompleteness is about a different declaration.
2486 let incomplete_down = plan_domain_passway(
2487 &passway_manifest(),
2488 origins_excluding(&["us-east-001", "us-west-001"], &["us-east-001"]),
2489 false,
2490 )
2491 .unwrap();
2492 let d = diff_apex_records(&incomplete_down, &live);
2493 assert_eq!(
2494 d.prune,
2495 vec!["51.81.85.145"],
2496 "a health withdrawal rests on a positive observation, not on an absence, so \
2497 origins_complete = false must not suppress it"
2498 );
2499 assert!(d.withheld_prune.is_empty());
2500 }
2501
2502 /// The two reasons for a prune coexist in one diff without merging: under an
2503 /// incomplete collation, the health-excluded address is pruned and the
2504 /// merely-absent one is still withheld.
2505 #[test]
2506 fn an_incomplete_collation_prunes_only_the_health_withdrawn_surplus() {
2507 let plan = plan_domain_passway(
2508 &passway_manifest(),
2509 origins_excluding(&["us-east-001", "us-west-001"], &["us-east-001"]),
2510 false,
2511 )
2512 .unwrap();
2513 let d = diff_apex_records(
2514 &plan,
2515 &live(&[
2516 ("51.81.85.145", false), // declared + confirmed down -> prune
2517 ("15.204.89.240", false), // the surviving origin -> kept
2518 ("203.0.113.9", false), // never declared at all -> withheld
2519 ]),
2520 );
2521 assert_eq!(d.prune, vec!["51.81.85.145"]);
2522 assert_eq!(d.withheld_prune, vec!["203.0.113.9"]);
2523 assert!(d.upsert.is_empty());
2524 }
2525
2526 /// A withheld prune is not convergence — it is a write the applier
2527 /// declined — but it is also not a write, so `is_converged` stays false
2528 /// only when there is something to actually do.
2529 #[test]
2530 fn a_withheld_prune_alone_leaves_nothing_to_write() {
2531 let plan = plan_of_incomplete(&["us-east-001"]);
2532 let diff = diff_apex_records(
2533 &plan,
2534 &live(&[("51.81.85.145", false), ("15.204.89.240", false)]),
2535 );
2536 assert!(diff.upsert.is_empty(), "{diff:?}");
2537 assert!(diff.prune.is_empty(), "{diff:?}");
2538 assert!(diff.is_converged(), "no write to make: {diff:?}");
2539 assert_eq!(diff.withheld_prune, vec!["15.204.89.240".to_string()]);
2540 }
2541
2542 /// Ordering contract (`cf-apex-mode.sh:245-246,287-288`): the applier
2543 /// writes `upsert` before `prune`, so a full origin swap never leaves the
2544 /// apex without a routing record.
2545 #[test]
2546 fn a_full_origin_swap_writes_the_new_record_before_pruning_the_old() {
2547 let plan = plan_of(&["us-west-001"]);
2548 let diff = diff_apex_records(&plan, &live(&[("51.81.85.145", false)]));
2549 assert_eq!(diff.upsert, vec!["15.204.89.240".to_string()]);
2550 assert_eq!(diff.prune, vec!["51.81.85.145".to_string()]);
2551 assert!(!diff.is_converged());
2552 }
2553}