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::WORKER_SCRIPT;
116use std::collections::BTreeMap;
117
118/// The plan for deploying one subdomain's static Worker — everything decided
119/// before any Cloudflare call. Pure output of [`plan_domain_worker`], so the
120/// decision logic is unit-testable offline; [`deploy_domain_worker`] performs
121/// the I/O.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct DomainWorkerPlan {
124 /// Worker script name (the domain manifest's file stem / `name`).
125 pub worker_name: String,
126 /// Hostname to bind via Workers Custom Domains, e.g. `scrabcake.net.yah.dev`.
127 pub custom_domain: String,
128 /// `ASSET_ORIGIN` the Worker fetches from: `<cdn_base>/<service>/<env>`.
129 pub asset_origin: String,
130 /// `plain_text` Worker bindings (mirrors the Static-mode shape of
131 /// mesofact_static's `worker_config_bindings`).
132 pub bindings: Vec<(String, String)>,
133}
134
135/// Static-mode Worker bindings for an alias-tier subdomain. Kept in lockstep
136/// with `mesofact_static::worker_config_bindings(WorkerMode::Static, …)`.
137///
138/// `route_headers` is the manifest's own `DomainConfig::route_headers_json` —
139/// this planner already holds the domain, so unlike the mirror-driven path it
140/// needs no lookup to find it (R746).
141fn static_worker_bindings(asset_origin: &str, route_headers: String) -> Vec<(String, String)> {
142 vec![
143 ("ASSET_ORIGIN".to_string(), asset_origin.to_string()),
144 ("UPLOAD_ORIGIN".to_string(), String::new()),
145 ("WORKER_MODE".to_string(), "static".to_string()),
146 ("SSR_ORIGIN".to_string(), String::new()),
147 ("SSR_PREFIXES".to_string(), "[]".to_string()),
148 ("ROUTE_HEADERS".to_string(), route_headers),
149 ]
150}
151
152/// Build the [`DomainWorkerPlan`] for a per-tenant alias-tier manifest.
153///
154/// `cdn_base` is the tier's CDN origin (an R2 custom domain bound to the
155/// tier bucket, e.g. `https://cdn.net.yah.dev`); `env` is the mirror env the
156/// publisher wrote under (e.g. `cloud`). The publisher lays assets down at
157/// `<bucket>/<service>/<env>/<key>` and the Worker fetches
158/// `${ASSET_ORIGIN}/<key>`, so `ASSET_ORIGIN = <cdn_base>/<service>/<env>`.
159///
160/// Fails fast (before any network call) when the manifest has no `static`
161/// route or its component ref is malformed.
162pub fn plan_domain_worker(
163 domain: &DomainConfig,
164 cdn_base: &str,
165 env: &str,
166) -> Result<DomainWorkerPlan> {
167 // R594-F12: refuse to synthesize a Worker for a domain that declared a
168 // different front door. Without this the plan succeeds and deploys a
169 // Worker that never receives traffic (bucket-direct) or that duplicates
170 // the sovereign ingress (passway).
171 if domain.front_door != FrontDoor::Worker {
172 anyhow::bail!(
173 "domain {} ({}) declares front_door = \"{}\" — only \"worker\" \
174 domains get a generated Cloudflare Worker",
175 domain.name,
176 domain.domain,
177 domain.front_door.as_str()
178 );
179 }
180
181 // First static route wins (v1 alias tier serves one site per subdomain).
182 let component = domain
183 .routes
184 .iter()
185 .find_map(|r| match &r.mode {
186 RouteMode::Static { component } => Some(component.as_str()),
187 _ => None,
188 })
189 .with_context(|| {
190 format!(
191 "domain {} ({}) has no `static` route — nothing for a static Worker to serve",
192 domain.name, domain.domain
193 )
194 })?;
195
196 let service = component
197 .split_once('/')
198 .map(|(svc, _)| svc)
199 .with_context(|| {
200 format!(
201 "domain {}: route component {component:?} — expected \"<service>/<component-id>\"",
202 domain.name
203 )
204 })?;
205
206 let asset_origin = format!("{}/{}/{}", cdn_base.trim_end_matches('/'), service, env);
207
208 Ok(DomainWorkerPlan {
209 worker_name: domain.name.clone(),
210 custom_domain: domain.domain.clone(),
211 bindings: static_worker_bindings(&asset_origin, domain.route_headers_json()),
212 asset_origin,
213 })
214}
215
216/// Deploy the planned Worker and bind the subdomain (R561-F3, live I/O).
217///
218/// Reuses the shared `WORKER_SCRIPT` bundle + the existing CloudflareClient
219/// deploy/custom-domain methods. Gated on `cloudflare-api-token`. The decision
220/// logic is covered by `plan_domain_worker`'s tests; this I/O path has NOT been
221/// exercised against a live account yet (no creds in CI) — treat as untested
222/// until a live `yah cloud apply` confirms it.
223///
224/// CAVEAT (zone resolution): `parent_zone_name` uses a two-label heuristic, so
225/// for `scrabcake.net.yah.dev` it returns `yah.dev`. But the alias tiers
226/// `net.yah.dev` / `com.yah.dev` are their own Cloudflare zones — the binding
227/// must target `net.yah.dev`, not `yah.dev`. Before this goes live, swap in a
228/// longest-suffix match against the account's zones (the upgrade path
229/// `parent_zone_name`'s doc already names). Tracked on R561-F3.
230pub async fn deploy_domain_worker(
231 workspace_root: &Path,
232 provider_id: &str,
233 plan: &DomainWorkerPlan,
234) -> Result<()> {
235 let cf_provider = super::cf_creds::CfProvider::resolve(workspace_root, provider_id)?;
236 let account_id = cf_provider.account_id.clone();
237 let cf = CloudflareClient::new(cf_provider.api_token()?);
238
239 let worker_bindings: Vec<WorkerBinding<'_>> = plan
240 .bindings
241 .iter()
242 .map(|(k, v)| WorkerBinding::PlainText {
243 name: k.as_str(),
244 text: v.as_str(),
245 })
246 .collect();
247
248 cf.deploy_worker_script(
249 &account_id,
250 &plan.worker_name,
251 WORKER_SCRIPT,
252 &worker_bindings,
253 )
254 .await
255 .with_context(|| format!("deploying static Worker {}", plan.worker_name))?;
256 info!(worker = %plan.worker_name, "alias-tier Worker deployed");
257
258 let zone = parent_zone_name(&plan.custom_domain);
259 let zone_id = cf
260 .zone_id_for_name(zone)
261 .await
262 .with_context(|| format!("resolving zone id for {zone:?}"))?;
263 cf.upsert_worker_custom_domain(
264 &account_id,
265 &zone_id,
266 &plan.custom_domain,
267 &plan.worker_name,
268 )
269 .await
270 .with_context(|| {
271 format!(
272 "binding {} to Worker {}",
273 plan.custom_domain, plan.worker_name
274 )
275 })?;
276 info!(domain = %plan.custom_domain, worker = %plan.worker_name, "alias-tier custom domain bound");
277 Ok(())
278}
279
280// ─── R561-F4: alias-tier registration ───────────────────────────────────────
281//
282// "Claim <name>.{com,net}.yah.dev": validate the label, check it's free, and
283// produce the per-tenant DomainConfig (the F2 manifest) the registration flow
284// writes. Pure + offline — the SaaS "sign up, get a subdomain" moment.
285
286/// The two wildcard alias tiers. `Com` = managed/commercial, `Net` = community.
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub enum AliasTier {
289 Com,
290 Net,
291}
292
293impl AliasTier {
294 /// The tier's Cloudflare zone, e.g. `net.yah.dev`.
295 pub fn zone(self) -> &'static str {
296 match self {
297 AliasTier::Com => "com.yah.dev",
298 AliasTier::Net => "net.yah.dev",
299 }
300 }
301
302 /// The tier's shared R2 bucket (per-tenant `<svc>/<env>` prefixes within).
303 pub fn bucket(self) -> &'static str {
304 match self {
305 AliasTier::Com => "com-yah-dev",
306 AliasTier::Net => "net-yah-dev",
307 }
308 }
309
310 /// Manifest-name infix, e.g. `net` → `<name>-net-yah-dev` file stem.
311 fn slug(self) -> &'static str {
312 match self {
313 AliasTier::Com => "com",
314 AliasTier::Net => "net",
315 }
316 }
317}
318
319/// A valid DNS label: 1–63 chars, lowercase alphanumeric or hyphen, no
320/// leading/trailing hyphen. (Subdomain names a tenant can claim.)
321pub fn valid_subdomain_label(name: &str) -> bool {
322 !name.is_empty()
323 && name.len() <= 63
324 && !name.starts_with('-')
325 && !name.ends_with('-')
326 && name
327 .bytes()
328 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
329}
330
331/// Validate and build the per-tenant alias manifest for a claim.
332///
333/// `name` is the requested subdomain label (e.g. `scrabcake`); `component` is
334/// the tenant's static component ref (`"<service>/<component-id>"`). `existing`
335/// is the current domain map (from `CloudConfig`) — the claim fails if the
336/// resulting host or manifest name is already taken. Returns the
337/// [`DomainConfig`] to write at `.yah/domains/<stem>.toml`.
338pub fn plan_alias_claim(
339 tier: AliasTier,
340 name: &str,
341 component: &str,
342 existing: &BTreeMap<String, DomainConfig>,
343) -> Result<DomainConfig> {
344 if !valid_subdomain_label(name) {
345 anyhow::bail!(
346 "invalid subdomain {name:?} — must be 1–63 chars, lowercase \
347 alphanumeric or hyphen, no leading/trailing hyphen"
348 );
349 }
350 if component.split_once('/').is_none() {
351 anyhow::bail!("component {component:?} — expected \"<service>/<component-id>\"");
352 }
353
354 let domain = format!("{name}.{}", tier.zone());
355 let stem = format!("{name}-{}-yah-dev", tier.slug());
356
357 if existing.values().any(|d| d.domain == domain) {
358 anyhow::bail!("{domain} is already claimed");
359 }
360 if existing.contains_key(&stem) {
361 anyhow::bail!("domain manifest {stem:?} already exists");
362 }
363
364 Ok(DomainConfig {
365 schema_version: 1,
366 name: stem,
367 domain,
368 // R594-F12: every tenant manifest is Worker-served (R561-F3 binds the
369 // subdomain as a custom domain on the generated Worker).
370 front_door: FrontDoor::Worker,
371 cdn_bucket: tier.bucket().to_string(),
372 worker_bundle_path: None,
373 routes: vec![DomainRoute {
374 path: "/*".into(),
375 // A claimed alias serves one bundle at the root with no special
376 // header needs; a tenant that wants some edits its own manifest.
377 headers: BTreeMap::new(),
378 mode: RouteMode::Static {
379 component: component.to_string(),
380 },
381 }],
382 })
383}
384
385// ─── R859-F1: sovereign apex (front_door = "passway") ───────────────────────
386//
387// The two-source flip this dissolves: `.yah/domains/*.toml` DECLARED
388// `front_door`, while `scripts/cf-apex-mode.sh` imperatively MUTATED the apex
389// records, and nothing kept the two in agreement. The declaration drifted from
390// the live zone for 19 days once (R330-B36) and 4 more (R703-B4). Here the
391// manifest is the only source and the reconciler renders the records from it.
392//
393// Same house shape as the Worker arm: a pure planner (`plan_domain_passway` +
394// `diff_apex_records`) that is fully unit-testable offline, and an I/O applier
395// (`deploy_domain_passway`) that only executes the diff.
396
397use crate::config::MachineConfig;
398use crate::envoy::dns_record::{
399 DnsRecordDeleteInput, DnsRecordListInput, DnsRecordUpsertInput,
400};
401use crate::provider::cloudflare_envoy::CloudflareEnvoy;
402use std::net::Ipv4Addr;
403
404/// One declared public origin behind a sovereign apex — a machine that runs a
405/// passway front door and carries a routable IPv4 address.
406#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
407pub struct PasswayOrigin {
408 /// Machine name, as declared in `.yah/infra/machines/<name>.toml`. Carried
409 /// so every error and log line names the box an operator would open.
410 pub machine: String,
411 /// The address the apex A record will publish.
412 pub address: Ipv4Addr,
413}
414
415/// What [`public_origins`] resolved: the origins to publish, and the ones it
416/// deliberately held back because their machine is confirmed down (R859-F2).
417///
418/// Two lists rather than one shorter one, because the difference is
419/// load-bearing downstream: an origin that is *absent* from a declaration and
420/// an origin that is *present and known dead* want opposite treatment when the
421/// declaration itself is untrustworthy. See
422/// [`DomainPasswayPlan::health_withdrawn`].
423#[derive(Debug, Clone, Default, PartialEq, Eq)]
424pub struct ResolvedOrigins {
425 /// Origins the apex should publish.
426 pub origins: Vec<PasswayOrigin>,
427 /// Declared, resolvable origins withheld because their machine is
428 /// confirmed down.
429 pub health_withdrawn: Vec<PasswayOrigin>,
430}
431
432/// The apex record set one passway domain should carry — pure output of
433/// [`plan_domain_passway`], applied by [`deploy_domain_passway`].
434#[derive(Debug, Clone, PartialEq, Eq)]
435pub struct DomainPasswayPlan {
436 /// Parent Cloudflare zone apex name, from [`parent_zone_name`].
437 pub zone: String,
438 /// FQDN the records live at — the manifest's `domain`.
439 pub name: String,
440 /// Desired origins, deduplicated and sorted by address so two planning
441 /// runs over the same declaration are byte-identical.
442 pub origins: Vec<PasswayOrigin>,
443 /// Whether [`origins`](Self::origins) is the **whole** declared set.
444 ///
445 /// `false` when the ingress collation reported problems, because
446 /// [`collate_workspace_ingress`](crate::validate::collate_workspace_ingress)
447 /// returns `Ok` while *skipping* an edge whose declaration fails to plan —
448 /// so a passway edge with a config typo silently drops its machine from
449 /// `front_doors`. An absent machine and a withdrawn machine are then
450 /// indistinguishable from inside this plan, and the two want opposite
451 /// treatment: a withdrawal should prune the record, a typo must not.
452 ///
453 /// So the flag gates exactly one thing —
454 /// [`diff_apex_records`] withholds every prune when it is `false` —
455 /// and nothing else. Upserts still happen: growing the fleet must keep
456 /// working through an unrelated service's broken declaration, and an
457 /// upsert can never make the apex worse. **Fail-closed on withdrawal,
458 /// fail-open on addition.**
459 pub origins_complete: bool,
460 /// Addresses withdrawn because their machine is **confirmed down** —
461 /// R859-F2's cross-provider failover, rendered through R859-F1's existing
462 /// diff rather than through a second entry point.
463 ///
464 /// # Why this is not just "absent from `origins`"
465 ///
466 /// [`origins_complete`](Self::origins_complete) and this field are two
467 /// different facts and it is worth being exact about which is which, because
468 /// conflating them silently disables the failover this ticket exists for:
469 ///
470 /// - `origins_complete` answers **"is the *declaration* picture
471 /// trustworthy?"** `false` means the collation skipped an edge, so an
472 /// origin's absence might be a withdrawal or might be a config typo —
473 /// indistinguishable, so every prune is withheld.
474 /// - `health_withdrawn` answers **"which declared machine do we know is
475 /// down?"** It is *positive* evidence about a machine the collation
476 /// plainly saw, not an inference from an absence.
477 ///
478 /// So a health withdrawal is **not** suppressed by `origins_complete =
479 /// false`. The discriminator `origins_complete` exists to protect is
480 /// exactly "did we see this machine declared?", and for these machines the
481 /// answer is yes — they were resolved, taint-checked and address-checked on
482 /// the way into this list. A broken edge elsewhere in the workspace says
483 /// nothing about a box we watched go down, and letting it veto the
484 /// withdrawal would leave a dead origin taking its share of the
485 /// round-robin for as long as some unrelated service's TOML is wrong.
486 ///
487 /// The fail-closed-on-withdrawal rule is not weakened by this: the gate for
488 /// a health withdrawal is the *quorum* verdict, applied one layer up in
489 /// [`plan_ingress_owner_effect`](crate::provider::floating_ip::plan_ingress_owner_effect),
490 /// which refuses to emit the exclusion at all out of a degraded quorum. Two
491 /// withdrawal paths, each fail-closed on the evidence that is actually
492 /// relevant to it.
493 pub health_withdrawn: Vec<PasswayOrigin>,
494}
495
496/// One A record currently live at the apex, as read back by
497/// `dns.record.list`.
498#[derive(Debug, Clone, PartialEq, Eq)]
499pub struct LiveApexRecord {
500 /// The published address.
501 pub content: String,
502 /// Whether the provider proxies it (Cloudflare orange-cloud).
503 pub proxied: bool,
504}
505
506/// What one apply has to change to converge the apex.
507#[derive(Debug, Clone, Default, PartialEq, Eq)]
508pub struct ApexRecordDiff {
509 /// Addresses to upsert as DNS-only A records.
510 pub upsert: Vec<String>,
511 /// Addresses to remove — surplus A records at the same name.
512 pub prune: Vec<String>,
513 /// Addresses that *would* have been pruned but were left in place because
514 /// [`DomainPasswayPlan::origins_complete`] was `false`.
515 ///
516 /// Kept rather than dropped so the applier can name the exact records it
517 /// declined to touch. A live A record sitting here is the visible symptom
518 /// of a broken ingress declaration somewhere in the workspace — it is
519 /// either a real withdrawal this apply refused to act on, or an origin the
520 /// collation could not see.
521 pub withheld_prune: Vec<String>,
522}
523
524impl ApexRecordDiff {
525 /// `true` when this apply has no write to make. Withheld prunes do not
526 /// count: they are deliberately not writes, and a converged-with-withheld
527 /// diff still has something to warn about.
528 pub fn is_converged(&self) -> bool {
529 self.upsert.is_empty() && self.prune.is_empty()
530 }
531}
532
533/// What [`deploy_domain_passway`] actually did, for the apply summary.
534#[derive(Debug, Clone, Default, PartialEq, Eq)]
535pub struct PasswayApexOutcome {
536 /// Addresses written (created or re-pointed to DNS-only).
537 pub upserted: Vec<String>,
538 /// Addresses withdrawn.
539 pub pruned: Vec<String>,
540 /// Addresses left in place that the declaration no longer names, because
541 /// the ingress collation was incomplete — see
542 /// [`DomainPasswayPlan::origins_complete`]. Surfaced in the outcome (not
543 /// only in the log) so `yah cloud apply` can print it: a withheld prune is
544 /// the operator's cue that some service's ingress declaration is broken.
545 pub withheld_prune: Vec<String>,
546}
547
548impl PasswayApexOutcome {
549 /// `true` when the live apex already matched the declaration and nothing
550 /// was written.
551 pub fn is_noop(&self) -> bool {
552 self.upserted.is_empty() && self.pruned.is_empty()
553 }
554}
555
556/// Parse `address` as a **publicly routable** IPv4, naming `machine` on
557/// failure.
558///
559/// This is the publicness cross-check nothing performs today: the `public-ip`
560/// taint and `connect.address` are two independent operator declarations, and
561/// [`crate::config::ConnectSpec::address`]'s own doc warns it is *whichever*
562/// address the operator chose to dial the box on — public, LAN, or tailnet
563/// (us-west-002 is deliberately pointed at its tailnet IP, R608-F10). Publishing
564/// a `100.64.0.x` or `192.168.x.x` A record on a public apex takes its share of
565/// the round-robin straight to a black hole, so a taint/address disagreement
566/// has to be a hard error rather than a silently-broken record.
567///
568/// Kept local to this arm on purpose (R859-F1 decision 4): a workspace-wide
569/// lint would fire on every machine that carries the taint for placement
570/// reasons while being dialled over the mesh, which is legitimate.
571fn public_ipv4_for(machine: &str, address: &str) -> Result<Ipv4Addr> {
572 let ip: Ipv4Addr = address.parse().map_err(|_| {
573 anyhow::anyhow!(
574 "machine {machine}: connect.address = {address:?} is not an IPv4 address, so it \
575 cannot be published as an apex A record. Either set it to the box's public IPv4 \
576 or drop the `public-ip` taint from {machine}.toml."
577 )
578 })?;
579 let o = ip.octets();
580 // 100.64.0.0/10 — RFC 6598 shared address space, which is also the
581 // tailnet range this fleet's mesh addresses live in.
582 let is_cgnat = o[0] == 100 && (64..=127).contains(&o[1]);
583 let reason = if ip.is_unspecified() {
584 Some("unspecified")
585 } else if ip.is_loopback() {
586 Some("loopback")
587 } else if ip.is_private() {
588 Some("RFC 1918 private")
589 } else if ip.is_link_local() {
590 Some("link-local")
591 } else if is_cgnat {
592 Some("RFC 6598 shared / tailnet")
593 } else if ip.is_multicast() {
594 Some("multicast")
595 } else if ip.is_broadcast() {
596 Some("broadcast")
597 } else {
598 None
599 };
600 if let Some(reason) = reason {
601 anyhow::bail!(
602 "machine {machine}: connect.address = {address:?} is a {reason} address, not a \
603 public one — publishing it at a public apex would black-hole its share of the \
604 round-robin. Set connect.address to the box's public IPv4 or drop the \
605 `public-ip` taint from {machine}.toml."
606 );
607 }
608 Ok(ip)
609}
610
611/// Resolve collated front-door machine names to the public origins the apex
612/// should publish (R859-F1).
613///
614/// Keeps only machines carrying the [`workload_spec::PUBLIC_IP_TAINT`]
615/// affinity taint — the same declaration that makes yubaba willing to place a
616/// public ingress appliance there — and reads each one's
617/// [`ConnectSpec::address`](crate::config::ConnectSpec::address). A named
618/// machine that is absent from `machines` is an error, not a silent drop: the
619/// alternative is an apex that quietly shrinks because a machine TOML was
620/// renamed.
621///
622/// Machines without the taint are skipped silently. That is the intended
623/// filter, not a swallowed error — a passway edge may be collated onto a
624/// mesh-only box that fronts internal hostnames.
625///
626/// # `health_excluded` — R859-F2's withdrawal seam
627///
628/// Machines named in `health_excluded` are declared, resolvable, and dropped
629/// from the origin list anyway, because something confirmed they are down. The
630/// resulting shorter list flows through [`plan_domain_passway`] and
631/// [`diff_apex_records`] unchanged, so the withdrawal renders as an ordinary
632/// prune and needs no second entry point — the whole point of extending this
633/// function rather than adding a parallel one.
634///
635/// Exclusions are returned separately (see [`ResolvedOrigins`]) rather than
636/// silently vanishing, because "declared but withheld for health" is a
637/// different fact from "never declared", and [`diff_apex_records`] has to be
638/// able to tell them apart. See [`DomainPasswayPlan::health_withdrawn`].
639///
640/// An excluded machine is still *resolved* first: it must exist, carry the
641/// taint and have a public address, exactly as if it were staying. Skipping
642/// those checks would let a health exclusion paper over a config error, and a
643/// machine we cannot resolve is one we cannot honestly say we are withdrawing.
644///
645/// A name in `health_excluded` that is not a front door at all is ignored — the
646/// caller's liveness view covers the whole fleet, and most of it never fronts
647/// anything.
648pub fn public_origins(
649 front_door_machines: &[String],
650 machines: &[MachineConfig],
651 health_excluded: &[String],
652) -> Result<ResolvedOrigins> {
653 let mut resolved = ResolvedOrigins::default();
654 for name in front_door_machines {
655 let machine = machines
656 .iter()
657 .find(|m| &m.name == name)
658 .with_context(|| {
659 format!(
660 "front door collated onto machine {name:?}, which has no \
661 .yah/infra/machines/*.toml — cannot resolve its public address"
662 )
663 })?;
664 if !machine
665 .taints
666 .iter()
667 .any(|t| t == workload_spec::PUBLIC_IP_TAINT)
668 {
669 debug!(
670 machine = %name,
671 "front-door machine has no `public-ip` taint — not an apex origin"
672 );
673 continue;
674 }
675 let address = machine
676 .connect
677 .as_ref()
678 .map(|c| c.address.as_str())
679 .with_context(|| {
680 format!(
681 "machine {name} carries the `public-ip` taint but declares no \
682 [connect] address — nothing to publish at the apex"
683 )
684 })?;
685 let origin = PasswayOrigin {
686 machine: name.clone(),
687 address: public_ipv4_for(name, address)?,
688 };
689 if health_excluded.iter().any(|m| m == name) {
690 debug!(
691 machine = %name,
692 address = %origin.address,
693 "front-door machine confirmed down — withheld from the apex origin set"
694 );
695 resolved.health_withdrawn.push(origin);
696 } else {
697 resolved.origins.push(origin);
698 }
699 }
700 Ok(resolved)
701}
702
703/// Build the apex record plan for a `front_door = "passway"` manifest.
704///
705/// Refuses two shapes outright:
706///
707/// 1. A manifest declaring a different front door — the same guard
708/// [`plan_domain_worker`] carries, for the same reason.
709/// 2. An **empty** origin set. The desired set is what the applier prunes
710/// against, so an empty one would not mean "leave it alone", it would mean
711/// "delete every A record at the apex" — a live outage rendered from a
712/// collation that simply found nothing. Nothing about "no machine is
713/// declared" says "take the site down", so it is an error.
714///
715/// That empty-set guard is checked **before** `origins_complete` is consulted:
716/// an empty set is unusable whether or not the collation was clean, so it stays
717/// the louder failure.
718///
719/// `origins_complete` is the caller's answer to "did the collation see the
720/// whole picture?" — `false` withholds every prune downstream. See
721/// [`DomainPasswayPlan::origins_complete`].
722///
723/// # The empty-set guard is also the health failover's backstop (R859-F2)
724///
725/// Guard 2 is checked against the origins that **survive** health exclusion, and
726/// that is the load-bearing interaction of this whole feature: if every declared
727/// front door is confirmed down, `origins` is empty and this refuses. "All our
728/// front doors are down" must never render as "withdraw every A record and take
729/// the site down" — a dead origin still in DNS is a partial outage, an empty
730/// apex is a total one, and between those the first is strictly better. So the
731/// health withdrawal is capped at "all but the last origin" by construction,
732/// with no separate rule to keep in sync.
733///
734/// An address that is *also* served by a surviving origin is dropped from
735/// [`health_withdrawn`](DomainPasswayPlan::health_withdrawn) for the same
736/// reason, one level finer: two machines can share a floating IP (the dedup
737/// comment below names that case), and pruning the record because one of them
738/// died would withdraw an address the other is still answering on.
739pub fn plan_domain_passway(
740 domain: &DomainConfig,
741 resolved: ResolvedOrigins,
742 origins_complete: bool,
743) -> Result<DomainPasswayPlan> {
744 let ResolvedOrigins {
745 origins,
746 health_withdrawn,
747 } = resolved;
748 if domain.front_door != FrontDoor::Passway {
749 anyhow::bail!(
750 "domain {} ({}) declares front_door = \"{}\" — only \"passway\" domains \
751 get a sovereign apex rendered here",
752 domain.name,
753 domain.domain,
754 domain.front_door.as_str()
755 );
756 }
757
758 // By address, not by machine name: the address is what lands in DNS, so
759 // ordering on it is what makes two planning runs byte-identical even if a
760 // box is renamed. Dedup on the same key — two edges collated onto one
761 // machine (or two machines sharing a floating IP) publish one record.
762 let mut origins = origins;
763 origins.sort_by(|a, b| (a.address, &a.machine).cmp(&(b.address, &b.machine)));
764 origins.dedup_by(|a, b| a.address == b.address);
765
766 if origins.is_empty() {
767 anyhow::bail!(
768 "domain {} ({}) declares front_door = \"passway\" but no declared front-door \
769 machine carries the `public-ip` taint with a public address — refusing to \
770 render an empty apex, which would withdraw every A record and take the site \
771 down. Declare the ingress edge's machines (or their taints) first.",
772 domain.name,
773 domain.domain
774 );
775 }
776
777 // Same normalisation as `origins`, plus the survivor filter: an address a
778 // live origin still answers on is not withdrawn, however many of the
779 // machines sharing it went down.
780 let mut health_withdrawn = health_withdrawn;
781 health_withdrawn.retain(|w| !origins.iter().any(|o| o.address == w.address));
782 health_withdrawn.sort_by(|a, b| (a.address, &a.machine).cmp(&(b.address, &b.machine)));
783 health_withdrawn.dedup_by(|a, b| a.address == b.address);
784
785 Ok(DomainPasswayPlan {
786 zone: parent_zone_name(&domain.domain).to_string(),
787 name: domain.domain.clone(),
788 origins,
789 origins_complete,
790 health_withdrawn,
791 })
792}
793
794/// Diff the planned apex against the A records live at that name.
795///
796/// `live` must already be narrowed to **type A at `plan.name`** — MX, TXT and
797/// AAAA records share the apex and are never this arm's to touch.
798///
799/// A live record whose content is desired but which is *proxied* still lands in
800/// `upsert`: orange-cloud is a break-glass state
801/// (`scripts/cf-apex-mode.sh orange`) and this reconciler declares DNS-only, so
802/// re-writing the record with `proxied = false` is convergence, not churn.
803///
804/// When [`plan.origins_complete`](DomainPasswayPlan::origins_complete) is
805/// `false` the surplus records move to
806/// [`withheld_prune`](ApexRecordDiff::withheld_prune) instead of `prune`, and
807/// `upsert` is unaffected. An incomplete collation cannot tell a withdrawn
808/// origin from one whose declaration failed to plan, and only one of those two
809/// wants a DNS withdrawal.
810///
811/// # …with one exception, and it is the point of R859-F2
812///
813/// A surplus address that appears in
814/// [`plan.health_withdrawn`](DomainPasswayPlan::health_withdrawn) is pruned
815/// **regardless of `origins_complete`**. The two flags are not two strengths of
816/// the same doubt, they are answers to different questions, and the four cases
817/// come out like this:
818///
819/// | `origins_complete` | in `health_withdrawn` | verdict |
820/// |---|---|---|
821/// | `true` | no | `prune` — an ordinary withdrawal from a trusted declaration |
822/// | `true` | yes | `prune` — a health withdrawal from a trusted declaration |
823/// | `false` | no | `withheld_prune` — might be a withdrawal, might be a typo |
824/// | `false` | yes | `prune` — we saw this machine declared *and* saw it die |
825///
826/// The bottom-right cell is the one that matters. `origins_complete = false`
827/// protects against mistaking an absence for a withdrawal; a health withdrawal
828/// is not an absence, it is a positive observation about a machine the
829/// collation resolved. An unrelated service's broken TOML is not evidence about
830/// a box we watched go down, and letting it withhold the prune would leave a
831/// dead origin serving its share of the round-robin for as long as that typo
832/// lives.
833pub fn diff_apex_records(plan: &DomainPasswayPlan, live: &[LiveApexRecord]) -> ApexRecordDiff {
834 let desired: Vec<String> = plan
835 .origins
836 .iter()
837 .map(|o| o.address.to_string())
838 .collect();
839 let upsert = desired
840 .iter()
841 .filter(|ip| {
842 !live
843 .iter()
844 .any(|r| &&r.content == ip && !r.proxied)
845 })
846 .cloned()
847 .collect();
848 let surplus: Vec<String> = live
849 .iter()
850 .filter(|r| !desired.contains(&r.content))
851 .map(|r| r.content.clone())
852 .collect();
853 if plan.origins_complete {
854 return ApexRecordDiff {
855 upsert,
856 prune: surplus,
857 withheld_prune: Vec::new(),
858 };
859 }
860 // Incomplete declaration: withhold every prune EXCEPT the health
861 // withdrawals, which rest on a positive observation rather than on an
862 // absence. See this function's doc table.
863 let withdrawn: Vec<String> = plan
864 .health_withdrawn
865 .iter()
866 .map(|o| o.address.to_string())
867 .collect();
868 let (prune, withheld_prune) = surplus
869 .into_iter()
870 .partition(|content| withdrawn.contains(content));
871 ApexRecordDiff {
872 upsert,
873 prune,
874 withheld_prune,
875 }
876}
877
878/// Apply a [`DomainPasswayPlan`] against live DNS (R859-F1, live I/O).
879///
880/// **First production consumer of the `dns.*` envoy verbs** — every read and
881/// write here goes through [`CloudflareEnvoy`]'s typed verb handlers rather
882/// than the `CloudflareClient` methods the older arms call directly, so
883/// swapping the DNS provider is a matter of resolving a different adapter here
884/// and nothing else.
885///
886/// Two invariants, both borrowed from `scripts/cf-apex-mode.sh`, whose
887/// behaviour this replaces for the routine case:
888///
889/// - **List first, skip when converged.** Same shape as
890/// [`ensure_r2_custom_domain`], so a re-run of `yah cloud apply` writes
891/// nothing.
892/// - **Upsert before prune.** Desired records are written first and surplus
893/// ones removed last, so the apex is never momentarily recordless. Only
894/// `A` records at the name are ever pruned; the apex's MX and TXT records
895/// (mail routing, SPF, site verification) are outside the filter by
896/// construction.
897/// - **Fail-closed on withdrawal, fail-open on addition.** When the plan says
898/// the origin set is incomplete
899/// ([`origins_complete = false`](DomainPasswayPlan::origins_complete)) the
900/// upserts still run but every prune is withheld and warned about — an
901/// absent origin might be a withdrawal or might be a config typo upstream,
902/// and only one of those should take a live record away.
903///
904/// Always writes `proxied = false`: the manifest cannot express grey vs
905/// orange, and orange stays break-glass in the script (W267 tier ladder).
906pub async fn deploy_domain_passway(
907 workspace_root: &Path,
908 provider_id: &str,
909 plan: &DomainPasswayPlan,
910) -> Result<PasswayApexOutcome> {
911 let cf_provider = super::cf_creds::CfProvider::resolve(workspace_root, provider_id)?;
912 let envoy = CloudflareEnvoy::new(cf_provider.api_token()?, cf_provider.account_id.clone());
913
914 let listed = envoy
915 .dns_record_list(DnsRecordListInput {
916 zone: plan.zone.clone(),
917 name: Some(plan.name.clone()),
918 record_type: Some("A".to_string()),
919 })
920 .await
921 .with_context(|| format!("listing A records at {}", plan.name))?;
922 let live: Vec<LiveApexRecord> = listed
923 .records
924 .into_iter()
925 .map(|r| LiveApexRecord {
926 content: r.content,
927 proxied: r.proxied,
928 })
929 .collect();
930
931 let diff = diff_apex_records(plan, &live);
932
933 // Warned before the converged early-return: a withheld prune is worth
934 // saying out loud even on an apply that writes nothing, because the record
935 // it names stays live until someone fixes the declaration.
936 if !diff.withheld_prune.is_empty() {
937 warn!(
938 domain = %plan.name,
939 withheld = %diff.withheld_prune.join(", "),
940 "apex A records NOT withdrawn: the ingress collation reported problems, so an \
941 origin missing from it may be a broken declaration rather than a withdrawal. \
942 Run `yah cloud validate` and fix the reported ingress declaration; these \
943 records stay live until the collation is clean."
944 );
945 }
946
947 if diff.is_converged() {
948 debug!(
949 domain = %plan.name,
950 origins = plan.origins.len(),
951 "sovereign apex already matches the declaration — skipping"
952 );
953 return Ok(PasswayApexOutcome {
954 withheld_prune: diff.withheld_prune,
955 ..Default::default()
956 });
957 }
958
959 // Writes first: never leave the apex without a routing record.
960 for ip in &diff.upsert {
961 envoy
962 .dns_record_upsert(DnsRecordUpsertInput {
963 zone: plan.zone.clone(),
964 name: plan.name.clone(),
965 record_type: "A".to_string(),
966 content: ip.clone(),
967 ttl: 1,
968 proxied: false,
969 // A round-robin apex is a multi-valued RRset: without this the
970 // second origin would overwrite the first.
971 match_content: true,
972 })
973 .await
974 .with_context(|| format!("upserting A {} -> {ip}", plan.name))?;
975 info!(domain = %plan.name, origin = %ip, "sovereign apex A record written");
976 }
977
978 // Prune last, and only ever type A carrying an undeclared value.
979 for ip in &diff.prune {
980 envoy
981 .dns_record_delete(DnsRecordDeleteInput {
982 zone: plan.zone.clone(),
983 name: plan.name.clone(),
984 record_type: Some("A".to_string()),
985 content: Some(ip.clone()),
986 })
987 .await
988 .with_context(|| format!("pruning surplus A {} -> {ip}", plan.name))?;
989 info!(domain = %plan.name, origin = %ip, "surplus apex A record withdrawn");
990 }
991
992 Ok(PasswayApexOutcome {
993 upserted: diff.upsert,
994 pruned: diff.prune,
995 withheld_prune: diff.withheld_prune,
996 })
997}
998
999/// Reconcile one `front_door = "passway"` domain end to end — the entry point
1000/// `yah cloud apply` calls (R859-F1).
1001///
1002/// Collates the workspace's declared ingress edges, keeps the passway front
1003/// doors, resolves their machines to public addresses, plans, and applies.
1004/// Every input is *declared* config: no network read decides which origins the
1005/// apex publishes, which is what makes the manifest the single source the
1006/// two-source flip lacked.
1007///
1008/// ## Why `report.problems` gates the prune
1009///
1010/// [`collate_workspace_ingress`](crate::validate::collate_workspace_ingress)
1011/// returns `Ok` while **skipping** an edge whose declaration fails to plan
1012/// (`validate.rs`, the `IngressProblem::Declaration` arms). A passway edge with
1013/// a config typo therefore drops its machine out of `front_doors` silently, and
1014/// from inside the plan that is indistinguishable from the operator having
1015/// withdrawn the node — so without this gate a typo would render as a DNS
1016/// withdrawal and take that origin's share of the apex offline.
1017///
1018/// Any non-empty `problems` therefore sets `origins_complete = false`, which
1019/// withholds every prune for this apply. Deliberately *not* narrowed to the
1020/// problems that mention this domain's edge: attributing a problem to an edge
1021/// requires the very planning that failed, and "non-empty means the picture is
1022/// incomplete" is the whole of what the prune path needs to know. Equally
1023/// deliberately, problems do **not** fail the arm — one service's broken
1024/// ingress declaration must not block another's DNS apply, and upserts can
1025/// only ever add reachable origins.
1026pub async fn ensure_passway_apex(
1027 workspace_root: &Path,
1028 provider_id: &str,
1029 domain: &DomainConfig,
1030) -> Result<PasswayApexOutcome> {
1031 let report = crate::validate::collate_workspace_ingress(workspace_root)
1032 .context("collating workspace ingress to find the passway front doors")?;
1033
1034 let origins_complete = report.problems.is_empty();
1035 if !origins_complete {
1036 for problem in &report.problems {
1037 warn!(
1038 domain = %domain.domain,
1039 "ingress collation problem — apex prunes withheld this apply: {}",
1040 problem.message()
1041 );
1042 }
1043 }
1044
1045 let mut front_door_machines: Vec<String> = report
1046 .collation
1047 .front_doors
1048 .iter()
1049 .filter(|fd| fd.provider == crate::config::IngressProvider::Passway)
1050 .map(|fd| fd.machine.clone())
1051 .collect();
1052 front_door_machines.sort();
1053 front_door_machines.dedup();
1054
1055 let machines: Vec<MachineConfig> = crate::validate::load_machine_tomls(
1056 workspace_root,
1057 crate::validate::MachineLoadMode::Strict,
1058 )?
1059 .into_iter()
1060 .map(|(_, m)| m)
1061 .collect();
1062
1063 // R859-F2: no health exclusions on the `yah cloud apply` path, deliberately
1064 // and not as a stub. The confirmed-down fact is a *fleet-runtime* one —
1065 // it comes from a leader's lease hysteresis, gated on a healthy quorum
1066 // (`plan_ingress_owner_effect`) — and `yah cloud apply` is an operator
1067 // running a declarative converge from a laptop, which holds no such view.
1068 // Feeding it a liveness guess here would let a laptop with a flaky uplink
1069 // withdraw a live origin. The exclusion set enters through the effector
1070 // that *has* the fact; this path renders the declaration as declared.
1071 let resolved = public_origins(&front_door_machines, &machines, &[])?;
1072 let plan = plan_domain_passway(domain, resolved, origins_complete)?;
1073 deploy_domain_passway(workspace_root, provider_id, &plan).await
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078 use super::parent_zone_name;
1079
1080 #[test]
1081 fn parent_zone_strips_one_label_off_subdomain() {
1082 assert_eq!(parent_zone_name("cdn.yah.dev"), "yah.dev");
1083 assert_eq!(parent_zone_name("app.yah.dev"), "yah.dev");
1084 }
1085
1086 #[test]
1087 fn parent_zone_returns_self_for_apex() {
1088 assert_eq!(parent_zone_name("yah.dev"), "yah.dev");
1089 }
1090
1091 #[test]
1092 fn parent_zone_strips_only_first_label_for_deeper_subdomain() {
1093 // Two-label heuristic: yah-side zones are all two-label apexes today.
1094 assert_eq!(parent_zone_name("a.b.yah.dev"), "yah.dev");
1095 }
1096
1097 use super::{plan_domain_worker, DomainWorkerPlan};
1098 use crate::config::{DomainConfig, DomainRoute, FrontDoor, RouteMode};
1099
1100 fn net_tier_manifest() -> DomainConfig {
1101 // Mirrors .yah/domains/scrabcake-net-yah-dev.toml.
1102 DomainConfig {
1103 schema_version: 1,
1104 name: "scrabcake-net-yah-dev".into(),
1105 domain: "scrabcake.net.yah.dev".into(),
1106 front_door: FrontDoor::Worker,
1107 cdn_bucket: "net-yah-dev".into(),
1108 worker_bundle_path: None,
1109 routes: vec![DomainRoute {
1110 headers: Default::default(),
1111 path: "/*".into(),
1112 mode: RouteMode::Static {
1113 component: "scrabcake/site".into(),
1114 },
1115 }],
1116 }
1117 }
1118
1119 #[test]
1120 fn plan_resolves_worker_name_domain_and_asset_origin() {
1121 let plan =
1122 plan_domain_worker(&net_tier_manifest(), "https://cdn.net.yah.dev", "cloud").unwrap();
1123 assert_eq!(
1124 plan,
1125 DomainWorkerPlan {
1126 worker_name: "scrabcake-net-yah-dev".into(),
1127 custom_domain: "scrabcake.net.yah.dev".into(),
1128 asset_origin: "https://cdn.net.yah.dev/scrabcake/cloud".into(),
1129 bindings: vec![
1130 (
1131 "ASSET_ORIGIN".into(),
1132 "https://cdn.net.yah.dev/scrabcake/cloud".into()
1133 ),
1134 ("UPLOAD_ORIGIN".into(), String::new()),
1135 ("WORKER_MODE".into(), "static".into()),
1136 ("SSR_ORIGIN".into(), String::new()),
1137 ("SSR_PREFIXES".into(), "[]".into()),
1138 ("ROUTE_HEADERS".into(), "[]".into()),
1139 ],
1140 }
1141 );
1142 }
1143
1144 /// R746: headers declared on a route reach the deployed Worker as the
1145 /// ROUTE_HEADERS binding. Without this the manifest could declare them and
1146 /// the Worker would serve without them — the exact silent gap the primitive
1147 /// exists to close.
1148 #[test]
1149 fn plan_carries_declared_route_headers_into_the_bindings() {
1150 let mut dom = net_tier_manifest();
1151 dom.routes[0].headers = [
1152 ("Cross-Origin-Opener-Policy".to_string(), "same-origin".to_string()),
1153 (
1154 "Cross-Origin-Embedder-Policy".to_string(),
1155 "require-corp".to_string(),
1156 ),
1157 ]
1158 .into_iter()
1159 .collect();
1160 let plan = plan_domain_worker(&dom, "https://cdn.net.yah.dev", "cloud").unwrap();
1161 let binding = plan
1162 .bindings
1163 .iter()
1164 .find(|(k, _)| k == "ROUTE_HEADERS")
1165 .expect("ROUTE_HEADERS binding");
1166 assert!(binding.1.contains("same-origin"), "{}", binding.1);
1167 assert!(binding.1.contains("require-corp"), "{}", binding.1);
1168 assert!(binding.1.contains("/*"), "{}", binding.1);
1169 }
1170
1171 #[test]
1172 fn plan_trims_trailing_slash_on_cdn_base() {
1173 let plan =
1174 plan_domain_worker(&net_tier_manifest(), "https://cdn.net.yah.dev/", "cloud").unwrap();
1175 assert_eq!(plan.asset_origin, "https://cdn.net.yah.dev/scrabcake/cloud");
1176 }
1177
1178 /// R594-F12: a manifest that declares a different front door must not
1179 /// get a Cloudflare Worker synthesized behind its back — the Worker
1180 /// would deploy and then never receive traffic.
1181 #[test]
1182 fn plan_bails_when_front_door_is_not_worker() {
1183 for door in [FrontDoor::BucketDirect, FrontDoor::Passway] {
1184 let mut dom = net_tier_manifest();
1185 dom.front_door = door;
1186 let err = plan_domain_worker(&dom, "https://cdn.net.yah.dev", "cloud").unwrap_err();
1187 let msg = format!("{err:#}");
1188 assert!(msg.contains("front_door"), "{msg}");
1189 assert!(msg.contains(door.as_str()), "{msg}");
1190 }
1191 }
1192
1193 #[test]
1194 fn plan_bails_when_no_static_route() {
1195 let mut dom = net_tier_manifest();
1196 dom.routes = vec![DomainRoute {
1197 headers: Default::default(),
1198 path: "/old".into(),
1199 mode: RouteMode::Redirect {
1200 target: "https://elsewhere".into(),
1201 status: 308,
1202 },
1203 }];
1204 let err = plan_domain_worker(&dom, "https://cdn.net.yah.dev", "cloud").unwrap_err();
1205 assert!(
1206 format!("{err:#}").contains("no `static` route"),
1207 "got: {err:#}"
1208 );
1209 }
1210
1211 // ── R561-F4: alias-tier registration ──
1212 use super::{plan_alias_claim, valid_subdomain_label, AliasTier};
1213 use std::collections::BTreeMap;
1214
1215 #[test]
1216 fn label_validation_rules() {
1217 assert!(valid_subdomain_label("scrabcake"));
1218 assert!(valid_subdomain_label("my-repo-1"));
1219 assert!(!valid_subdomain_label("")); // empty
1220 assert!(!valid_subdomain_label("-lead")); // leading hyphen
1221 assert!(!valid_subdomain_label("trail-")); // trailing hyphen
1222 assert!(!valid_subdomain_label("Caps")); // uppercase
1223 assert!(!valid_subdomain_label("under_score")); // underscore
1224 }
1225
1226 #[test]
1227 fn claim_builds_net_tier_manifest() {
1228 let dom = plan_alias_claim(
1229 AliasTier::Net,
1230 "scrabcake",
1231 "scrabcake/site",
1232 &BTreeMap::new(),
1233 )
1234 .unwrap();
1235 assert_eq!(dom.name, "scrabcake-net-yah-dev");
1236 assert_eq!(dom.domain, "scrabcake.net.yah.dev");
1237 assert_eq!(dom.cdn_bucket, "net-yah-dev");
1238 assert_eq!(dom.routes.len(), 1);
1239 assert!(
1240 matches!(&dom.routes[0].mode, RouteMode::Static { component } if component == "scrabcake/site")
1241 );
1242 }
1243
1244 #[test]
1245 fn claim_uses_com_tier_zone_and_bucket() {
1246 let dom = plan_alias_claim(AliasTier::Com, "acme", "acme/site", &BTreeMap::new()).unwrap();
1247 assert_eq!(dom.domain, "acme.com.yah.dev");
1248 assert_eq!(dom.cdn_bucket, "com-yah-dev");
1249 }
1250
1251 #[test]
1252 fn claim_bails_on_duplicate_host() {
1253 let existing: BTreeMap<String, DomainConfig> =
1254 [("scrabcake-net-yah-dev".to_string(), net_tier_manifest())]
1255 .into_iter()
1256 .collect();
1257 let err =
1258 plan_alias_claim(AliasTier::Net, "scrabcake", "scrabcake/site", &existing).unwrap_err();
1259 assert!(format!("{err:#}").contains("already"), "got: {err:#}");
1260 }
1261
1262 #[test]
1263 fn claim_bails_on_invalid_label() {
1264 let err =
1265 plan_alias_claim(AliasTier::Net, "Bad_Name", "x/y", &BTreeMap::new()).unwrap_err();
1266 assert!(
1267 format!("{err:#}").contains("invalid subdomain"),
1268 "got: {err:#}"
1269 );
1270 }
1271
1272 // ── R859-F1: sovereign apex (front_door = "passway") ──
1273 //
1274 // Everything here is the pure half. The blast radius of this arm is LIVE
1275 // DNS on a public apex, so the decision logic is exercised offline in full
1276 // and `deploy_domain_passway` only executes the diff these produce.
1277
1278 use super::{
1279 diff_apex_records, plan_domain_passway, public_origins, ApexRecordDiff, DomainPasswayPlan,
1280 LiveApexRecord, PasswayOrigin, ResolvedOrigins,
1281 };
1282 use crate::config::{ConnectSpec, MachineConfig};
1283 use std::net::Ipv4Addr;
1284
1285 /// Mirrors `.yah/domains/yah-dev.toml` — the only `front_door = "passway"`
1286 /// manifest in the camp today.
1287 fn passway_manifest() -> DomainConfig {
1288 DomainConfig {
1289 schema_version: 1,
1290 name: "yah-dev".into(),
1291 domain: "yah.dev".into(),
1292 front_door: FrontDoor::Passway,
1293 cdn_bucket: "yah-dev".into(),
1294 worker_bundle_path: None,
1295 routes: vec![DomainRoute {
1296 headers: Default::default(),
1297 path: "/*".into(),
1298 mode: RouteMode::Static {
1299 component: "yah-marketing/site".into(),
1300 },
1301 }],
1302 }
1303 }
1304
1305 fn machine(name: &str, address: Option<&str>, taints: &[&str]) -> MachineConfig {
1306 MachineConfig {
1307 name: name.into(),
1308 provider: "ovh".into(),
1309 location: None,
1310 server_type: None,
1311 hosts_mirrors: vec![],
1312 mesh_tags: vec![],
1313 region: None,
1314 zone: None,
1315 arch: None,
1316 bucket: None,
1317 vendor: None,
1318 nickname: None,
1319 legacy_hostkey_fingerprint: None,
1320 registration: Default::default(),
1321 ssh_keys: vec![],
1322 cloudflared: None,
1323 hosts_operator_bridge: false,
1324 connect: address.map(|a| ConnectSpec {
1325 address: a.into(),
1326 ssh: format!("root@{a}"),
1327 yubaba_port: None,
1328 yubaba: None,
1329 }),
1330 allocatable: None,
1331 taints: taints.iter().map(|t| t.to_string()).collect(),
1332 sovereign_group: None,
1333 sovereign_role: None,
1334 ingress_floating_ip: None,
1335 }
1336 }
1337
1338 /// The live fleet shape: us-east-001 and us-west-001 both carry the
1339 /// `public-ip` taint with a public address (machines/*.toml), a third box
1340 /// fronts internal hostnames over the mesh and must not reach the apex.
1341 fn fleet() -> Vec<MachineConfig> {
1342 vec![
1343 machine("us-east-001", Some("51.81.85.145"), &["public-ip"]),
1344 machine("us-west-001", Some("15.204.89.240"), &["public-ip"]),
1345 machine("us-west-002", Some("100.64.0.4"), &[]),
1346 ]
1347 }
1348
1349 fn origins(names: &[&str]) -> ResolvedOrigins {
1350 origins_excluding(names, &[])
1351 }
1352
1353 /// R859-F2: the same resolution with `down` confirmed dead — declared and
1354 /// resolvable, but held out of the published set.
1355 fn origins_excluding(names: &[&str], down: &[&str]) -> ResolvedOrigins {
1356 let fleet = fleet();
1357 let names: Vec<String> = names.iter().map(|n| n.to_string()).collect();
1358 let down: Vec<String> = down.iter().map(|n| n.to_string()).collect();
1359 public_origins(&names, &fleet, &down).unwrap()
1360 }
1361
1362 /// A plan whose origin set is COMPLETE — the clean-collation case.
1363 fn plan_of(names: &[&str]) -> DomainPasswayPlan {
1364 plan_domain_passway(&passway_manifest(), origins(names), true).unwrap()
1365 }
1366
1367 /// The same plan, but built from a collation that reported problems, so an
1368 /// origin's absence cannot be read as a withdrawal.
1369 fn plan_of_incomplete(names: &[&str]) -> DomainPasswayPlan {
1370 plan_domain_passway(&passway_manifest(), origins(names), false).unwrap()
1371 }
1372
1373 fn live(records: &[(&str, bool)]) -> Vec<LiveApexRecord> {
1374 records
1375 .iter()
1376 .map(|(content, proxied)| LiveApexRecord {
1377 content: (*content).into(),
1378 proxied: *proxied,
1379 })
1380 .collect()
1381 }
1382
1383 #[test]
1384 fn public_origins_keeps_tainted_machines_and_skips_the_rest() {
1385 let got = origins(&["us-east-001", "us-west-001", "us-west-002"]);
1386 assert!(
1387 got.health_withdrawn.is_empty(),
1388 "no exclusions were passed, so nothing may be withheld"
1389 );
1390 assert_eq!(
1391 got.origins,
1392 vec![
1393 PasswayOrigin {
1394 machine: "us-east-001".into(),
1395 address: Ipv4Addr::new(51, 81, 85, 145),
1396 },
1397 PasswayOrigin {
1398 machine: "us-west-001".into(),
1399 address: Ipv4Addr::new(15, 204, 89, 240),
1400 },
1401 ],
1402 "an untainted mesh-only front door is not an apex origin"
1403 );
1404 }
1405
1406 /// The publicness cross-check nothing did before R859-F1: the `public-ip`
1407 /// taint and `connect.address` are independent declarations, and a machine
1408 /// dialled over its tailnet address would otherwise publish `100.64.0.x`
1409 /// at a public apex and black-hole its share of the round-robin.
1410 #[test]
1411 fn public_origins_rejects_a_non_public_address_and_names_the_machine() {
1412 for bad in ["100.64.0.4", "10.0.0.7", "192.168.1.20", "127.0.0.1"] {
1413 let fleet = vec![machine("us-west-002", Some(bad), &["public-ip"])];
1414 let err = public_origins(&["us-west-002".to_string()], &fleet, &[]).unwrap_err();
1415 let msg = format!("{err:#}");
1416 assert!(msg.contains("us-west-002"), "{msg}");
1417 assert!(msg.contains(bad), "{msg}");
1418 assert!(msg.contains("public"), "{msg}");
1419 }
1420 }
1421
1422 #[test]
1423 fn public_origins_rejects_an_unparseable_address_and_names_the_machine() {
1424 let fleet = vec![machine("us-east-001", Some("edge.example.net"), &["public-ip"])];
1425 let err = public_origins(&["us-east-001".to_string()], &fleet, &[]).unwrap_err();
1426 let msg = format!("{err:#}");
1427 assert!(msg.contains("us-east-001"), "{msg}");
1428 assert!(msg.contains("not an IPv4 address"), "{msg}");
1429 }
1430
1431 #[test]
1432 fn public_origins_rejects_a_tainted_machine_with_no_connect_block() {
1433 let fleet = vec![machine("us-east-001", None, &["public-ip"])];
1434 let err = public_origins(&["us-east-001".to_string()], &fleet, &[]).unwrap_err();
1435 let msg = format!("{err:#}");
1436 assert!(msg.contains("us-east-001"), "{msg}");
1437 assert!(msg.contains("[connect]"), "{msg}");
1438 }
1439
1440 #[test]
1441 fn public_origins_rejects_a_machine_with_no_toml() {
1442 let err = public_origins(&["ghost-001".to_string()], &fleet(), &[]).unwrap_err();
1443 let msg = format!("{err:#}");
1444 assert!(msg.contains("ghost-001"), "{msg}");
1445 }
1446
1447 #[test]
1448 fn plan_sorts_and_dedups_origins_and_resolves_the_apex_zone() {
1449 let mut o = origins(&["us-west-001", "us-east-001"]);
1450 o.origins.push(o.origins[0].clone()); // same machine collated twice
1451 let plan = plan_domain_passway(&passway_manifest(), o, true).unwrap();
1452 assert_eq!(plan.zone, "yah.dev");
1453 assert_eq!(plan.name, "yah.dev");
1454 assert_eq!(
1455 plan.origins
1456 .iter()
1457 .map(|o| o.address.to_string())
1458 .collect::<Vec<_>>(),
1459 vec!["15.204.89.240", "51.81.85.145"],
1460 );
1461 }
1462
1463 /// The live-DNS blast radius this arm exists to bound: an empty desired
1464 /// set is what the applier prunes against, so rendering it would withdraw
1465 /// every A record at the apex. Nothing about "no machine declared it"
1466 /// means "take the site down" — it must be an error, not a wipe.
1467 #[test]
1468 fn empty_origin_set_is_an_error_not_an_apex_wipe() {
1469 let err = plan_domain_passway(&passway_manifest(), ResolvedOrigins::default(), true).unwrap_err();
1470 let msg = format!("{err:#}");
1471 assert!(msg.contains("refusing to render an empty apex"), "{msg}");
1472 assert!(msg.contains("yah.dev"), "{msg}");
1473 }
1474
1475 #[test]
1476 fn plan_bails_when_front_door_is_not_passway() {
1477 for door in [FrontDoor::BucketDirect, FrontDoor::Worker] {
1478 let mut dom = passway_manifest();
1479 dom.front_door = door;
1480 let err = plan_domain_passway(&dom, origins(&["us-east-001"]), true).unwrap_err();
1481 let msg = format!("{err:#}");
1482 assert!(msg.contains("front_door"), "{msg}");
1483 assert!(msg.contains(door.as_str()), "{msg}");
1484 }
1485 }
1486
1487 #[test]
1488 fn converged_apex_is_a_no_op() {
1489 let plan = plan_of(&["us-east-001", "us-west-001"]);
1490 let diff = diff_apex_records(
1491 &plan,
1492 &live(&[("51.81.85.145", false), ("15.204.89.240", false)]),
1493 );
1494 assert_eq!(diff, ApexRecordDiff::default());
1495 assert!(diff.is_converged());
1496 }
1497
1498 #[test]
1499 fn adding_a_machine_yields_exactly_one_added_record_and_no_prune() {
1500 let plan = plan_of(&["us-east-001", "us-west-001"]);
1501 let diff = diff_apex_records(&plan, &live(&[("51.81.85.145", false)]));
1502 assert_eq!(diff.upsert, vec!["15.204.89.240".to_string()]);
1503 assert!(diff.prune.is_empty(), "{diff:?}");
1504 }
1505
1506 #[test]
1507 fn removing_a_machine_yields_exactly_one_prune_and_leaves_the_survivor() {
1508 let plan = plan_of(&["us-east-001"]);
1509 let diff = diff_apex_records(
1510 &plan,
1511 &live(&[("51.81.85.145", false), ("15.204.89.240", false)]),
1512 );
1513 assert_eq!(diff.prune, vec!["15.204.89.240".to_string()]);
1514 assert!(
1515 diff.upsert.is_empty(),
1516 "the survivor is already correct: {diff:?}"
1517 );
1518 }
1519
1520 /// `scripts/cf-apex-mode.sh orange` is break-glass; the manifest declares
1521 /// DNS-only. A proxied record carrying a desired address is therefore
1522 /// drift, and re-upserting it (`proxied = false`) is the convergence.
1523 #[test]
1524 fn a_proxied_record_at_a_desired_address_is_rewritten_not_left_alone() {
1525 let plan = plan_of(&["us-east-001"]);
1526 let diff = diff_apex_records(&plan, &live(&[("51.81.85.145", true)]));
1527 assert_eq!(diff.upsert, vec!["51.81.85.145".to_string()]);
1528 assert!(
1529 diff.prune.is_empty(),
1530 "the address is declared — it must not be pruned: {diff:?}"
1531 );
1532 }
1533
1534 /// **Fail-closed on withdrawal.** `collate_workspace_ingress` returns `Ok`
1535 /// while SKIPPING an edge whose declaration fails to plan, so a passway
1536 /// edge with a config typo silently drops its machine from `front_doors` —
1537 /// which, from inside the plan, is indistinguishable from the operator
1538 /// withdrawing that node. Without this gate a typo would render as a DNS
1539 /// withdrawal and take that origin offline. Upserts must still run: an
1540 /// unrelated service's broken declaration cannot be allowed to block
1541 /// growing the fleet, and an upsert can never make the apex worse.
1542 #[test]
1543 fn an_incomplete_collation_upserts_but_withholds_every_prune() {
1544 // us-west-001 is missing from the front-door set, and the collation
1545 // reported problems — so its absence is not trustworthy evidence of a
1546 // withdrawal. us-east-001 is newly declared and must still be written.
1547 let plan = plan_of_incomplete(&["us-east-001"]);
1548 let diff = diff_apex_records(&plan, &live(&[("15.204.89.240", false)]));
1549
1550 assert_eq!(
1551 diff.upsert,
1552 vec!["51.81.85.145".to_string()],
1553 "additions must still land through a dirty collation"
1554 );
1555 assert!(
1556 diff.prune.is_empty(),
1557 "a live record must never be withdrawn on an incomplete collation: {diff:?}"
1558 );
1559 assert_eq!(
1560 diff.withheld_prune,
1561 vec!["15.204.89.240".to_string()],
1562 "the withheld record is reported so the applier can name it"
1563 );
1564
1565 // Same inputs, clean collation: the prune is real. This is the
1566 // control that proves the gate is what changed the outcome.
1567 let trusted = plan_of(&["us-east-001"]);
1568 let diff = diff_apex_records(&trusted, &live(&[("15.204.89.240", false)]));
1569 assert_eq!(diff.prune, vec!["15.204.89.240".to_string()]);
1570 assert!(diff.withheld_prune.is_empty(), "{diff:?}");
1571 }
1572
1573 /// The empty-set guard stays AHEAD of the incompleteness gate: an empty
1574 /// origin set is unusable whether or not the collation was clean, so it
1575 /// remains the louder failure rather than degrading into a silent
1576 /// everything-withheld apply.
1577 #[test]
1578 fn empty_origin_set_still_errors_on_an_incomplete_collation() {
1579 let err = plan_domain_passway(&passway_manifest(), ResolvedOrigins::default(), false).unwrap_err();
1580 assert!(
1581 format!("{err:#}").contains("refusing to render an empty apex"),
1582 "got: {err:#}"
1583 );
1584 }
1585
1586 // ── R859-F2: health-excluded origins ──────────────────────────────────
1587
1588 /// The exclusion happens after full resolution, and reports what it held
1589 /// back rather than dropping it silently — `health_withdrawn` is what makes
1590 /// "declared but dead" distinguishable from "never declared" downstream.
1591 #[test]
1592 fn a_confirmed_down_machine_is_resolved_then_withheld_not_dropped() {
1593 let got = origins_excluding(&["us-east-001", "us-west-001"], &["us-east-001"]);
1594 assert_eq!(
1595 got.origins,
1596 vec![PasswayOrigin {
1597 machine: "us-west-001".into(),
1598 address: Ipv4Addr::new(15, 204, 89, 240),
1599 }]
1600 );
1601 assert_eq!(
1602 got.health_withdrawn,
1603 vec![PasswayOrigin {
1604 machine: "us-east-001".into(),
1605 address: Ipv4Addr::new(51, 81, 85, 145),
1606 }],
1607 "a withheld origin must still be reported, with the address it would have published"
1608 );
1609 }
1610
1611 /// An exclusion naming a machine that fronts nothing is not an error. The
1612 /// caller's liveness view covers the whole fleet and most of it never
1613 /// fronts anything, so requiring the sets to line up would make every
1614 /// unrelated node failure an apex-render failure.
1615 #[test]
1616 fn excluding_a_machine_that_fronts_nothing_is_a_no_op() {
1617 let got = origins_excluding(&["us-east-001"], &["us-west-001", "ghost-001"]);
1618 assert_eq!(got.origins.len(), 1);
1619 assert!(got.health_withdrawn.is_empty());
1620 }
1621
1622 /// The catastrophic case, and the reason the empty-set guard is checked
1623 /// against the *survivors*: "every front door is down" must not render as
1624 /// "withdraw every A record". A dead origin still in DNS is a partial
1625 /// outage; an empty apex is a total one.
1626 #[test]
1627 fn excluding_every_origin_is_an_error_not_an_apex_wipe() {
1628 let resolved = origins_excluding(
1629 &["us-east-001", "us-west-001"],
1630 &["us-east-001", "us-west-001"],
1631 );
1632 assert_eq!(resolved.health_withdrawn.len(), 2);
1633 let err = plan_domain_passway(&passway_manifest(), resolved, true).unwrap_err();
1634 assert!(
1635 format!("{err:#}").contains("refusing to render an empty apex"),
1636 "got: {err:#}"
1637 );
1638 }
1639
1640 /// Two machines can share one address (a floating IP mid-move, or two edges
1641 /// collated onto one box). Losing one of them must not withdraw a record
1642 /// the other still answers on.
1643 #[test]
1644 fn an_address_a_live_origin_still_serves_is_not_withdrawn() {
1645 let fleet = vec![
1646 machine("us-east-001", Some("51.81.85.145"), &["public-ip"]),
1647 machine("us-east-002", Some("51.81.85.145"), &["public-ip"]),
1648 ];
1649 let resolved = public_origins(
1650 &["us-east-001".to_string(), "us-east-002".to_string()],
1651 &fleet,
1652 &["us-east-001".to_string()],
1653 )
1654 .unwrap();
1655 let plan = plan_domain_passway(&passway_manifest(), resolved, true).unwrap();
1656 assert_eq!(
1657 plan.origins
1658 .iter()
1659 .map(|o| o.address.to_string())
1660 .collect::<Vec<_>>(),
1661 vec!["51.81.85.145"]
1662 );
1663 assert!(
1664 plan.health_withdrawn.is_empty(),
1665 "us-east-002 still answers on that address, so it must not be pruned"
1666 );
1667 }
1668
1669 /// **The cross-product.** `origins_complete` and `health_withdrawn` are two
1670 /// different facts, and the bottom-right cell is the whole point of R859-F2:
1671 /// an unrelated service's broken declaration must not veto a withdrawal
1672 /// resting on a box we watched go down.
1673 #[test]
1674 fn health_withdrawal_and_declaration_completeness_are_independent() {
1675 let live = live(&[("51.81.85.145", false), ("15.204.89.240", false)]);
1676
1677 // complete + healthy: an ordinary withdrawal from a trusted declaration.
1678 let complete_healthy = plan_domain_passway(
1679 &passway_manifest(),
1680 origins(&["us-west-001"]),
1681 true,
1682 )
1683 .unwrap();
1684 let d = diff_apex_records(&complete_healthy, &live);
1685 assert_eq!(d.prune, vec!["51.81.85.145"]);
1686 assert!(d.withheld_prune.is_empty());
1687
1688 // complete + down: the same prune, now on health grounds.
1689 let complete_down = plan_domain_passway(
1690 &passway_manifest(),
1691 origins_excluding(&["us-east-001", "us-west-001"], &["us-east-001"]),
1692 true,
1693 )
1694 .unwrap();
1695 let d = diff_apex_records(&complete_down, &live);
1696 assert_eq!(d.prune, vec!["51.81.85.145"]);
1697 assert!(d.withheld_prune.is_empty());
1698
1699 // incomplete + healthy: might be a withdrawal, might be a typo — withheld.
1700 let incomplete_healthy = plan_domain_passway(
1701 &passway_manifest(),
1702 origins(&["us-west-001"]),
1703 false,
1704 )
1705 .unwrap();
1706 let d = diff_apex_records(&incomplete_healthy, &live);
1707 assert!(
1708 d.prune.is_empty(),
1709 "an absence under an incomplete collation must never prune"
1710 );
1711 assert_eq!(d.withheld_prune, vec!["51.81.85.145"]);
1712
1713 // incomplete + down: PRUNED ANYWAY. We saw this machine declared and we
1714 // saw it die; the incompleteness is about a different declaration.
1715 let incomplete_down = plan_domain_passway(
1716 &passway_manifest(),
1717 origins_excluding(&["us-east-001", "us-west-001"], &["us-east-001"]),
1718 false,
1719 )
1720 .unwrap();
1721 let d = diff_apex_records(&incomplete_down, &live);
1722 assert_eq!(
1723 d.prune,
1724 vec!["51.81.85.145"],
1725 "a health withdrawal rests on a positive observation, not on an absence, so \
1726 origins_complete = false must not suppress it"
1727 );
1728 assert!(d.withheld_prune.is_empty());
1729 }
1730
1731 /// The two reasons for a prune coexist in one diff without merging: under an
1732 /// incomplete collation, the health-excluded address is pruned and the
1733 /// merely-absent one is still withheld.
1734 #[test]
1735 fn an_incomplete_collation_prunes_only_the_health_withdrawn_surplus() {
1736 let plan = plan_domain_passway(
1737 &passway_manifest(),
1738 origins_excluding(&["us-east-001", "us-west-001"], &["us-east-001"]),
1739 false,
1740 )
1741 .unwrap();
1742 let d = diff_apex_records(
1743 &plan,
1744 &live(&[
1745 ("51.81.85.145", false), // declared + confirmed down -> prune
1746 ("15.204.89.240", false), // the surviving origin -> kept
1747 ("203.0.113.9", false), // never declared at all -> withheld
1748 ]),
1749 );
1750 assert_eq!(d.prune, vec!["51.81.85.145"]);
1751 assert_eq!(d.withheld_prune, vec!["203.0.113.9"]);
1752 assert!(d.upsert.is_empty());
1753 }
1754
1755 /// A withheld prune is not convergence — it is a write the applier
1756 /// declined — but it is also not a write, so `is_converged` stays false
1757 /// only when there is something to actually do.
1758 #[test]
1759 fn a_withheld_prune_alone_leaves_nothing_to_write() {
1760 let plan = plan_of_incomplete(&["us-east-001"]);
1761 let diff = diff_apex_records(
1762 &plan,
1763 &live(&[("51.81.85.145", false), ("15.204.89.240", false)]),
1764 );
1765 assert!(diff.upsert.is_empty(), "{diff:?}");
1766 assert!(diff.prune.is_empty(), "{diff:?}");
1767 assert!(diff.is_converged(), "no write to make: {diff:?}");
1768 assert_eq!(diff.withheld_prune, vec!["15.204.89.240".to_string()]);
1769 }
1770
1771 /// Ordering contract (`cf-apex-mode.sh:245-246,287-288`): the applier
1772 /// writes `upsert` before `prune`, so a full origin swap never leaves the
1773 /// apex without a routing record.
1774 #[test]
1775 fn a_full_origin_swap_writes_the_new_record_before_pruning_the_old() {
1776 let plan = plan_of(&["us-west-001"]);
1777 let diff = diff_apex_records(&plan, &live(&[("51.81.85.145", false)]));
1778 assert_eq!(diff.upsert, vec!["15.204.89.240".to_string()]);
1779 assert_eq!(diff.prune, vec!["51.81.85.145".to_string()]);
1780 assert!(!diff.is_converged());
1781 }
1782}