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 is in neither this \
661 camp's own .yah/infra/machines/*.toml nor any fleet it borrows through \
662 .yah/infra/sources.toml — cannot resolve its public address"
663 )
664 })?;
665 if !machine
666 .taints
667 .iter()
668 .any(|t| t == workload_spec::PUBLIC_IP_TAINT)
669 {
670 debug!(
671 machine = %name,
672 "front-door machine has no `public-ip` taint — not an apex origin"
673 );
674 continue;
675 }
676 let address = machine
677 .connect
678 .as_ref()
679 .map(|c| c.address.as_str())
680 .with_context(|| {
681 format!(
682 "machine {name} carries the `public-ip` taint but declares no \
683 [connect] address — nothing to publish at the apex"
684 )
685 })?;
686 let origin = PasswayOrigin {
687 machine: name.clone(),
688 address: public_ipv4_for(name, address)?,
689 };
690 if health_excluded.iter().any(|m| m == name) {
691 debug!(
692 machine = %name,
693 address = %origin.address,
694 "front-door machine confirmed down — withheld from the apex origin set"
695 );
696 resolved.health_withdrawn.push(origin);
697 } else {
698 resolved.origins.push(origin);
699 }
700 }
701 Ok(resolved)
702}
703
704/// Build the apex record plan for a `front_door = "passway"` manifest.
705///
706/// Refuses two shapes outright:
707///
708/// 1. A manifest declaring a different front door — the same guard
709/// [`plan_domain_worker`] carries, for the same reason.
710/// 2. An **empty** origin set. The desired set is what the applier prunes
711/// against, so an empty one would not mean "leave it alone", it would mean
712/// "delete every A record at the apex" — a live outage rendered from a
713/// collation that simply found nothing. Nothing about "no machine is
714/// declared" says "take the site down", so it is an error.
715///
716/// That empty-set guard is checked **before** `origins_complete` is consulted:
717/// an empty set is unusable whether or not the collation was clean, so it stays
718/// the louder failure.
719///
720/// `origins_complete` is the caller's answer to "did the collation see the
721/// whole picture?" — `false` withholds every prune downstream. See
722/// [`DomainPasswayPlan::origins_complete`].
723///
724/// # The empty-set guard is also the health failover's backstop (R859-F2)
725///
726/// Guard 2 is checked against the origins that **survive** health exclusion, and
727/// that is the load-bearing interaction of this whole feature: if every declared
728/// front door is confirmed down, `origins` is empty and this refuses. "All our
729/// front doors are down" must never render as "withdraw every A record and take
730/// the site down" — a dead origin still in DNS is a partial outage, an empty
731/// apex is a total one, and between those the first is strictly better. So the
732/// health withdrawal is capped at "all but the last origin" by construction,
733/// with no separate rule to keep in sync.
734///
735/// An address that is *also* served by a surviving origin is dropped from
736/// [`health_withdrawn`](DomainPasswayPlan::health_withdrawn) for the same
737/// reason, one level finer: two machines can share a floating IP (the dedup
738/// comment below names that case), and pruning the record because one of them
739/// died would withdraw an address the other is still answering on.
740pub fn plan_domain_passway(
741 domain: &DomainConfig,
742 resolved: ResolvedOrigins,
743 origins_complete: bool,
744) -> Result<DomainPasswayPlan> {
745 let ResolvedOrigins {
746 origins,
747 health_withdrawn,
748 } = resolved;
749 if domain.front_door != FrontDoor::Passway {
750 anyhow::bail!(
751 "domain {} ({}) declares front_door = \"{}\" — only \"passway\" domains \
752 get a sovereign apex rendered here",
753 domain.name,
754 domain.domain,
755 domain.front_door.as_str()
756 );
757 }
758
759 // By address, not by machine name: the address is what lands in DNS, so
760 // ordering on it is what makes two planning runs byte-identical even if a
761 // box is renamed. Dedup on the same key — two edges collated onto one
762 // machine (or two machines sharing a floating IP) publish one record.
763 let mut origins = origins;
764 origins.sort_by(|a, b| (a.address, &a.machine).cmp(&(b.address, &b.machine)));
765 origins.dedup_by(|a, b| a.address == b.address);
766
767 if origins.is_empty() {
768 anyhow::bail!(
769 "domain {} ({}) declares front_door = \"passway\" but no declared front-door \
770 machine carries the `public-ip` taint with a public address — refusing to \
771 render an empty apex, which would withdraw every A record and take the site \
772 down. Declare the ingress edge's machines (or their taints) first.",
773 domain.name,
774 domain.domain
775 );
776 }
777
778 // Same normalisation as `origins`, plus the survivor filter: an address a
779 // live origin still answers on is not withdrawn, however many of the
780 // machines sharing it went down.
781 let mut health_withdrawn = health_withdrawn;
782 health_withdrawn.retain(|w| !origins.iter().any(|o| o.address == w.address));
783 health_withdrawn.sort_by(|a, b| (a.address, &a.machine).cmp(&(b.address, &b.machine)));
784 health_withdrawn.dedup_by(|a, b| a.address == b.address);
785
786 Ok(DomainPasswayPlan {
787 zone: parent_zone_name(&domain.domain).to_string(),
788 name: domain.domain.clone(),
789 origins,
790 origins_complete,
791 health_withdrawn,
792 })
793}
794
795/// Diff the planned apex against the A records live at that name.
796///
797/// `live` must already be narrowed to **type A at `plan.name`** — MX, TXT and
798/// AAAA records share the apex and are never this arm's to touch.
799///
800/// A live record whose content is desired but which is *proxied* still lands in
801/// `upsert`: orange-cloud is a break-glass state
802/// (`scripts/cf-apex-mode.sh orange`) and this reconciler declares DNS-only, so
803/// re-writing the record with `proxied = false` is convergence, not churn.
804///
805/// When [`plan.origins_complete`](DomainPasswayPlan::origins_complete) is
806/// `false` the surplus records move to
807/// [`withheld_prune`](ApexRecordDiff::withheld_prune) instead of `prune`, and
808/// `upsert` is unaffected. An incomplete collation cannot tell a withdrawn
809/// origin from one whose declaration failed to plan, and only one of those two
810/// wants a DNS withdrawal.
811///
812/// # …with one exception, and it is the point of R859-F2
813///
814/// A surplus address that appears in
815/// [`plan.health_withdrawn`](DomainPasswayPlan::health_withdrawn) is pruned
816/// **regardless of `origins_complete`**. The two flags are not two strengths of
817/// the same doubt, they are answers to different questions, and the four cases
818/// come out like this:
819///
820/// | `origins_complete` | in `health_withdrawn` | verdict |
821/// |---|---|---|
822/// | `true` | no | `prune` — an ordinary withdrawal from a trusted declaration |
823/// | `true` | yes | `prune` — a health withdrawal from a trusted declaration |
824/// | `false` | no | `withheld_prune` — might be a withdrawal, might be a typo |
825/// | `false` | yes | `prune` — we saw this machine declared *and* saw it die |
826///
827/// The bottom-right cell is the one that matters. `origins_complete = false`
828/// protects against mistaking an absence for a withdrawal; a health withdrawal
829/// is not an absence, it is a positive observation about a machine the
830/// collation resolved. An unrelated service's broken TOML is not evidence about
831/// a box we watched go down, and letting it withhold the prune would leave a
832/// dead origin serving its share of the round-robin for as long as that typo
833/// lives.
834pub fn diff_apex_records(plan: &DomainPasswayPlan, live: &[LiveApexRecord]) -> ApexRecordDiff {
835 let desired: Vec<String> = plan
836 .origins
837 .iter()
838 .map(|o| o.address.to_string())
839 .collect();
840 let upsert = desired
841 .iter()
842 .filter(|ip| {
843 !live
844 .iter()
845 .any(|r| &&r.content == ip && !r.proxied)
846 })
847 .cloned()
848 .collect();
849 let surplus: Vec<String> = live
850 .iter()
851 .filter(|r| !desired.contains(&r.content))
852 .map(|r| r.content.clone())
853 .collect();
854 if plan.origins_complete {
855 return ApexRecordDiff {
856 upsert,
857 prune: surplus,
858 withheld_prune: Vec::new(),
859 };
860 }
861 // Incomplete declaration: withhold every prune EXCEPT the health
862 // withdrawals, which rest on a positive observation rather than on an
863 // absence. See this function's doc table.
864 let withdrawn: Vec<String> = plan
865 .health_withdrawn
866 .iter()
867 .map(|o| o.address.to_string())
868 .collect();
869 let (prune, withheld_prune) = surplus
870 .into_iter()
871 .partition(|content| withdrawn.contains(content));
872 ApexRecordDiff {
873 upsert,
874 prune,
875 withheld_prune,
876 }
877}
878
879/// Resolve the DNS adapter one passway apply talks to.
880///
881/// Its own function only so the read path and the write path cannot drift onto
882/// different credentials or a different provider seam.
883fn passway_envoy(workspace_root: &Path, provider_id: &str) -> Result<CloudflareEnvoy> {
884 let cf_provider = super::cf_creds::CfProvider::resolve(workspace_root, provider_id)?;
885 Ok(CloudflareEnvoy::new(
886 cf_provider.api_token()?,
887 cf_provider.account_id.clone(),
888 ))
889}
890
891/// The `list` leg of [`deploy_domain_passway`] — the live A records at one
892/// name, as `dns.record.list` reports them.
893///
894/// Takes `zone`/`name` rather than a [`DomainPasswayPlan`] because the
895/// no-door-declared branch of [`ensure_passway_apex`] has to read the apex
896/// *without* a plan — there are no origins to build one from, and whether the
897/// apex is currently serving is exactly the question it needs answered.
898async fn read_live_apex(
899 envoy: &CloudflareEnvoy,
900 zone: &str,
901 name: &str,
902) -> Result<Vec<LiveApexRecord>> {
903 let listed = envoy
904 .dns_record_list(DnsRecordListInput {
905 zone: zone.to_string(),
906 name: Some(name.to_string()),
907 record_type: Some("A".to_string()),
908 })
909 .await
910 .with_context(|| format!("listing A records at {name}"))?;
911 Ok(listed
912 .records
913 .into_iter()
914 .map(|r| LiveApexRecord {
915 content: r.content,
916 proxied: r.proxied,
917 })
918 .collect())
919}
920
921/// Read the live apex A records **without being able to write any** — the
922/// read-only half of [`deploy_domain_passway`], exposed so convergence can be
923/// checked against a real Cloudflare account by something that has no write
924/// path at all (R859-F1's live acceptance check,
925/// `tests/passway_apex_live.rs`).
926///
927/// Pair it with [`plan_passway_apex`] and [`diff_apex_records`] to answer "what
928/// would `yah cloud apply` change?" against live DNS. That is the *whole* of
929/// what an apply decides — same planner, same list verb, same diff — minus the
930/// two `dns.record.upsert` / `dns.record.delete` calls, which this function
931/// cannot reach. A caller can therefore assert convergence against the real
932/// zone without a live-DNS blast radius, which is what makes the check safe to
933/// leave runnable rather than described in a handoff.
934pub async fn list_live_apex_records(
935 workspace_root: &Path,
936 provider_id: &str,
937 plan: &DomainPasswayPlan,
938) -> Result<Vec<LiveApexRecord>> {
939 let envoy = passway_envoy(workspace_root, provider_id)?;
940 read_live_apex(&envoy, &plan.zone, &plan.name).await
941}
942
943/// Apply a [`DomainPasswayPlan`] against live DNS (R859-F1, live I/O).
944///
945/// **First production consumer of the `dns.*` envoy verbs** — every read and
946/// write here goes through [`CloudflareEnvoy`]'s typed verb handlers rather
947/// than the `CloudflareClient` methods the older arms call directly, so
948/// swapping the DNS provider is a matter of resolving a different adapter here
949/// and nothing else.
950///
951/// Two invariants, both borrowed from `scripts/cf-apex-mode.sh`, whose
952/// behaviour this replaces for the routine case:
953///
954/// - **List first, skip when converged.** Same shape as
955/// [`ensure_r2_custom_domain`], so a re-run of `yah cloud apply` writes
956/// nothing.
957/// - **Upsert before prune.** Desired records are written first and surplus
958/// ones removed last, so the apex is never momentarily recordless. Only
959/// `A` records at the name are ever pruned; the apex's MX and TXT records
960/// (mail routing, SPF, site verification) are outside the filter by
961/// construction.
962/// - **Fail-closed on withdrawal, fail-open on addition.** When the plan says
963/// the origin set is incomplete
964/// ([`origins_complete = false`](DomainPasswayPlan::origins_complete)) the
965/// upserts still run but every prune is withheld and warned about — an
966/// absent origin might be a withdrawal or might be a config typo upstream,
967/// and only one of those should take a live record away.
968///
969/// Always writes `proxied = false`: the manifest cannot express grey vs
970/// orange, and orange stays break-glass in the script (W267 tier ladder).
971pub async fn deploy_domain_passway(
972 workspace_root: &Path,
973 provider_id: &str,
974 plan: &DomainPasswayPlan,
975) -> Result<PasswayApexOutcome> {
976 let envoy = passway_envoy(workspace_root, provider_id)?;
977 let live = read_live_apex(&envoy, &plan.zone, &plan.name).await?;
978
979 let diff = diff_apex_records(plan, &live);
980
981 // Warned before the converged early-return: a withheld prune is worth
982 // saying out loud even on an apply that writes nothing, because the record
983 // it names stays live until someone fixes the declaration.
984 if !diff.withheld_prune.is_empty() {
985 warn!(
986 domain = %plan.name,
987 withheld = %diff.withheld_prune.join(", "),
988 "apex A records NOT withdrawn: the ingress collation reported problems, so an \
989 origin missing from it may be a broken declaration rather than a withdrawal. \
990 Run `yah cloud validate` and fix the reported ingress declaration; these \
991 records stay live until the collation is clean."
992 );
993 }
994
995 if diff.is_converged() {
996 debug!(
997 domain = %plan.name,
998 origins = plan.origins.len(),
999 "sovereign apex already matches the declaration — skipping"
1000 );
1001 return Ok(PasswayApexOutcome {
1002 withheld_prune: diff.withheld_prune,
1003 ..Default::default()
1004 });
1005 }
1006
1007 // Writes first: never leave the apex without a routing record.
1008 for ip in &diff.upsert {
1009 envoy
1010 .dns_record_upsert(DnsRecordUpsertInput {
1011 zone: plan.zone.clone(),
1012 name: plan.name.clone(),
1013 record_type: "A".to_string(),
1014 content: ip.clone(),
1015 ttl: 1,
1016 proxied: false,
1017 // A round-robin apex is a multi-valued RRset: without this the
1018 // second origin would overwrite the first.
1019 match_content: true,
1020 })
1021 .await
1022 .with_context(|| format!("upserting A {} -> {ip}", plan.name))?;
1023 info!(domain = %plan.name, origin = %ip, "sovereign apex A record written");
1024 }
1025
1026 // Prune last, and only ever type A carrying an undeclared value.
1027 for ip in &diff.prune {
1028 envoy
1029 .dns_record_delete(DnsRecordDeleteInput {
1030 zone: plan.zone.clone(),
1031 name: plan.name.clone(),
1032 record_type: Some("A".to_string()),
1033 content: Some(ip.clone()),
1034 })
1035 .await
1036 .with_context(|| format!("pruning surplus A {} -> {ip}", plan.name))?;
1037 info!(domain = %plan.name, origin = %ip, "surplus apex A record withdrawn");
1038 }
1039
1040 Ok(PasswayApexOutcome {
1041 upserted: diff.upsert,
1042 pruned: diff.prune,
1043 withheld_prune: diff.withheld_prune,
1044 })
1045}
1046
1047/// Reconcile one `front_door = "passway"` domain end to end — the entry point
1048/// `yah cloud apply` calls (R859-F1).
1049///
1050/// Collates the workspace's declared ingress edges, keeps the passway front
1051/// doors, resolves their machines to public addresses, plans, and applies.
1052/// Every input is *declared* config: no network read decides which origins the
1053/// apex publishes, which is what makes the manifest the single source the
1054/// two-source flip lacked.
1055///
1056/// The planning half is [`plan_passway_apex`] (which also carries the
1057/// `report.problems` prune gate); this function is that plan handed to
1058/// [`deploy_domain_passway`].
1059///
1060/// # `Ok(None)`: the door is declared but not stood up yet
1061///
1062/// A camp can legitimately declare `front_door = "passway"` on a domain whose
1063/// passway edge does not exist yet — the manifest field is how you *say* which
1064/// door you intend, and R859-F1 made it the mechanism as well, so it is written
1065/// before the edge is. The noisetable camp is exactly this: `noisetable.com`
1066/// declares `passway`, its only ingress edge is a `cloudflare-tunnel` on a
1067/// different domain, and its apex is deliberately NXDOMAIN pending a node.
1068///
1069/// Treating that as an error would have been wrong twice over. It fails the
1070/// whole `yah cloud apply` — the domain loop `bail!`s at the first failure
1071/// without `--continue-on-error` — so one not-yet-built door takes down the
1072/// publish chain for every service and every other domain in the camp. And the
1073/// empty-apex guard it would trip
1074/// ([`plan_domain_passway`]'s "refusing to render an empty apex") exists to
1075/// prevent a *wipe*, which skipping prevents equally well: this arm writes
1076/// nothing at all on this path.
1077///
1078/// So "no passway edge collated" is `Ok(None)` — but only after checking that
1079/// the apex is not **currently serving**. Those are two different worlds and
1080/// only a DNS read tells them apart:
1081///
1082/// - Nothing live at the name → the door was never stood up. Skip.
1083/// - Records live at the name → an edge that WAS fronting this apex has
1084/// vanished from the collation, and the next apply would otherwise silently
1085/// leave orphaned records pointing at whatever used to serve. That is a hard
1086/// error, and it is the case the empty-apex guard was really written for.
1087///
1088/// The read costs nothing extra in practice: this arm only runs inside the
1089/// domain pass, which already resolved a Cloudflare provider and an
1090/// `account_id` before entering the loop.
1091pub async fn ensure_passway_apex(
1092 workspace_root: &Path,
1093 provider_id: &str,
1094 domain: &DomainConfig,
1095) -> Result<Option<PasswayApexOutcome>> {
1096 let Some(plan) = plan_passway_apex(workspace_root, domain)? else {
1097 let zone = parent_zone_name(&domain.domain).to_string();
1098 let envoy = passway_envoy(workspace_root, provider_id)?;
1099 let live = read_live_apex(&envoy, &zone, &domain.domain).await?;
1100 if live.is_empty() {
1101 debug!(
1102 domain = %domain.domain,
1103 "declares front_door = \"passway\" with no passway ingress edge collated, and \
1104 nothing is live at the apex — nothing to render, skipping"
1105 );
1106 return Ok(None);
1107 }
1108 anyhow::bail!(
1109 "domain {} ({}) declares front_door = \"passway\" and {} A record(s) are live at \
1110 the apex ({}), but no passway ingress edge collates onto it. An edge that was \
1111 fronting this apex has disappeared from the declaration, and these records now \
1112 point at whatever used to serve. Restore the ingress edge, or move the domain \
1113 off `passway`, before applying again.",
1114 domain.name,
1115 domain.domain,
1116 live.len(),
1117 live.iter()
1118 .map(|r| r.content.as_str())
1119 .collect::<Vec<_>>()
1120 .join(", "),
1121 );
1122 };
1123 deploy_domain_passway(workspace_root, provider_id, &plan)
1124 .await
1125 .map(Some)
1126}
1127
1128/// The pure half of [`ensure_passway_apex`]: collate, resolve, plan — no
1129/// network, no credential, no write path.
1130///
1131/// Split out so the apex a given workspace *would* publish can be computed by a
1132/// caller that must not be able to change it. `yah cloud apply` reaches it only
1133/// through [`ensure_passway_apex`]; the live acceptance check
1134/// (`tests/passway_apex_live.rs`) calls it directly and pairs it with
1135/// [`list_live_apex_records`], so the check exercises this exact planner rather
1136/// than a second copy of its logic that could agree with the live zone while
1137/// production disagreed.
1138///
1139/// Every input is *declared* config: no network read decides which origins the
1140/// apex publishes, which is what makes the manifest the single source the
1141/// two-source flip lacked.
1142///
1143/// ## Why `report.problems` gates the prune
1144///
1145/// [`collate_workspace_ingress`](crate::validate::collate_workspace_ingress)
1146/// returns `Ok` while **skipping** an edge whose declaration fails to plan
1147/// (`validate.rs`, the `IngressProblem::Declaration` arms). A passway edge with
1148/// a config typo therefore drops its machine out of `front_doors` silently, and
1149/// from inside the plan that is indistinguishable from the operator having
1150/// withdrawn the node — so without this gate a typo would render as a DNS
1151/// withdrawal and take that origin's share of the apex offline.
1152///
1153/// Any non-empty `problems` therefore sets `origins_complete = false`, which
1154/// withholds every prune for this apply. Deliberately *not* narrowed to the
1155/// problems that mention this domain's edge: attributing a problem to an edge
1156/// requires the very planning that failed, and "non-empty means the picture is
1157/// incomplete" is the whole of what the prune path needs to know. Equally
1158/// deliberately, problems do **not** fail the arm — one service's broken
1159/// ingress declaration must not block another's DNS apply, and upserts can
1160/// only ever add reachable origins.
1161///
1162/// ## `Ok(None)` — no passway edge collates at all
1163///
1164/// Distinct from every error this can return, and the distinction is the whole
1165/// point: a camp that declares `front_door = "passway"` before building the
1166/// door has made no mistake, and must not have its apply failed for it. See
1167/// [`ensure_passway_apex`], which decides what to do about it (and is the one
1168/// that can tell "never stood up" from "the door vanished", because that takes
1169/// a DNS read this pure function must not make).
1170///
1171/// Note the narrowness: `None` means the *collation* produced no passway edge.
1172/// A declared edge whose machine lacks the `public-ip` taint, or carries a
1173/// private address, still reaches [`plan_domain_passway`] and still trips its
1174/// empty-apex guard as an error — an intended door that resolves to nothing is
1175/// a misconfiguration, not an absence.
1176pub fn plan_passway_apex(
1177 workspace_root: &Path,
1178 domain: &DomainConfig,
1179) -> Result<Option<DomainPasswayPlan>> {
1180 let report = crate::validate::collate_workspace_ingress(workspace_root)
1181 .context("collating workspace ingress to find the passway front doors")?;
1182
1183 let origins_complete = report.problems.is_empty();
1184 if !origins_complete {
1185 for problem in &report.problems {
1186 warn!(
1187 domain = %domain.domain,
1188 "ingress collation problem — apex prunes withheld this apply: {}",
1189 problem.message()
1190 );
1191 }
1192 }
1193
1194 let mut front_door_machines: Vec<String> = report
1195 .collation
1196 .front_doors
1197 .iter()
1198 .filter(|fd| fd.provider == crate::config::IngressProvider::Passway)
1199 .map(|fd| fd.machine.clone())
1200 .collect();
1201 front_door_machines.sort();
1202 front_door_machines.dedup();
1203
1204 // The door is declared but not built yet. Answered here rather than left to
1205 // plan_domain_passway's empty-apex guard, because an absent edge and an
1206 // edge that resolves to no public address want opposite treatment.
1207 if front_door_machines.is_empty() {
1208 return Ok(None);
1209 }
1210
1211 // R870-B13: the camp's resolved fleet inventory, not its camp-local
1212 // machine files. A camp that BORROWS another camp's fleet (empty
1213 // `.yah/infra/machines/`, one `[[source]]` link in
1214 // `.yah/infra/sources.toml`) declares no machines of its own, so reading
1215 // the camp-local loader here failed the apex render on a front-door
1216 // machine that was declared all along — in the owner's tree, which the
1217 // link names. The inventory is the one reader every name lookup shares.
1218 let inventory = crate::config::resolve_fleet_inventory(workspace_root)
1219 .context("resolving the fleet inventory to place the apex origins")?;
1220
1221 // R859-F2: no health exclusions on the `yah cloud apply` path, deliberately
1222 // and not as a stub. The confirmed-down fact is a *fleet-runtime* one —
1223 // it comes from a leader's lease hysteresis, gated on a healthy quorum
1224 // (`plan_ingress_owner_effect`) — and `yah cloud apply` is an operator
1225 // running a declarative converge from a laptop, which holds no such view.
1226 // Feeding it a liveness guess here would let a laptop with a flaky uplink
1227 // withdraw a live origin. The exclusion set enters through the effector
1228 // that *has* the fact; this path renders the declaration as declared.
1229 //
1230 // The source accounting rides along on the error rather than the success
1231 // path: "no such machine" reads identically whether this camp declared no
1232 // link, aimed one at a directory that is not a camp, or filtered the
1233 // machine out with `select`, and the operator needs to know which.
1234 let resolved =
1235 public_origins(&front_door_machines, &inventory.machines, &[]).map_err(|e| {
1236 let sources = inventory.describe_sources();
1237 if sources.is_empty() {
1238 e
1239 } else {
1240 e.context(sources)
1241 }
1242 })?;
1243 plan_domain_passway(domain, resolved, origins_complete).map(Some)
1244}
1245
1246#[cfg(test)]
1247mod tests {
1248 use super::parent_zone_name;
1249
1250 #[test]
1251 fn parent_zone_strips_one_label_off_subdomain() {
1252 assert_eq!(parent_zone_name("cdn.yah.dev"), "yah.dev");
1253 assert_eq!(parent_zone_name("app.yah.dev"), "yah.dev");
1254 }
1255
1256 #[test]
1257 fn parent_zone_returns_self_for_apex() {
1258 assert_eq!(parent_zone_name("yah.dev"), "yah.dev");
1259 }
1260
1261 #[test]
1262 fn parent_zone_strips_only_first_label_for_deeper_subdomain() {
1263 // Two-label heuristic: yah-side zones are all two-label apexes today.
1264 assert_eq!(parent_zone_name("a.b.yah.dev"), "yah.dev");
1265 }
1266
1267 use super::{plan_domain_worker, DomainWorkerPlan};
1268 use crate::config::{DomainConfig, DomainRoute, FrontDoor, RouteMode};
1269
1270 fn net_tier_manifest() -> DomainConfig {
1271 // Mirrors .yah/domains/scrabcake-net-yah-dev.toml.
1272 DomainConfig {
1273 schema_version: 1,
1274 name: "scrabcake-net-yah-dev".into(),
1275 domain: "scrabcake.net.yah.dev".into(),
1276 front_door: FrontDoor::Worker,
1277 cdn_bucket: "net-yah-dev".into(),
1278 worker_bundle_path: None,
1279 routes: vec![DomainRoute {
1280 headers: Default::default(),
1281 path: "/*".into(),
1282 mode: RouteMode::Static {
1283 component: "scrabcake/site".into(),
1284 },
1285 }],
1286 }
1287 }
1288
1289 #[test]
1290 fn plan_resolves_worker_name_domain_and_asset_origin() {
1291 let plan =
1292 plan_domain_worker(&net_tier_manifest(), "https://cdn.net.yah.dev", "cloud").unwrap();
1293 assert_eq!(
1294 plan,
1295 DomainWorkerPlan {
1296 worker_name: "scrabcake-net-yah-dev".into(),
1297 custom_domain: "scrabcake.net.yah.dev".into(),
1298 asset_origin: "https://cdn.net.yah.dev/scrabcake/cloud".into(),
1299 bindings: vec![
1300 (
1301 "ASSET_ORIGIN".into(),
1302 "https://cdn.net.yah.dev/scrabcake/cloud".into()
1303 ),
1304 ("UPLOAD_ORIGIN".into(), String::new()),
1305 ("WORKER_MODE".into(), "static".into()),
1306 ("SSR_ORIGIN".into(), String::new()),
1307 ("SSR_PREFIXES".into(), "[]".into()),
1308 ("ROUTE_HEADERS".into(), "[]".into()),
1309 ],
1310 }
1311 );
1312 }
1313
1314 /// R746: headers declared on a route reach the deployed Worker as the
1315 /// ROUTE_HEADERS binding. Without this the manifest could declare them and
1316 /// the Worker would serve without them — the exact silent gap the primitive
1317 /// exists to close.
1318 #[test]
1319 fn plan_carries_declared_route_headers_into_the_bindings() {
1320 let mut dom = net_tier_manifest();
1321 dom.routes[0].headers = [
1322 ("Cross-Origin-Opener-Policy".to_string(), "same-origin".to_string()),
1323 (
1324 "Cross-Origin-Embedder-Policy".to_string(),
1325 "require-corp".to_string(),
1326 ),
1327 ]
1328 .into_iter()
1329 .collect();
1330 let plan = plan_domain_worker(&dom, "https://cdn.net.yah.dev", "cloud").unwrap();
1331 let binding = plan
1332 .bindings
1333 .iter()
1334 .find(|(k, _)| k == "ROUTE_HEADERS")
1335 .expect("ROUTE_HEADERS binding");
1336 assert!(binding.1.contains("same-origin"), "{}", binding.1);
1337 assert!(binding.1.contains("require-corp"), "{}", binding.1);
1338 assert!(binding.1.contains("/*"), "{}", binding.1);
1339 }
1340
1341 #[test]
1342 fn plan_trims_trailing_slash_on_cdn_base() {
1343 let plan =
1344 plan_domain_worker(&net_tier_manifest(), "https://cdn.net.yah.dev/", "cloud").unwrap();
1345 assert_eq!(plan.asset_origin, "https://cdn.net.yah.dev/scrabcake/cloud");
1346 }
1347
1348 /// R594-F12: a manifest that declares a different front door must not
1349 /// get a Cloudflare Worker synthesized behind its back — the Worker
1350 /// would deploy and then never receive traffic.
1351 #[test]
1352 fn plan_bails_when_front_door_is_not_worker() {
1353 for door in [FrontDoor::BucketDirect, FrontDoor::Passway] {
1354 let mut dom = net_tier_manifest();
1355 dom.front_door = door;
1356 let err = plan_domain_worker(&dom, "https://cdn.net.yah.dev", "cloud").unwrap_err();
1357 let msg = format!("{err:#}");
1358 assert!(msg.contains("front_door"), "{msg}");
1359 assert!(msg.contains(door.as_str()), "{msg}");
1360 }
1361 }
1362
1363 #[test]
1364 fn plan_bails_when_no_static_route() {
1365 let mut dom = net_tier_manifest();
1366 dom.routes = vec![DomainRoute {
1367 headers: Default::default(),
1368 path: "/old".into(),
1369 mode: RouteMode::Redirect {
1370 target: "https://elsewhere".into(),
1371 status: 308,
1372 },
1373 }];
1374 let err = plan_domain_worker(&dom, "https://cdn.net.yah.dev", "cloud").unwrap_err();
1375 assert!(
1376 format!("{err:#}").contains("no `static` route"),
1377 "got: {err:#}"
1378 );
1379 }
1380
1381 // ── R561-F4: alias-tier registration ──
1382 use super::{plan_alias_claim, valid_subdomain_label, AliasTier};
1383 use std::collections::BTreeMap;
1384
1385 #[test]
1386 fn label_validation_rules() {
1387 assert!(valid_subdomain_label("scrabcake"));
1388 assert!(valid_subdomain_label("my-repo-1"));
1389 assert!(!valid_subdomain_label("")); // empty
1390 assert!(!valid_subdomain_label("-lead")); // leading hyphen
1391 assert!(!valid_subdomain_label("trail-")); // trailing hyphen
1392 assert!(!valid_subdomain_label("Caps")); // uppercase
1393 assert!(!valid_subdomain_label("under_score")); // underscore
1394 }
1395
1396 #[test]
1397 fn claim_builds_net_tier_manifest() {
1398 let dom = plan_alias_claim(
1399 AliasTier::Net,
1400 "scrabcake",
1401 "scrabcake/site",
1402 &BTreeMap::new(),
1403 )
1404 .unwrap();
1405 assert_eq!(dom.name, "scrabcake-net-yah-dev");
1406 assert_eq!(dom.domain, "scrabcake.net.yah.dev");
1407 assert_eq!(dom.cdn_bucket, "net-yah-dev");
1408 assert_eq!(dom.routes.len(), 1);
1409 assert!(
1410 matches!(&dom.routes[0].mode, RouteMode::Static { component } if component == "scrabcake/site")
1411 );
1412 }
1413
1414 #[test]
1415 fn claim_uses_com_tier_zone_and_bucket() {
1416 let dom = plan_alias_claim(AliasTier::Com, "acme", "acme/site", &BTreeMap::new()).unwrap();
1417 assert_eq!(dom.domain, "acme.com.yah.dev");
1418 assert_eq!(dom.cdn_bucket, "com-yah-dev");
1419 }
1420
1421 #[test]
1422 fn claim_bails_on_duplicate_host() {
1423 let existing: BTreeMap<String, DomainConfig> =
1424 [("scrabcake-net-yah-dev".to_string(), net_tier_manifest())]
1425 .into_iter()
1426 .collect();
1427 let err =
1428 plan_alias_claim(AliasTier::Net, "scrabcake", "scrabcake/site", &existing).unwrap_err();
1429 assert!(format!("{err:#}").contains("already"), "got: {err:#}");
1430 }
1431
1432 #[test]
1433 fn claim_bails_on_invalid_label() {
1434 let err =
1435 plan_alias_claim(AliasTier::Net, "Bad_Name", "x/y", &BTreeMap::new()).unwrap_err();
1436 assert!(
1437 format!("{err:#}").contains("invalid subdomain"),
1438 "got: {err:#}"
1439 );
1440 }
1441
1442 // ── R859-F1: sovereign apex (front_door = "passway") ──
1443 //
1444 // Everything here is the pure half. The blast radius of this arm is LIVE
1445 // DNS on a public apex, so the decision logic is exercised offline in full
1446 // and `deploy_domain_passway` only executes the diff these produce.
1447
1448 use super::{
1449 diff_apex_records, plan_domain_passway, plan_passway_apex, public_origins, ApexRecordDiff,
1450 DomainPasswayPlan, LiveApexRecord, PasswayOrigin, ResolvedOrigins,
1451 };
1452 use crate::config::{ConnectSpec, MachineConfig};
1453 use std::net::Ipv4Addr;
1454
1455 /// Mirrors `.yah/domains/yah-dev.toml` — the only `front_door = "passway"`
1456 /// manifest in the camp today.
1457 fn passway_manifest() -> DomainConfig {
1458 DomainConfig {
1459 schema_version: 1,
1460 name: "yah-dev".into(),
1461 domain: "yah.dev".into(),
1462 front_door: FrontDoor::Passway,
1463 cdn_bucket: "yah-dev".into(),
1464 worker_bundle_path: None,
1465 routes: vec![DomainRoute {
1466 headers: Default::default(),
1467 path: "/*".into(),
1468 mode: RouteMode::Static {
1469 component: "yah-marketing/site".into(),
1470 },
1471 }],
1472 }
1473 }
1474
1475 fn machine(name: &str, address: Option<&str>, taints: &[&str]) -> MachineConfig {
1476 MachineConfig {
1477 name: name.into(),
1478 provider: "ovh".into(),
1479 location: None,
1480 server_type: None,
1481 hosts_mirrors: vec![],
1482 mesh_tags: vec![],
1483 region: None,
1484 zone: None,
1485 arch: None,
1486 bucket: None,
1487 vendor: None,
1488 nickname: None,
1489 legacy_hostkey_fingerprint: None,
1490 registration: Default::default(),
1491 ssh_keys: vec![],
1492 cloudflared: None,
1493 hosts_operator_bridge: false,
1494 connect: address.map(|a| ConnectSpec {
1495 address: a.into(),
1496 ssh: format!("root@{a}"),
1497 identity_file: "~/.ssh/yah".into(),
1498 yubaba_port: None,
1499 yubaba: None,
1500 }),
1501 allocatable: None,
1502 taints: taints.iter().map(|t| t.to_string()).collect(),
1503 sovereign_group: None,
1504 sovereign_role: None,
1505 ingress_floating_ip: None,
1506 }
1507 }
1508
1509 /// The live fleet shape: us-east-001 and us-west-001 both carry the
1510 /// `public-ip` taint with a public address (machines/*.toml), a third box
1511 /// fronts internal hostnames over the mesh and must not reach the apex.
1512 fn fleet() -> Vec<MachineConfig> {
1513 vec![
1514 machine("us-east-001", Some("51.81.85.145"), &["public-ip"]),
1515 machine("us-west-001", Some("15.204.89.240"), &["public-ip"]),
1516 machine("us-west-002", Some("100.64.0.4"), &[]),
1517 ]
1518 }
1519
1520 fn origins(names: &[&str]) -> ResolvedOrigins {
1521 origins_excluding(names, &[])
1522 }
1523
1524 /// R859-F2: the same resolution with `down` confirmed dead — declared and
1525 /// resolvable, but held out of the published set.
1526 fn origins_excluding(names: &[&str], down: &[&str]) -> ResolvedOrigins {
1527 let fleet = fleet();
1528 let names: Vec<String> = names.iter().map(|n| n.to_string()).collect();
1529 let down: Vec<String> = down.iter().map(|n| n.to_string()).collect();
1530 public_origins(&names, &fleet, &down).unwrap()
1531 }
1532
1533 /// A plan whose origin set is COMPLETE — the clean-collation case.
1534 fn plan_of(names: &[&str]) -> DomainPasswayPlan {
1535 plan_domain_passway(&passway_manifest(), origins(names), true).unwrap()
1536 }
1537
1538 /// The same plan, but built from a collation that reported problems, so an
1539 /// origin's absence cannot be read as a withdrawal.
1540 fn plan_of_incomplete(names: &[&str]) -> DomainPasswayPlan {
1541 plan_domain_passway(&passway_manifest(), origins(names), false).unwrap()
1542 }
1543
1544 fn live(records: &[(&str, bool)]) -> Vec<LiveApexRecord> {
1545 records
1546 .iter()
1547 .map(|(content, proxied)| LiveApexRecord {
1548 content: (*content).into(),
1549 proxied: *proxied,
1550 })
1551 .collect()
1552 }
1553
1554 #[test]
1555 fn public_origins_keeps_tainted_machines_and_skips_the_rest() {
1556 let got = origins(&["us-east-001", "us-west-001", "us-west-002"]);
1557 assert!(
1558 got.health_withdrawn.is_empty(),
1559 "no exclusions were passed, so nothing may be withheld"
1560 );
1561 assert_eq!(
1562 got.origins,
1563 vec![
1564 PasswayOrigin {
1565 machine: "us-east-001".into(),
1566 address: Ipv4Addr::new(51, 81, 85, 145),
1567 },
1568 PasswayOrigin {
1569 machine: "us-west-001".into(),
1570 address: Ipv4Addr::new(15, 204, 89, 240),
1571 },
1572 ],
1573 "an untainted mesh-only front door is not an apex origin"
1574 );
1575 }
1576
1577 /// The publicness cross-check nothing did before R859-F1: the `public-ip`
1578 /// taint and `connect.address` are independent declarations, and a machine
1579 /// dialled over its tailnet address would otherwise publish `100.64.0.x`
1580 /// at a public apex and black-hole its share of the round-robin.
1581 #[test]
1582 fn public_origins_rejects_a_non_public_address_and_names_the_machine() {
1583 for bad in ["100.64.0.4", "10.0.0.7", "192.168.1.20", "127.0.0.1"] {
1584 let fleet = vec![machine("us-west-002", Some(bad), &["public-ip"])];
1585 let err = public_origins(&["us-west-002".to_string()], &fleet, &[]).unwrap_err();
1586 let msg = format!("{err:#}");
1587 assert!(msg.contains("us-west-002"), "{msg}");
1588 assert!(msg.contains(bad), "{msg}");
1589 assert!(msg.contains("public"), "{msg}");
1590 }
1591 }
1592
1593 #[test]
1594 fn public_origins_rejects_an_unparseable_address_and_names_the_machine() {
1595 let fleet = vec![machine("us-east-001", Some("edge.example.net"), &["public-ip"])];
1596 let err = public_origins(&["us-east-001".to_string()], &fleet, &[]).unwrap_err();
1597 let msg = format!("{err:#}");
1598 assert!(msg.contains("us-east-001"), "{msg}");
1599 assert!(msg.contains("not an IPv4 address"), "{msg}");
1600 }
1601
1602 #[test]
1603 fn public_origins_rejects_a_tainted_machine_with_no_connect_block() {
1604 let fleet = vec![machine("us-east-001", None, &["public-ip"])];
1605 let err = public_origins(&["us-east-001".to_string()], &fleet, &[]).unwrap_err();
1606 let msg = format!("{err:#}");
1607 assert!(msg.contains("us-east-001"), "{msg}");
1608 assert!(msg.contains("[connect]"), "{msg}");
1609 }
1610
1611 #[test]
1612 fn public_origins_rejects_a_machine_with_no_toml() {
1613 let err = public_origins(&["ghost-001".to_string()], &fleet(), &[]).unwrap_err();
1614 let msg = format!("{err:#}");
1615 assert!(msg.contains("ghost-001"), "{msg}");
1616 }
1617
1618 #[test]
1619 fn plan_sorts_and_dedups_origins_and_resolves_the_apex_zone() {
1620 let mut o = origins(&["us-west-001", "us-east-001"]);
1621 o.origins.push(o.origins[0].clone()); // same machine collated twice
1622 let plan = plan_domain_passway(&passway_manifest(), o, true).unwrap();
1623 assert_eq!(plan.zone, "yah.dev");
1624 assert_eq!(plan.name, "yah.dev");
1625 assert_eq!(
1626 plan.origins
1627 .iter()
1628 .map(|o| o.address.to_string())
1629 .collect::<Vec<_>>(),
1630 vec!["15.204.89.240", "51.81.85.145"],
1631 );
1632 }
1633
1634 /// A camp may declare `front_door = "passway"` BEFORE the passway edge
1635 /// exists — the field is how you say which door you intend, and R859-F1
1636 /// made it the mechanism as well, so it gets written first. The noisetable
1637 /// camp is exactly this: `noisetable.com` declares `passway`, its only
1638 /// ingress edge is a `cloudflare-tunnel` on a different domain, and its
1639 /// apex is deliberately NXDOMAIN pending a node of its own (passway serves
1640 /// one cert per listener — R777).
1641 ///
1642 /// That must plan to `None`, never to an `Err`. The domain loop in `yah
1643 /// cloud apply` bails at the first domain failure without
1644 /// `--continue-on-error`, so an error here would take down the publish
1645 /// chain of every service in the camp over a door nobody has built yet.
1646 #[test]
1647 fn a_passway_domain_with_no_ingress_edge_plans_to_none_rather_than_erroring() {
1648 let dir = tempfile::tempdir().unwrap();
1649 let root = dir.path();
1650 // A workspace with a services tree but nothing fronting anything —
1651 // the shape of a camp whose door is declared but not stood up.
1652 std::fs::create_dir_all(root.join(".yah/services")).unwrap();
1653
1654 let planned = plan_passway_apex(root, &passway_manifest())
1655 .expect("an undeclared door is not an error");
1656 assert!(
1657 planned.is_none(),
1658 "expected None (nothing to render), got {planned:?}"
1659 );
1660 }
1661
1662 /// R870-B13, end to end: a camp that BORROWS another camp's fleet renders
1663 /// its sovereign apex from the owner's machine declaration.
1664 ///
1665 /// This is the ticket's defect in one test. The borrowing camp's own
1666 /// `.yah/infra/machines/` is empty — it declares one `[[source]]` link in
1667 /// `.yah/infra/sources.toml` and pins its front door by name. Before the
1668 /// fix, `plan_passway_apex` read a camp-local-only loader, so the pinned
1669 /// name resolved against an empty fleet and the render died with "has no
1670 /// .yah/infra/machines/*.toml — cannot resolve its public address" on a
1671 /// machine that was declared all along, one directory over.
1672 ///
1673 /// The camp-local control assertion is what makes this non-vacuous: the
1674 /// borrowing camp genuinely holds no copy of the inventory, so a pass here
1675 /// can only come from the link being followed.
1676 #[test]
1677 fn a_borrowing_camp_renders_its_apex_from_the_owners_machine_declaration() {
1678 let dir = tempfile::tempdir().unwrap();
1679
1680 // The owner camp — the ONE copy of the inventory.
1681 let owner = dir.path().join("owner");
1682 std::fs::create_dir_all(owner.join(".yah/infra/machines")).unwrap();
1683 std::fs::write(
1684 owner.join(".yah/infra/machines/us-east-001.toml"),
1685 "name = \"us-east-001\"\nprovider = \"ovh\"\nmesh_tags = []\n\
1686 taints = [\"public-ip\"]\n\
1687 [connect]\naddress = \"51.81.85.145\"\nssh = \"root@51.81.85.145\"\n\
1688 identity_file = \"~/.ssh/yah\"\n",
1689 )
1690 .unwrap();
1691
1692 // The borrowing camp: empty machines dir, one link, one pinned edge.
1693 let borrower = dir.path().join("borrower");
1694 std::fs::create_dir_all(borrower.join(".yah/infra/machines")).unwrap();
1695 std::fs::write(
1696 borrower.join(".yah/infra/sources.toml"),
1697 "schema_version = 1\n[[source]]\nowner = \"owner\"\nkind = \"path\"\n\
1698 path = \"../owner\"\nmode = \"read-only\"\n",
1699 )
1700 .unwrap();
1701 let svc = borrower.join(".yah/services/marketing");
1702 std::fs::create_dir_all(svc.join("mirrors")).unwrap();
1703 std::fs::write(
1704 svc.join("service.toml"),
1705 "schema_version = 1\nname = \"marketing\"\ndomain = \"yah.dev\"\n\
1706 [[components]]\nid = \"site\"\nkind = \"static-asset\"\n\
1707 path = \"marketing/site\"\nrole = \"static\"\n",
1708 )
1709 .unwrap();
1710 std::fs::write(
1711 svc.join("mirrors/cloud.toml"),
1712 "schema_version = 1\nshape = \"single-machine\"\n\
1713 ingress = \"passway\"\ningress_machines = [\"us-east-001\"]\n\
1714 [providers.compute]\nuse = \"hetzner\"\nzone = \"yah.dev\"\n\
1715 port = 8080\nupstream_host = \"100.64.0.5\"\n",
1716 )
1717 .unwrap();
1718
1719 // Control: the camp holds no machine declaration of its own.
1720 assert!(crate::validate::load_camp_local_machine_tomls(&borrower)
1721 .unwrap()
1722 .is_empty());
1723
1724 let plan = plan_passway_apex(&borrower, &passway_manifest())
1725 .expect("a borrowed front-door machine must resolve")
1726 .expect("the passway edge collates, so this is not the no-door case");
1727 assert_eq!(
1728 plan.origins
1729 .iter()
1730 .map(|o| o.address.to_string())
1731 .collect::<Vec<_>>(),
1732 vec!["51.81.85.145".to_string()],
1733 );
1734 }
1735
1736 /// The failure that remains a failure, with the diagnosis attached: a
1737 /// pinned machine no linked source supplies must still error — and the
1738 /// error must name the links that were consulted, since "no such machine"
1739 /// alone cannot tell an undeclared link from a broken one.
1740 #[test]
1741 fn an_unresolvable_front_door_names_the_links_that_were_consulted() {
1742 let dir = tempfile::tempdir().unwrap();
1743 let borrower = dir.path().join("borrower");
1744 std::fs::create_dir_all(borrower.join(".yah/infra/machines")).unwrap();
1745 std::fs::write(
1746 borrower.join(".yah/infra/sources.toml"),
1747 "schema_version = 1\n[[source]]\nowner = \"owner\"\nkind = \"path\"\n\
1748 path = \"../not-a-camp\"\n",
1749 )
1750 .unwrap();
1751 let svc = borrower.join(".yah/services/marketing");
1752 std::fs::create_dir_all(svc.join("mirrors")).unwrap();
1753 std::fs::write(
1754 svc.join("service.toml"),
1755 "schema_version = 1\nname = \"marketing\"\ndomain = \"yah.dev\"\n\
1756 [[components]]\nid = \"site\"\nkind = \"static-asset\"\n\
1757 path = \"marketing/site\"\nrole = \"static\"\n",
1758 )
1759 .unwrap();
1760 std::fs::write(
1761 svc.join("mirrors/cloud.toml"),
1762 "schema_version = 1\nshape = \"single-machine\"\n\
1763 ingress = \"passway\"\ningress_machines = [\"us-east-001\"]\n\
1764 [providers.compute]\nuse = \"hetzner\"\nzone = \"yah.dev\"\n\
1765 port = 8080\nupstream_host = \"100.64.0.5\"\n",
1766 )
1767 .unwrap();
1768
1769 let err = plan_passway_apex(&borrower, &passway_manifest()).unwrap_err();
1770 let msg = format!("{err:#}");
1771 assert!(msg.contains("us-east-001"), "{msg}");
1772 assert!(msg.contains("sources.toml"), "{msg}");
1773 assert!(msg.contains("owner"), "{msg}");
1774 assert!(msg.contains("ABSENT"), "{msg}");
1775 }
1776
1777 /// The other side of that discriminator, and the reason it is drawn at the
1778 /// COLLATION rather than at the resolved-address set: a machine that IS
1779 /// declared as a front door but yields no public address is a
1780 /// misconfiguration of an intended door, and stays an error.
1781 #[test]
1782 fn a_declared_front_door_that_resolves_to_no_public_address_still_errors() {
1783 // Declared as a front door, but carries no `public-ip` taint — so the
1784 // taint filter empties the set even though the edge exists.
1785 let machines = vec![machine("us-east-001", Some("51.81.85.145"), &[])];
1786 let resolved = public_origins(&["us-east-001".to_string()], &machines, &[]).unwrap();
1787 let err = plan_domain_passway(&passway_manifest(), resolved, true).unwrap_err();
1788 let msg = format!("{err:#}");
1789 assert!(msg.contains("empty apex"), "{msg}");
1790 }
1791
1792 /// The live-DNS blast radius this arm exists to bound: an empty desired
1793 /// set is what the applier prunes against, so rendering it would withdraw
1794 /// every A record at the apex. Nothing about "no machine declared it"
1795 /// means "take the site down" — it must be an error, not a wipe.
1796 #[test]
1797 fn empty_origin_set_is_an_error_not_an_apex_wipe() {
1798 let err = plan_domain_passway(&passway_manifest(), ResolvedOrigins::default(), true).unwrap_err();
1799 let msg = format!("{err:#}");
1800 assert!(msg.contains("refusing to render an empty apex"), "{msg}");
1801 assert!(msg.contains("yah.dev"), "{msg}");
1802 }
1803
1804 #[test]
1805 fn plan_bails_when_front_door_is_not_passway() {
1806 for door in [FrontDoor::BucketDirect, FrontDoor::Worker] {
1807 let mut dom = passway_manifest();
1808 dom.front_door = door;
1809 let err = plan_domain_passway(&dom, origins(&["us-east-001"]), true).unwrap_err();
1810 let msg = format!("{err:#}");
1811 assert!(msg.contains("front_door"), "{msg}");
1812 assert!(msg.contains(door.as_str()), "{msg}");
1813 }
1814 }
1815
1816 #[test]
1817 fn converged_apex_is_a_no_op() {
1818 let plan = plan_of(&["us-east-001", "us-west-001"]);
1819 let diff = diff_apex_records(
1820 &plan,
1821 &live(&[("51.81.85.145", false), ("15.204.89.240", false)]),
1822 );
1823 assert_eq!(diff, ApexRecordDiff::default());
1824 assert!(diff.is_converged());
1825 }
1826
1827 #[test]
1828 fn adding_a_machine_yields_exactly_one_added_record_and_no_prune() {
1829 let plan = plan_of(&["us-east-001", "us-west-001"]);
1830 let diff = diff_apex_records(&plan, &live(&[("51.81.85.145", false)]));
1831 assert_eq!(diff.upsert, vec!["15.204.89.240".to_string()]);
1832 assert!(diff.prune.is_empty(), "{diff:?}");
1833 }
1834
1835 #[test]
1836 fn removing_a_machine_yields_exactly_one_prune_and_leaves_the_survivor() {
1837 let plan = plan_of(&["us-east-001"]);
1838 let diff = diff_apex_records(
1839 &plan,
1840 &live(&[("51.81.85.145", false), ("15.204.89.240", false)]),
1841 );
1842 assert_eq!(diff.prune, vec!["15.204.89.240".to_string()]);
1843 assert!(
1844 diff.upsert.is_empty(),
1845 "the survivor is already correct: {diff:?}"
1846 );
1847 }
1848
1849 /// `scripts/cf-apex-mode.sh orange` is break-glass; the manifest declares
1850 /// DNS-only. A proxied record carrying a desired address is therefore
1851 /// drift, and re-upserting it (`proxied = false`) is the convergence.
1852 #[test]
1853 fn a_proxied_record_at_a_desired_address_is_rewritten_not_left_alone() {
1854 let plan = plan_of(&["us-east-001"]);
1855 let diff = diff_apex_records(&plan, &live(&[("51.81.85.145", true)]));
1856 assert_eq!(diff.upsert, vec!["51.81.85.145".to_string()]);
1857 assert!(
1858 diff.prune.is_empty(),
1859 "the address is declared — it must not be pruned: {diff:?}"
1860 );
1861 }
1862
1863 /// **Fail-closed on withdrawal.** `collate_workspace_ingress` returns `Ok`
1864 /// while SKIPPING an edge whose declaration fails to plan, so a passway
1865 /// edge with a config typo silently drops its machine from `front_doors` —
1866 /// which, from inside the plan, is indistinguishable from the operator
1867 /// withdrawing that node. Without this gate a typo would render as a DNS
1868 /// withdrawal and take that origin offline. Upserts must still run: an
1869 /// unrelated service's broken declaration cannot be allowed to block
1870 /// growing the fleet, and an upsert can never make the apex worse.
1871 #[test]
1872 fn an_incomplete_collation_upserts_but_withholds_every_prune() {
1873 // us-west-001 is missing from the front-door set, and the collation
1874 // reported problems — so its absence is not trustworthy evidence of a
1875 // withdrawal. us-east-001 is newly declared and must still be written.
1876 let plan = plan_of_incomplete(&["us-east-001"]);
1877 let diff = diff_apex_records(&plan, &live(&[("15.204.89.240", false)]));
1878
1879 assert_eq!(
1880 diff.upsert,
1881 vec!["51.81.85.145".to_string()],
1882 "additions must still land through a dirty collation"
1883 );
1884 assert!(
1885 diff.prune.is_empty(),
1886 "a live record must never be withdrawn on an incomplete collation: {diff:?}"
1887 );
1888 assert_eq!(
1889 diff.withheld_prune,
1890 vec!["15.204.89.240".to_string()],
1891 "the withheld record is reported so the applier can name it"
1892 );
1893
1894 // Same inputs, clean collation: the prune is real. This is the
1895 // control that proves the gate is what changed the outcome.
1896 let trusted = plan_of(&["us-east-001"]);
1897 let diff = diff_apex_records(&trusted, &live(&[("15.204.89.240", false)]));
1898 assert_eq!(diff.prune, vec!["15.204.89.240".to_string()]);
1899 assert!(diff.withheld_prune.is_empty(), "{diff:?}");
1900 }
1901
1902 /// The empty-set guard stays AHEAD of the incompleteness gate: an empty
1903 /// origin set is unusable whether or not the collation was clean, so it
1904 /// remains the louder failure rather than degrading into a silent
1905 /// everything-withheld apply.
1906 #[test]
1907 fn empty_origin_set_still_errors_on_an_incomplete_collation() {
1908 let err = plan_domain_passway(&passway_manifest(), ResolvedOrigins::default(), false).unwrap_err();
1909 assert!(
1910 format!("{err:#}").contains("refusing to render an empty apex"),
1911 "got: {err:#}"
1912 );
1913 }
1914
1915 // ── R859-F2: health-excluded origins ──────────────────────────────────
1916
1917 /// The exclusion happens after full resolution, and reports what it held
1918 /// back rather than dropping it silently — `health_withdrawn` is what makes
1919 /// "declared but dead" distinguishable from "never declared" downstream.
1920 #[test]
1921 fn a_confirmed_down_machine_is_resolved_then_withheld_not_dropped() {
1922 let got = origins_excluding(&["us-east-001", "us-west-001"], &["us-east-001"]);
1923 assert_eq!(
1924 got.origins,
1925 vec![PasswayOrigin {
1926 machine: "us-west-001".into(),
1927 address: Ipv4Addr::new(15, 204, 89, 240),
1928 }]
1929 );
1930 assert_eq!(
1931 got.health_withdrawn,
1932 vec![PasswayOrigin {
1933 machine: "us-east-001".into(),
1934 address: Ipv4Addr::new(51, 81, 85, 145),
1935 }],
1936 "a withheld origin must still be reported, with the address it would have published"
1937 );
1938 }
1939
1940 /// An exclusion naming a machine that fronts nothing is not an error. The
1941 /// caller's liveness view covers the whole fleet and most of it never
1942 /// fronts anything, so requiring the sets to line up would make every
1943 /// unrelated node failure an apex-render failure.
1944 #[test]
1945 fn excluding_a_machine_that_fronts_nothing_is_a_no_op() {
1946 let got = origins_excluding(&["us-east-001"], &["us-west-001", "ghost-001"]);
1947 assert_eq!(got.origins.len(), 1);
1948 assert!(got.health_withdrawn.is_empty());
1949 }
1950
1951 /// The catastrophic case, and the reason the empty-set guard is checked
1952 /// against the *survivors*: "every front door is down" must not render as
1953 /// "withdraw every A record". A dead origin still in DNS is a partial
1954 /// outage; an empty apex is a total one.
1955 #[test]
1956 fn excluding_every_origin_is_an_error_not_an_apex_wipe() {
1957 let resolved = origins_excluding(
1958 &["us-east-001", "us-west-001"],
1959 &["us-east-001", "us-west-001"],
1960 );
1961 assert_eq!(resolved.health_withdrawn.len(), 2);
1962 let err = plan_domain_passway(&passway_manifest(), resolved, true).unwrap_err();
1963 assert!(
1964 format!("{err:#}").contains("refusing to render an empty apex"),
1965 "got: {err:#}"
1966 );
1967 }
1968
1969 /// Two machines can share one address (a floating IP mid-move, or two edges
1970 /// collated onto one box). Losing one of them must not withdraw a record
1971 /// the other still answers on.
1972 #[test]
1973 fn an_address_a_live_origin_still_serves_is_not_withdrawn() {
1974 let fleet = vec![
1975 machine("us-east-001", Some("51.81.85.145"), &["public-ip"]),
1976 machine("us-east-002", Some("51.81.85.145"), &["public-ip"]),
1977 ];
1978 let resolved = public_origins(
1979 &["us-east-001".to_string(), "us-east-002".to_string()],
1980 &fleet,
1981 &["us-east-001".to_string()],
1982 )
1983 .unwrap();
1984 let plan = plan_domain_passway(&passway_manifest(), resolved, true).unwrap();
1985 assert_eq!(
1986 plan.origins
1987 .iter()
1988 .map(|o| o.address.to_string())
1989 .collect::<Vec<_>>(),
1990 vec!["51.81.85.145"]
1991 );
1992 assert!(
1993 plan.health_withdrawn.is_empty(),
1994 "us-east-002 still answers on that address, so it must not be pruned"
1995 );
1996 }
1997
1998 /// **The cross-product.** `origins_complete` and `health_withdrawn` are two
1999 /// different facts, and the bottom-right cell is the whole point of R859-F2:
2000 /// an unrelated service's broken declaration must not veto a withdrawal
2001 /// resting on a box we watched go down.
2002 #[test]
2003 fn health_withdrawal_and_declaration_completeness_are_independent() {
2004 let live = live(&[("51.81.85.145", false), ("15.204.89.240", false)]);
2005
2006 // complete + healthy: an ordinary withdrawal from a trusted declaration.
2007 let complete_healthy = plan_domain_passway(
2008 &passway_manifest(),
2009 origins(&["us-west-001"]),
2010 true,
2011 )
2012 .unwrap();
2013 let d = diff_apex_records(&complete_healthy, &live);
2014 assert_eq!(d.prune, vec!["51.81.85.145"]);
2015 assert!(d.withheld_prune.is_empty());
2016
2017 // complete + down: the same prune, now on health grounds.
2018 let complete_down = plan_domain_passway(
2019 &passway_manifest(),
2020 origins_excluding(&["us-east-001", "us-west-001"], &["us-east-001"]),
2021 true,
2022 )
2023 .unwrap();
2024 let d = diff_apex_records(&complete_down, &live);
2025 assert_eq!(d.prune, vec!["51.81.85.145"]);
2026 assert!(d.withheld_prune.is_empty());
2027
2028 // incomplete + healthy: might be a withdrawal, might be a typo — withheld.
2029 let incomplete_healthy = plan_domain_passway(
2030 &passway_manifest(),
2031 origins(&["us-west-001"]),
2032 false,
2033 )
2034 .unwrap();
2035 let d = diff_apex_records(&incomplete_healthy, &live);
2036 assert!(
2037 d.prune.is_empty(),
2038 "an absence under an incomplete collation must never prune"
2039 );
2040 assert_eq!(d.withheld_prune, vec!["51.81.85.145"]);
2041
2042 // incomplete + down: PRUNED ANYWAY. We saw this machine declared and we
2043 // saw it die; the incompleteness is about a different declaration.
2044 let incomplete_down = plan_domain_passway(
2045 &passway_manifest(),
2046 origins_excluding(&["us-east-001", "us-west-001"], &["us-east-001"]),
2047 false,
2048 )
2049 .unwrap();
2050 let d = diff_apex_records(&incomplete_down, &live);
2051 assert_eq!(
2052 d.prune,
2053 vec!["51.81.85.145"],
2054 "a health withdrawal rests on a positive observation, not on an absence, so \
2055 origins_complete = false must not suppress it"
2056 );
2057 assert!(d.withheld_prune.is_empty());
2058 }
2059
2060 /// The two reasons for a prune coexist in one diff without merging: under an
2061 /// incomplete collation, the health-excluded address is pruned and the
2062 /// merely-absent one is still withheld.
2063 #[test]
2064 fn an_incomplete_collation_prunes_only_the_health_withdrawn_surplus() {
2065 let plan = plan_domain_passway(
2066 &passway_manifest(),
2067 origins_excluding(&["us-east-001", "us-west-001"], &["us-east-001"]),
2068 false,
2069 )
2070 .unwrap();
2071 let d = diff_apex_records(
2072 &plan,
2073 &live(&[
2074 ("51.81.85.145", false), // declared + confirmed down -> prune
2075 ("15.204.89.240", false), // the surviving origin -> kept
2076 ("203.0.113.9", false), // never declared at all -> withheld
2077 ]),
2078 );
2079 assert_eq!(d.prune, vec!["51.81.85.145"]);
2080 assert_eq!(d.withheld_prune, vec!["203.0.113.9"]);
2081 assert!(d.upsert.is_empty());
2082 }
2083
2084 /// A withheld prune is not convergence — it is a write the applier
2085 /// declined — but it is also not a write, so `is_converged` stays false
2086 /// only when there is something to actually do.
2087 #[test]
2088 fn a_withheld_prune_alone_leaves_nothing_to_write() {
2089 let plan = plan_of_incomplete(&["us-east-001"]);
2090 let diff = diff_apex_records(
2091 &plan,
2092 &live(&[("51.81.85.145", false), ("15.204.89.240", false)]),
2093 );
2094 assert!(diff.upsert.is_empty(), "{diff:?}");
2095 assert!(diff.prune.is_empty(), "{diff:?}");
2096 assert!(diff.is_converged(), "no write to make: {diff:?}");
2097 assert_eq!(diff.withheld_prune, vec!["15.204.89.240".to_string()]);
2098 }
2099
2100 /// Ordering contract (`cf-apex-mode.sh:245-246,287-288`): the applier
2101 /// writes `upsert` before `prune`, so a full origin swap never leaves the
2102 /// apex without a routing record.
2103 #[test]
2104 fn a_full_origin_swap_writes_the_new_record_before_pruning_the_old() {
2105 let plan = plan_of(&["us-west-001"]);
2106 let diff = diff_apex_records(&plan, &live(&[("51.81.85.145", false)]));
2107 assert_eq!(diff.upsert, vec!["15.204.89.240".to_string()]);
2108 assert_eq!(diff.prune, vec!["51.81.85.145".to_string()]);
2109 assert!(!diff.is_converged());
2110 }
2111}