cloud/inner_door.rs
1//! The **inner door** planner (R870-F23) — the `service.toml` +
2//! domain-manifest join that produces a passway `PASSWAY_PATH_ROUTES_FILE`.
3//!
4//! ## What an inner door is, and what it is not
5//!
6//! R870-F15 landed path routing in passway; R870-T18 gave it a config surface
7//! (a JSON mount table named by `PASSWAY_PATH_ROUTES_FILE`). Both are the
8//! *consumer*. This module is the producer: given a service's declared
9//! components and the domain manifest that routes them, it answers "what mount
10//! table does this service's own door need, if any".
11//!
12//! It is an **inner** door because it sits behind the service's public one, on
13//! loopback. The public door owns a hostname and terminates TLS; the inner door
14//! owns one hostname's *paths* and splits them across units that deploy
15//! independently. That split is the only thing it does — and it is the thing
16//! the public door structurally cannot do, because the public tier routes by
17//! SNI/Host and a request's path is not visible until after that.
18//!
19//! ## The join carries no new vocabulary
20//!
21//! R870-F15 claimed the join needs nothing new, and that holds up. Every input
22//! already exists:
23//!
24//! | Field | Source |
25//! |---|---|
26//! | `mount` | [`ServiceComponent::mount`], normalized by [`normalize_mount`] |
27//! | `headers` | the route the domain's table gives that mount's path ([`DomainConfig::route_for_path`]) |
28//! | tier | [`ServiceComponent::deploy`] — the one thing R870-F23 added |
29//! | `upstreams` | placement-time, so it is [`InnerDoorPlan::routes_file`]'s argument, not a config field |
30//!
31//! The mount/route agreement is not re-derived here: [`CloudConfig::cross_ref_validate`]
32//! already *proves* a component's mount and its route's path prefix are the
33//! same string, so the lookup below cannot silently mismatch — a config where
34//! it would have does not load.
35//!
36//! ## Headers come from the ONE route table (R898-F2)
37//!
38//! The header column is not this module's to derive. R898-F1 compiled a
39//! domain's declared routes into one ordered [`RouteTable`], and every tier —
40//! the Worker, mesofact, the outer door's `ROUTE_HEADERS` — answers "what
41//! governs this path" by that table's rule. [`headers_for`] therefore asks the
42//! table's rule too, rather than re-deriving the join; two producers of one
43//! fact is precisely what the R898 relay exists to delete.
44//!
45//! **And this tier is the only one that applies those headers.** Operator call,
46//! 2026-09-12: the outer passway door stays a pure HOST router (path routing and
47//! host routing are mutually exclusive at its boot — `HOST_ROUTING_ENV` in
48//! passway's `main.rs`), so it applies no per-path headers and cannot
49//! double-apply these. That settles R870-F23's open ownership question, and it
50//! is what makes a bundle-tier component at a non-root mount keeping its own
51//! entry here CORRECT rather than redundant: the entry points at the same bundle
52//! upstream as the root and exists purely to carry that mount's headers.
53//! Deleting it as a duplicate would silently strip them.
54//!
55//! [`RouteTable`]: crate::route_table::RouteTable
56//! [`ServiceComponent::mount`]: crate::config::ServiceComponent::mount
57//! [`ServiceComponent::deploy`]: crate::config::ServiceComponent::deploy
58//!
59//! ## The two admission rules
60//!
61//! Both belong here, never to passway: passway proxies whatever `PathRouter`
62//! it is handed and has no view of how many components a service declares.
63//!
64//! 1. **A service with one independently-deployed unit gets no inner tier at
65//! all.** Enforced by construction — [`plan`] answers `Ok(None)` below two
66//! units, so there is no config to write and no process to supervise. That
67//! makes the negative assertable on the *absence* of a plan rather than on
68//! a site staying up, which is the only form of that assertion that can
69//! fail loudly.
70//! 2. **A component cannot be both bundle-staged and its own workload.**
71//! Enforced by [`DeployTier`] being one field with two values rather than
72//! two independent flags: the contradictory state has no spelling. What
73//! remains checkable — that two components do not claim one mount — lives
74//! in `cross_ref_validate`'s existing loop, widened rather than duplicated.
75//!
76//! ## Grouping is by deployed UNIT, not by component
77//!
78//! Every bundle-tier component of a service shares ONE bundle workload (config
79//! 1, R870-B11), so they contribute one upstream between them —
80//! [`DeployedUnit::Bundle`]. They still contribute their own *mounts*, because
81//! a mount is where the bundle stores that component's output
82//! (`app/dist/<mount>/`) and because the domain manifest may give that path
83//! response headers the root does not have. So N bundle components produce N
84//! mounts and one unit, and it is the unit count that rule 1 keys on.
85
86use std::collections::{BTreeMap, HashMap};
87use std::path::{Path, PathBuf};
88
89use anyhow::{bail, Result};
90use serde::Serialize;
91use workload_spec::{
92 EnvValue, EnvVar, ExposeSpec, HealthProbe, Healthcheck, ImageRef, InlineFile,
93 LifecycleArchetype, MeshExpose, MeshIdent, Millis, NamespaceId, ResourceLimits, RestartPolicy,
94 StopPolicy, TenantId, TierTag, Workload, WorkloadSpec,
95};
96
97use crate::config::{
98 domain_serving_service, normalize_mount, DeployTier, DomainConfig, ServiceConfig,
99};
100
101/// `schema_version` of the route table this module writes. Must match
102/// passway's `path_routes_file::SCHEMA_VERSION`; a mismatch is a boot failure
103/// on the door naming both numbers, which is the intended way for a
104/// producer/consumer skew to surface (see that module's doc).
105pub const ROUTES_SCHEMA_VERSION: u32 = 1;
106
107/// Which deployed thing serves a mount.
108///
109/// The distinction the whole module turns on: several components can share one
110/// of these, and rule 1 counts *these*, not components.
111#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
112pub enum DeployedUnit {
113 /// The service's single assembled W272 bundle — every [`DeployTier::Bundle`]
114 /// component, collapsed.
115 Bundle,
116 /// One [`DeployTier::Workload`] component, by component id.
117 Component(String),
118}
119
120/// One mount of an inner door's table, before upstream addresses exist.
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct InnerDoorMount {
123 /// passway's mount spelling: `""` for the service root, otherwise
124 /// `/segment[/segment…]`. The `service.toml` side spells the same mount
125 /// without the leading slash — see [`passway_mount`].
126 pub mount: String,
127 /// What serves it.
128 pub unit: DeployedUnit,
129 /// Response headers the domain manifest gives this path (R746) — the
130 /// answer of the domain's one route table, not a second join. Empty when
131 /// the route governing this mount declares none, or when no route governs
132 /// it at all.
133 ///
134 /// This tier is their sole owner; see the module doc for why the outer door
135 /// cannot double-apply them, and why a bundle sub-mount's entry is not
136 /// redundant.
137 pub headers: BTreeMap<String, String>,
138}
139
140/// A service's inner door, as configuration — everything but the addresses.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct InnerDoorPlan {
143 /// Service name, for error messages and the workload name.
144 pub service: String,
145 /// Mounts in declaration order. Precedence is `PathRouter`'s (longest
146 /// mount wins), not this vector's, so the order is presentational.
147 pub mounts: Vec<InnerDoorMount>,
148}
149
150/// Translate a normalized mount (`""`, `"app"`) to passway's spelling (`""`,
151/// `"/app"`).
152///
153/// The twin of [`normalize_mount`] on the wire side, and deliberately built by
154/// composing with it rather than trimming slashes again: `/app`, `app/` and
155/// `/app/` all mean one mount, and this crate already has exactly one place
156/// that knows so.
157///
158/// passway has its own `path_route::mount_from_component` doing the same job on
159/// the reading side. That is a genuine two-sided format rather than a
160/// duplicated normalizer — passway is a separately released crate with its own
161/// workspace, and yubaba cannot call into it — and it is handled the way the
162/// wire format handles every other such risk: `PathRouter::new` is the ONE
163/// validator of mount well-formedness, so a disagreement here is a loud boot
164/// failure on the door, never a silently mis-served prefix.
165pub fn passway_mount(raw: Option<&str>) -> String {
166 match raw.map(normalize_mount) {
167 Some(m) if !m.is_empty() => format!("/{m}"),
168 _ => String::new(),
169 }
170}
171
172/// Plan the inner door for one service, or answer `None` when it should not
173/// have one.
174///
175/// `None` is rule 1 and is the common answer: a service whose components all
176/// ship in one bundle has a single upstream, and a proxy in front of a single
177/// upstream is a hop that can only add latency and a failure mode.
178///
179/// `Err` is reserved for a service that *needs* a door and cannot have a
180/// working one — today that is exactly one case, no root mount, which would
181/// produce a table that 503s every unclaimed path.
182pub fn plan(
183 service: &ServiceConfig,
184 domains: &BTreeMap<String, DomainConfig>,
185) -> Result<Option<InnerDoorPlan>> {
186 let domain = domain_serving_service(domains, &service.name);
187
188 let mut mounts: Vec<InnerDoorMount> = Vec::new();
189 for component in &service.components {
190 let unit = match component.deploy {
191 DeployTier::Bundle => DeployedUnit::Bundle,
192 DeployTier::Workload => DeployedUnit::Component(component.id.clone()),
193 };
194 let mount = passway_mount(component.mount.as_deref());
195 mounts.push(InnerDoorMount {
196 headers: headers_for(domain, &mount),
197 mount,
198 unit,
199 });
200 }
201
202 // Rule 1, counted on UNITS. Three bundle components are one unit and get
203 // no door; one bundle component plus one workload component are two and
204 // do.
205 let units: std::collections::BTreeSet<&DeployedUnit> = mounts.iter().map(|m| &m.unit).collect();
206 if units.len() < 2 {
207 return Ok(None);
208 }
209
210 if !mounts.iter().any(|m| m.mount.is_empty()) {
211 bail!(
212 "services/{}/service.toml declares {} independently-deployed units but no component \
213 at the service root — every component sets a `mount`. An inner door's table needs a \
214 root (\"\") mount as its catch-all; without one every path outside the declared \
215 mounts 503s, which is indistinguishable from an outage. Drop the `mount` from \
216 whichever component serves `/`.",
217 service.name,
218 units.len(),
219 );
220 }
221
222 Ok(Some(InnerDoorPlan {
223 service: service.name.clone(),
224 mounts,
225 }))
226}
227
228/// The response headers `domain`'s route table gives `mount` (passway spelling:
229/// `""` or `/app`).
230///
231/// ## One join, not two
232///
233/// This used to be its own walk — the [`DomainRoute`] whose `route_path_prefix`
234/// equalled the component's [`normalize_mount`], skipping headerless routes.
235/// R898-F1 made a domain's routes a compiled [`RouteTable`] with ONE matching
236/// rule, and the Worker, mesofact and the outer door's `ROUTE_HEADERS` all
237/// answer by that rule. An inner door answering by a *different* one would serve
238/// different headers on the same path than the table says are served there — two
239/// producers of one fact. So the lookup goes through
240/// [`DomainConfig::route_for_path`], which is [`RouteTable::match_path`]'s walk
241/// over the declared routes.
242///
243/// Matched on the mount's PATH rather than on the route's `component`
244/// reference, so a route declared as a bare prefix still contributes:
245/// `cross_ref_validate` has already proved the two agree for every route that
246/// names a component, and matching on the path is what makes the lookup total.
247///
248/// ## The consequence: the answer is order-dependent now
249///
250/// First-match-wins is, and the old prefix-equality join was not. A manifest
251/// that puts `/*` above `/app/*` gives the ROOT's headers to `/app` — at every
252/// tier, now including this one. That is the table being right rather than this
253/// being wrong: `/app` is what such a manifest actually serves through the
254/// outer tier, and `yah-dev.toml:108` states the contract ("routes above this
255/// catch-all … Vec order = match order"). A headerless route that matches is
256/// likewise an answer of "no headers", not a reason to keep looking — no
257/// merging across rules (R746).
258///
259/// ## Why this takes a `DomainConfig` and not a compiled `RouteTable`
260///
261/// It would be circular. `CdnPlacement::backend_origin` resolves a route's
262/// origin out of [`InnerDoorPlan::resolve_addresses`]' output (R898-F1), so no
263/// table can be compiled until the plan this function is helping build exists.
264/// [`DomainConfig::route_for_path`] is the placement-free half of that same
265/// table, in exactly the relationship `route_headers_json` already has to
266/// [`RouteTable::headers_json`].
267///
268/// [`DomainRoute`]: crate::config::DomainRoute
269/// [`RouteTable`]: crate::route_table::RouteTable
270/// [`RouteTable::match_path`]: crate::route_table::RouteTable::match_path
271/// [`RouteTable::headers_json`]: crate::route_table::RouteTable::headers_json
272fn headers_for(domain: Option<&DomainConfig>, mount: &str) -> BTreeMap<String, String> {
273 // passway spells the root mount `""`; a request for it is `/`.
274 let path = if mount.is_empty() { "/" } else { mount };
275 domain
276 .and_then(|d| d.route_for_path(path))
277 .map(|r| r.headers.clone())
278 .unwrap_or_default()
279}
280
281// ── Rendering ────────────────────────────────────────────────────────────────
282
283/// Serialization mirror of passway's `path_routes_file::PathRoutesFile`. Kept
284/// private: the supported output is [`InnerDoorPlan::routes_file`]'s string, so
285/// nothing can construct a half-filled table and write it.
286#[derive(Debug, Serialize)]
287struct RoutesFile<'a> {
288 schema_version: u32,
289 routes: Vec<RouteEntry<'a>>,
290}
291
292#[derive(Debug, Serialize)]
293struct RouteEntry<'a> {
294 mount: &'a str,
295 upstreams: Vec<String>,
296 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
297 headers: &'a BTreeMap<String, String>,
298}
299
300impl InnerDoorPlan {
301 /// Every distinct unit this door proxies to, in a stable order. What a
302 /// caller resolving addresses has to answer for.
303 pub fn units(&self) -> Vec<DeployedUnit> {
304 let set: std::collections::BTreeSet<DeployedUnit> =
305 self.mounts.iter().map(|m| m.unit.clone()).collect();
306 set.into_iter().collect()
307 }
308
309 /// Render the JSON passway reads, resolving each unit to its address.
310 ///
311 /// `address` is placement-time knowledge — which node the unit landed on
312 /// and which port kamaji gave it — so it arrives as a closure rather than
313 /// as config. Returning `None` from it is refused rather than skipped: a
314 /// mount whose upstream could not be resolved would be dropped from the
315 /// table, and the door would then serve that path from whichever *shorter*
316 /// mount matched — the root, usually — which is a wrong answer wearing a
317 /// 200.
318 pub fn routes_file(
319 &self,
320 address: impl Fn(&DeployedUnit) -> Option<String>,
321 ) -> Result<String> {
322 let mut routes = Vec::with_capacity(self.mounts.len());
323 for m in &self.mounts {
324 let Some(addr) = address(&m.unit) else {
325 bail!(
326 "service {}: mount {:?} is served by {:?}, which has no resolved address yet. \
327 Refusing to write a partial route table — a missing mount does not 503, it \
328 falls through to the root mount and serves the wrong component with a 200.",
329 self.service,
330 m.mount,
331 m.unit,
332 );
333 };
334 routes.push(RouteEntry {
335 mount: &m.mount,
336 upstreams: vec![addr],
337 headers: &m.headers,
338 });
339 }
340 Ok(serde_json::to_string(&RoutesFile {
341 schema_version: ROUTES_SCHEMA_VERSION,
342 routes,
343 })?)
344 }
345}
346
347// ── Supervision ──────────────────────────────────────────────────────────────
348
349/// The passway binary every node carries, installed by the yubaba release
350/// tarball's `control_plane_install`. The inner door is the *same* binary as
351/// the public door — one door implementation, two configurations, which is the
352/// property R870-F15 built path routing to preserve.
353pub const INNER_DOOR_BINARY: &str = "/usr/local/bin/passway";
354
355/// Where a node keeps generated route tables. Same directory the demux and
356/// http-router tables already live in.
357pub const ROUTES_DIR: &str = "/var/lib/passway/routes";
358
359/// The only address an inner door ever binds, and the only one the outer door
360/// ever dials it at. Literal rather than a parameter — see
361/// [`InnerDoorPlan::workload`].
362pub const INNER_DOOR_HOST: &str = "127.0.0.1";
363
364/// Low end of the window [`listen_port`] picks from, inclusive.
365pub const INNER_DOOR_PORT_LOW: u16 = 10_000;
366/// High end of the window [`listen_port`] picks from, inclusive.
367pub const INNER_DOOR_PORT_HIGH: u16 = 19_999;
368
369/// The loopback port a service's inner door listens on — derived from the
370/// service name, so every apply of an unchanged tree renders the same number.
371///
372/// ## Why a derived pin rather than kamaji's ledger
373///
374/// R870-F23 phase 2 preferred taking the number from `kamaji::ports`
375/// ([`LedgerPorts`], `oss/kamaji/crates/kamaji/src/ports.rs`). Read rather than
376/// assumed, that ledger cannot answer here, for three reasons that also happen
377/// to make a pin safe:
378///
379/// 1. **It is node-local and has no RPC.** `LedgerPorts` persists
380/// `(ident, name) -> port` to a JSON file beside the supervisor's state dir,
381/// and yubaba's HTTP surface exposes no allocation verb (`yubaba/src/lib.rs`
382/// routes `/workloads/*`, `/services`, `/node/*` — nothing for ports). An
383/// apply running on an operator's laptop has no way to ask.
384/// 2. **A pin is honoured, not rejected, on the path this workload takes.**
385/// R844-F14's rule — a non-world-fixed pin is an error — bites in
386/// `LedgerPorts::resolve_set`, and `NativeRuntime::resolve_declared_ports`
387/// (`kamaji/src/native.rs:280`) filters `pin.is_none()` *before* calling it.
388/// A stated number is therefore passed through, which is what
389/// `PASSWAY_LISTEN` needs: the door's own env has to carry the number, and
390/// a number the node picked after the spec was rendered could not be in it.
391/// 3. **A collision is not even representable.** The ledger allocates on the
392/// workload's *mesh* IP; an inner door binds loopback. `100.64.0.3:14210`
393/// and `127.0.0.1:14210` are different sockets.
394///
395/// The window is deliberately below Linux's default ephemeral range
396/// (32768-60999), which is where `pick_free_port`'s `bind(:0)` draws from — so
397/// a derived number cannot land on one the ledger is about to hand out even on
398/// the same interface.
399///
400/// The hash is FNV-1a written out here rather than `DefaultHasher`, whose
401/// output std explicitly does not promise to keep stable across releases. This
402/// number is written into a deployed door's environment and into the outer
403/// door's upstream list; a toolchain bump silently moving it would repoint one
404/// tier and not the other.
405pub fn listen_port(service: &str) -> u16 {
406 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
407 for byte in service.as_bytes() {
408 hash ^= u64::from(*byte);
409 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
410 }
411 let span = u64::from(INNER_DOOR_PORT_HIGH - INNER_DOOR_PORT_LOW) + 1;
412 INNER_DOOR_PORT_LOW + (hash % span) as u16
413}
414
415/// The mesh identity a [`DeployTier::Workload`] component registers its service
416/// record under.
417///
418/// **This is the naming rule, not a lookup**, and it is stated here because
419/// nothing else states it. A bundle's ident comes from the mirror
420/// (`BundleSlot::workload_name`, overridable by `name = "…"`), but a
421/// workload-tier component has no slot of its own — `[providers.*]` is
422/// per-kind, per-mirror, which is exactly the gap [`DeployTier`] was added to
423/// close. So the ident has to be derivable from the two names the service
424/// already declares, and this is that derivation.
425///
426/// Getting it wrong is a *loud* failure rather than a quiet one:
427/// [`InnerDoorPlan::routes_file`] refuses a mount whose unit resolved to no
428/// address, naming the unit, so a component that registered under some other
429/// ident fails the apply instead of falling through to the root mount.
430pub fn component_workload_ident(service: &str, component_id: &str) -> String {
431 crate::reconciler::native_support::sanitize_ident(&format!("{service}-{component_id}"))
432}
433
434impl InnerDoorPlan {
435 /// The workload name / mesh identity for this service's inner door.
436 pub fn workload_name(&self) -> String {
437 format!("passway-inner-{}", self.service)
438 }
439
440 /// Where this door's route table is materialized on the node.
441 pub fn routes_path(&self) -> PathBuf {
442 Path::new(ROUTES_DIR).join(format!("{}.routes.json", self.service))
443 }
444
445 /// This door's loopback port — [`listen_port`] of the service name.
446 pub fn listen_port(&self) -> u16 {
447 listen_port(&self.service)
448 }
449
450 /// The mesh identity whose ready service record carries `unit`'s address.
451 ///
452 /// The two arms come from different places on purpose, and neither is
453 /// derivable from the other. A bundle's ident is a *mirror* fact —
454 /// `BundleSlot::workload_name`, which a slot may rename with `name = "…"` —
455 /// so it is handed in. A workload-tier component has no slot to rename it,
456 /// so its ident is derived ([`component_workload_ident`]).
457 pub fn unit_ident(&self, unit: &DeployedUnit, bundle_ident: &str) -> String {
458 match unit {
459 DeployedUnit::Bundle => bundle_ident.to_string(),
460 DeployedUnit::Component(id) => component_workload_ident(&self.service, id),
461 }
462 }
463
464 /// Resolve every unit to a `host:port`, given a way to look an address up
465 /// by mesh identity.
466 ///
467 /// The step between [`units`](Self::units) and the `address` closure
468 /// [`routes_file`](Self::routes_file) and [`workload`](Self::workload)
469 /// take: those two ask "where is this unit", this answers it from a
470 /// discovery read. Split out rather than folded in so the identity mapping
471 /// stays testable without a fleet.
472 ///
473 /// A unit with no answer is simply absent from the map — the refusal lives
474 /// in `routes_file`, which is the single place a missing address is
475 /// reported and which already explains why a dropped mount is worse than a
476 /// failed apply.
477 pub fn resolve_addresses(
478 &self,
479 bundle_ident: &str,
480 lookup: impl Fn(&str) -> Option<String>,
481 ) -> BTreeMap<DeployedUnit, String> {
482 self.units()
483 .into_iter()
484 .filter_map(|unit| {
485 let addr = lookup(&self.unit_ident(&unit, bundle_ident))?;
486 Some((unit, addr))
487 })
488 .collect()
489 }
490
491 /// Render the supervisable workload: a passway process serving this
492 /// service's mount table on loopback.
493 ///
494 /// ## Cleartext, and the invariant that makes it safe
495 ///
496 /// `PASSWAY_TLS_MODE=plaintext` (operator call, 2026-09-09 — see
497 /// `passway::tls::parse_listener_tls_mode` for the full argument). The
498 /// short version: no CA issues for `127.0.0.1`, so "TLS everywhere" here
499 /// means a self-signed leaf plus a way to switch OFF upstream certificate
500 /// verification on the *public* door — a real trust-boundary knob traded
501 /// for encrypting a hop that never leaves the loopback interface.
502 ///
503 /// This function cannot violate that invariant even if `listen_port` is
504 /// wrong, because it binds `127.0.0.1` literally and passway refuses the
505 /// mode on anything else. The bind is not a parameter.
506 ///
507 /// ## Why `listen_port` is an argument
508 ///
509 /// It is placement-time knowledge, exactly like the upstream addresses:
510 /// which port is free is a property of the node, not of the config. The
511 /// caller allocates and passes it, so this stays a pure function of
512 /// (plan, port, addresses) and is testable without a node.
513 ///
514 /// ## The route table travels IN the spec
515 ///
516 /// Not written beside it: [`WorkloadSpec::files`] makes the table and the
517 /// process that reads it one deploy rather than two, so a redeploy cannot
518 /// leave a door serving a stale table. Only kamaji's native backend
519 /// materializes those; every other backend refuses the spec by name rather
520 /// than starting the door against a file that is not there.
521 ///
522 /// ## Why `Workload::Container` and not a new `Workload` variant
523 ///
524 /// `TenantPasswayWorkload` is a typed variant, so the precedent for one
525 /// exists — but it earns that by carrying config kamaji itself must act on
526 /// (a domain to match, a PEM pair to re-read on every cold start, an idle
527 /// TTL to reap against). An inner door carries none of it: its entire
528 /// configuration is an argv, three env vars and one file, all of which
529 /// `WorkloadSpec` already expresses. A variant would buy nothing but
530 /// exhaustive-match churn in peer-owned `kamaji-proto`, which is the trade
531 /// R572-F1 already made and recorded.
532 pub fn workload(
533 &self,
534 listen_port: u16,
535 address: impl Fn(&DeployedUnit) -> Option<String>,
536 ) -> Result<Workload> {
537 let routes_path = self.routes_path();
538 let name = self.workload_name();
539 let listen = format!("127.0.0.1:{listen_port}");
540
541 let env = vec![
542 literal_env("PASSWAY_TLS_MODE", "plaintext".to_string()),
543 literal_env("PASSWAY_LISTEN", listen),
544 literal_env(
545 "PASSWAY_PATH_ROUTES_FILE",
546 routes_path.display().to_string(),
547 ),
548 ];
549
550 let spec = WorkloadSpec {
551 name: name.clone(),
552 // Identity metadata only — the native backend pulls nothing.
553 image: ImageRef {
554 registry: "local".into(),
555 repository: "passway".into(),
556 tag: "inner-door".into(),
557 digest: String::new(),
558 },
559 tier: TierTag("infra".into()),
560 tenant: TenantId::singleton(),
561 namespace: NamespaceId::singleton(),
562 replicas: 1,
563 command: Some(vec![INNER_DOOR_BINARY.to_string()]),
564 entrypoint: None,
565 workdir: None,
566 user: None,
567 env,
568 secrets: vec![],
569 volumes: vec![],
570 resources: ResourceLimits {
571 memory_mb: 128,
572 cpu_millis: 256,
573 memory_request_mb: None,
574 cpu_limit_millis: None,
575 pids_max: None,
576 scratch_floor_mb: None,
577 },
578 depends_on: vec![],
579 requires: vec![],
580 healthcheck: Some(Healthcheck {
581 // A cleartext listener would answer an HttpGet probe, but a
582 // bare connect is the same liveness signal without asking the
583 // door to route a synthetic path through a mount table that
584 // may legitimately not have a catch-all for it.
585 probe: HealthProbe::TcpConnect { port: listen_port },
586 interval: Millis::from_secs(10),
587 timeout: Millis::from_secs(2),
588 initial_delay: Millis::from_secs(5),
589 failure_threshold: 3,
590 }),
591 restart_policy: RestartPolicy::Always,
592 // Pinned and non-drainable: the service's public door proxies to
593 // this on loopback, so moving it to another node does not relocate
594 // the thing that reaches it — it severs it.
595 archetype: Some(LifecycleArchetype::Appliance),
596 stop_policy: StopPolicy {
597 signal: 15,
598 grace_period: Millis::from_secs(5),
599 },
600 expose: ExposeSpec {
601 mesh: MeshExpose {
602 identity: MeshIdent(name),
603 ports: MeshExpose::anonymous_ports([listen_port]),
604 allow_from: vec![],
605 },
606 // Loopback only. Nothing off this node reaches an inner door,
607 // which is the premise the cleartext listener rests on.
608 public: None,
609 operator: None,
610 },
611 labels: HashMap::new(),
612 durability: None,
613 annotations: HashMap::new(),
614 files: vec![InlineFile {
615 path: routes_path,
616 content: self.routes_file(address)?,
617 mode: Some(0o600),
618 }],
619 };
620
621 Ok(Workload::container(spec))
622 }
623}
624
625fn literal_env(name: &str, value: String) -> EnvVar {
626 EnvVar {
627 name: name.into(),
628 value: EnvValue::Literal { value },
629 }
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635 use crate::config::{DomainRoute, FrontDoor, RouteMode, ServiceComponent};
636 use crate::route_table::{CdnPlacement, RouteTable};
637
638 fn component(id: &str, mount: Option<&str>, deploy: DeployTier) -> ServiceComponent {
639 ServiceComponent {
640 id: id.to_string(),
641 kind: "mesofact-spa".to_string(),
642 path: format!("app/{id}"),
643 git: None,
644 role: "static".to_string(),
645 publishes: Some("static".to_string()),
646 mount: mount.map(str::to_string),
647 wave: 0,
648 deploy,
649 }
650 }
651
652 fn service(name: &str, components: Vec<ServiceComponent>) -> ServiceConfig {
653 ServiceConfig {
654 schema_version: 1,
655 name: name.to_string(),
656 domain: format!("{name}.test"),
657 health_path: None,
658 components,
659 db: Default::default(),
660 }
661 }
662
663 fn domains(service: &str, routes: &[(&str, &[(&str, &str)])]) -> BTreeMap<String, DomainConfig> {
664 let mut map = BTreeMap::new();
665 map.insert(
666 "test".to_string(),
667 DomainConfig {
668 schema_version: 1,
669 name: "test".to_string(),
670 domain: format!("{service}.test"),
671 front_door: FrontDoor::Passway,
672 cdn_bucket: "cdn".to_string(),
673 worker_bundle_path: None,
674 routes: routes
675 .iter()
676 .map(|(path, headers)| DomainRoute {
677 path: path.to_string(),
678 headers: headers
679 .iter()
680 .map(|(k, v)| (k.to_string(), v.to_string()))
681 .collect(),
682 mode: RouteMode::Static {
683 component: format!("{service}/root"),
684 },
685 })
686 .collect(),
687 },
688 );
689 map
690 }
691
692 /// Rule 1's negative, and the cheap half of this ticket's verify list: a
693 /// single-component service produces no plan at all, so there is no config
694 /// to write and no process to supervise.
695 #[test]
696 fn a_single_unit_service_gets_no_inner_door() {
697 let svc = service(
698 "yah-marketing",
699 vec![component("site", None, DeployTier::Bundle)],
700 );
701 assert_eq!(plan(&svc, &BTreeMap::new()).unwrap(), None);
702 }
703
704 /// The same negative one step further out, and the one that would be easy
705 /// to get wrong: THREE components still share one bundle, so they are one
706 /// unit and still earn no door.
707 #[test]
708 fn several_bundle_components_are_one_unit_and_still_get_no_door() {
709 let svc = service(
710 "noisetable",
711 vec![
712 component("site", None, DeployTier::Bundle),
713 component("app", Some("app"), DeployTier::Bundle),
714 component("docs", Some("docs"), DeployTier::Bundle),
715 ],
716 );
717 assert_eq!(plan(&svc, &BTreeMap::new()).unwrap(), None);
718 }
719
720 #[test]
721 fn one_bundle_component_plus_one_workload_component_is_two_units() {
722 let svc = service(
723 "noisetable",
724 vec![
725 component("site", None, DeployTier::Bundle),
726 component("account", Some("app"), DeployTier::Workload),
727 ],
728 );
729 let plan = plan(&svc, &BTreeMap::new()).unwrap().expect("two units");
730 assert_eq!(
731 plan.mounts.iter().map(|m| m.mount.as_str()).collect::<Vec<_>>(),
732 ["", "/app"]
733 );
734 assert_eq!(
735 plan.units(),
736 vec![
737 DeployedUnit::Bundle,
738 DeployedUnit::Component("account".into())
739 ]
740 );
741 }
742
743 /// The header half of the join: a mount picks up exactly the headers its
744 /// own route declares, and the root picks up none when its route declares
745 /// none. This is the config-side half of the ticket's live assertion that
746 /// `/app/` carries COOP/COEP while `/` carries neither.
747 ///
748 /// R898-F2 REORDERED THIS FIXTURE and changed nothing else. The assertions
749 /// and the property they pin are untouched; the route list now declares
750 /// `/app/*` ABOVE `/*`, which is what a legal manifest looks like under the
751 /// route table's first-match-wins contract (`yah-dev.toml:108`,
752 /// `noisetable-com.toml`). The old fixture declared the catch-all first,
753 /// which the previous prefix-equality join was blind to and the shared
754 /// matching rule is not — see [`headers_for`].
755 #[test]
756 fn each_mount_carries_only_its_own_routes_headers() {
757 let svc = service(
758 "noisetable",
759 vec![
760 component("site", None, DeployTier::Bundle),
761 component("account", Some("app"), DeployTier::Workload),
762 ],
763 );
764 let domains = domains(
765 "noisetable",
766 &[
767 (
768 "/app/*",
769 &[
770 ("cross-origin-opener-policy", "same-origin"),
771 ("cross-origin-embedder-policy", "require-corp"),
772 ],
773 ),
774 ("/*", &[]),
775 ],
776 );
777 let plan = plan(&svc, &domains).unwrap().expect("two units");
778
779 let root = &plan.mounts[0];
780 assert_eq!(root.mount, "");
781 assert!(root.headers.is_empty(), "{:?}", root.headers);
782
783 let app = &plan.mounts[1];
784 assert_eq!(app.mount, "/app");
785 assert_eq!(
786 app.headers.get("cross-origin-opener-policy").map(String::as_str),
787 Some("same-origin")
788 );
789 assert_eq!(
790 app.headers
791 .get("cross-origin-embedder-policy")
792 .map(String::as_str),
793 Some("require-corp")
794 );
795 }
796
797 /// A bundle-tier component at a non-root mount keeps its own headers even
798 /// though it shares the bundle's upstream — the reason mounts are per
799 /// COMPONENT while units are per deployed thing.
800 #[test]
801 fn a_bundle_components_sub_mount_keeps_its_headers_and_the_bundle_upstream() {
802 let svc = service(
803 "noisetable",
804 vec![
805 component("site", None, DeployTier::Bundle),
806 component("docs", Some("docs"), DeployTier::Bundle),
807 component("account", Some("app"), DeployTier::Workload),
808 ],
809 );
810 let domains = domains(
811 "noisetable",
812 &[("/docs/*", &[("x-frame-options", "DENY")])],
813 );
814 let plan = plan(&svc, &domains).unwrap().expect("two units");
815
816 let docs = &plan.mounts[1];
817 assert_eq!(docs.mount, "/docs");
818 assert_eq!(docs.unit, DeployedUnit::Bundle);
819 assert_eq!(docs.headers.get("x-frame-options").map(String::as_str), Some("DENY"));
820 // Two units, three mounts.
821 assert_eq!(plan.units().len(), 2);
822 assert_eq!(plan.mounts.len(), 3);
823 }
824
825 #[test]
826 fn a_table_with_no_root_mount_is_refused_rather_than_written() {
827 let svc = service(
828 "noisetable",
829 vec![
830 component("app", Some("app"), DeployTier::Bundle),
831 component("account", Some("account"), DeployTier::Workload),
832 ],
833 );
834 let err = plan(&svc, &BTreeMap::new()).unwrap_err().to_string();
835 assert!(err.contains("no component at the service root"), "{err}");
836 }
837
838 #[test]
839 fn rendering_produces_the_exact_shape_passway_reads() {
840 let svc = service(
841 "noisetable",
842 vec![
843 component("site", None, DeployTier::Bundle),
844 component("account", Some("app"), DeployTier::Workload),
845 ],
846 );
847 let domains = domains(
848 "noisetable",
849 &[("/app/*", &[("cross-origin-opener-policy", "same-origin")])],
850 );
851 let plan = plan(&svc, &domains).unwrap().unwrap();
852
853 let json = plan
854 .routes_file(|unit| match unit {
855 DeployedUnit::Bundle => Some("127.0.0.1:8081".to_string()),
856 DeployedUnit::Component(id) if id == "account" => {
857 Some("127.0.0.1:8082".to_string())
858 }
859 DeployedUnit::Component(_) => None,
860 })
861 .unwrap();
862
863 assert_eq!(
864 json,
865 r#"{"schema_version":1,"routes":[{"mount":"","upstreams":["127.0.0.1:8081"]},{"mount":"/app","upstreams":["127.0.0.1:8082"],"headers":{"cross-origin-opener-policy":"same-origin"}}]}"#
866 );
867 }
868
869 /// An unresolved address must stop the write. Dropping the mount would
870 /// leave the door serving `/app` from the ROOT mount with a 200 — the
871 /// silent wrong answer, not a 503.
872 #[test]
873 fn an_unresolved_upstream_refuses_the_whole_table() {
874 let svc = service(
875 "noisetable",
876 vec![
877 component("site", None, DeployTier::Bundle),
878 component("account", Some("app"), DeployTier::Workload),
879 ],
880 );
881 let plan = plan(&svc, &BTreeMap::new()).unwrap().unwrap();
882 let err = plan
883 .routes_file(|unit| match unit {
884 DeployedUnit::Bundle => Some("127.0.0.1:8081".to_string()),
885 DeployedUnit::Component(_) => None,
886 })
887 .unwrap_err()
888 .to_string();
889 assert!(err.contains("no resolved address"), "{err}");
890 assert!(err.contains("account"), "{err}");
891 }
892
893 /// The rendered door, pinned on the four properties that are not
894 /// cosmetic: cleartext ONLY on loopback, the routes file travelling inside
895 /// the spec, and the env var passway selects path routing by.
896 #[test]
897 fn the_rendered_door_is_cleartext_on_loopback_and_carries_its_own_table() {
898 let svc = service(
899 "noisetable",
900 vec![
901 component("site", None, DeployTier::Bundle),
902 component("account", Some("app"), DeployTier::Workload),
903 ],
904 );
905 let plan = plan(&svc, &BTreeMap::new()).unwrap().unwrap();
906 let workload = plan
907 .workload(8443, |unit| match unit {
908 DeployedUnit::Bundle => Some("127.0.0.1:8081".to_string()),
909 DeployedUnit::Component(_) => Some("127.0.0.1:8082".to_string()),
910 })
911 .unwrap();
912 let spec = workload.container_spec().expect("container-shaped");
913
914 let env: BTreeMap<&str, &str> = spec
915 .env
916 .iter()
917 .filter_map(|e| match &e.value {
918 EnvValue::Literal { value } => Some((e.name.as_str(), value.as_str())),
919 _ => None,
920 })
921 .collect();
922 assert_eq!(env.get("PASSWAY_TLS_MODE"), Some(&"plaintext"));
923 // The invariant: cleartext is bound to loopback by construction, not
924 // by whoever picked the port.
925 assert_eq!(env.get("PASSWAY_LISTEN"), Some(&"127.0.0.1:8443"));
926 assert!(spec.expose.public.is_none(), "an inner door is never public");
927
928 // The routes file rides the spec, and the env var points AT it.
929 assert_eq!(spec.files.len(), 1);
930 let file = &spec.files[0];
931 assert_eq!(
932 env.get("PASSWAY_PATH_ROUTES_FILE").map(|s| s.to_string()),
933 Some(file.path.display().to_string())
934 );
935 assert!(file.content.contains("\"schema_version\":1"), "{}", file.content);
936 assert!(file.content.contains("127.0.0.1:8082"), "{}", file.content);
937 assert_eq!(spec.command.as_deref(), Some(&[INNER_DOOR_BINARY.to_string()][..]));
938 }
939
940 /// A door whose table cannot be rendered is not rendered at all — the
941 /// refusal propagates out of `workload`, so there is no spec that deploys
942 /// a door pointing at nothing.
943 #[test]
944 fn an_unresolvable_unit_stops_the_workload_being_built() {
945 let svc = service(
946 "noisetable",
947 vec![
948 component("site", None, DeployTier::Bundle),
949 component("account", Some("app"), DeployTier::Workload),
950 ],
951 );
952 let plan = plan(&svc, &BTreeMap::new()).unwrap().unwrap();
953 assert!(plan.workload(8443, |_| None).is_err());
954 }
955
956 #[test]
957 fn the_mount_spelling_matches_passways_convention_in_both_directions() {
958 assert_eq!(passway_mount(None), "");
959 assert_eq!(passway_mount(Some("")), "");
960 assert_eq!(passway_mount(Some("/")), "");
961 // Every spelling of one mount collapses to one string — the whole
962 // reason this composes with `normalize_mount` instead of formatting.
963 for raw in ["app", "/app", "app/", "/app/"] {
964 assert_eq!(passway_mount(Some(raw)), "/app", "{raw}");
965 }
966 assert_eq!(passway_mount(Some("/a/b/")), "/a/b");
967 }
968
969 // ── Phase 2: placement (R870-F23 steps 2 and 3) ─────────────────────────
970
971 /// The property the whole pin rests on: same service, same number, forever.
972 /// The outer door's upstream list and the inner door's `PASSWAY_LISTEN` are
973 /// rendered by two different call sites in two different apply phases; if
974 /// this drifted, one tier would be repointed and the other would not.
975 #[test]
976 fn the_derived_port_is_stable_and_inside_its_declared_window() {
977 assert_eq!(listen_port("noisetable"), listen_port("noisetable"));
978 for service in ["noisetable", "yah-marketing", "", "a", "a-very-long-service-name"] {
979 let port = listen_port(service);
980 assert!(
981 (INNER_DOOR_PORT_LOW..=INNER_DOOR_PORT_HIGH).contains(&port),
982 "{service} -> {port}"
983 );
984 // Below the Linux default ephemeral floor, which is where
985 // `kamaji::ports::pick_free_port`'s `bind(:0)` draws from. A number
986 // inside that range could collide with a ledger allocation.
987 assert!(port < 32_768, "{service} -> {port}");
988 }
989 }
990
991 /// Different services get different doors. Not a guarantee the hash can
992 /// make in general — 10_000 slots, so a collision is possible — but two
993 /// services co-tenant on one node colliding is what this is checked
994 /// against, and the two real ones do not.
995 #[test]
996 fn two_services_do_not_share_a_door() {
997 assert_ne!(listen_port("noisetable"), listen_port("yah-marketing"));
998 }
999
1000 /// Step 3's identity mapping. The two arms come from different places and
1001 /// the test says so: the bundle's ident is handed in (a mirror may rename
1002 /// it), a component's is derived from names the service already declares.
1003 #[test]
1004 fn each_unit_resolves_through_its_own_identity_rule() {
1005 let svc = service(
1006 "noisetable",
1007 vec![
1008 component("site", None, DeployTier::Bundle),
1009 component("account", Some("app"), DeployTier::Workload),
1010 ],
1011 );
1012 let plan = plan(&svc, &BTreeMap::new()).unwrap().unwrap();
1013
1014 assert_eq!(
1015 plan.unit_ident(&DeployedUnit::Bundle, "renamed-bundle"),
1016 "renamed-bundle",
1017 "a slot's `name = \"…\"` override has to win — it is what the record carries"
1018 );
1019 assert_eq!(
1020 plan.unit_ident(&DeployedUnit::Component("account".into()), "renamed-bundle"),
1021 "noisetable-account",
1022 );
1023 }
1024
1025 /// A component id that is not already a legal mesh ident is folded, not
1026 /// passed through — the ident travels into a service-record lookup and a
1027 /// `MeshIdent`, both of which are lowercase-and-dash.
1028 #[test]
1029 fn a_derived_component_ident_is_folded_like_every_other_mesh_ident() {
1030 assert_eq!(
1031 component_workload_ident("Noise_Table", "Account.API"),
1032 "noise-table-account-api"
1033 );
1034 }
1035
1036 /// Steps 2 and 3 joined: a two-unit service renders a door whose table
1037 /// names both resolved addresses and whose listener is the derived port.
1038 /// The positive half of the ticket's verify list, at the config tier.
1039 #[test]
1040 fn resolved_units_render_a_door_on_the_derived_port() {
1041 let svc = service(
1042 "noisetable",
1043 vec![
1044 component("site", None, DeployTier::Bundle),
1045 component("account", Some("app"), DeployTier::Workload),
1046 ],
1047 );
1048 let domains = domains(
1049 "noisetable",
1050 &[(
1051 "/app/*",
1052 &[
1053 ("cross-origin-opener-policy", "same-origin"),
1054 ("cross-origin-embedder-policy", "require-corp"),
1055 ],
1056 )],
1057 );
1058 let plan = plan(&svc, &domains).unwrap().unwrap();
1059
1060 let addresses = plan.resolve_addresses("noisetable", |ident| match ident {
1061 "noisetable" => Some("100.64.0.3:8080".to_string()),
1062 "noisetable-account" => Some("100.64.0.3:14001".to_string()),
1063 _ => None,
1064 });
1065 assert_eq!(addresses.len(), 2);
1066
1067 let workload = plan
1068 .workload(plan.listen_port(), |unit| addresses.get(unit).cloned())
1069 .unwrap();
1070 let spec = workload.container_spec().expect("container-shaped");
1071 let listen = spec
1072 .env
1073 .iter()
1074 .find(|e| e.name == "PASSWAY_LISTEN")
1075 .and_then(|e| match &e.value {
1076 EnvValue::Literal { value } => Some(value.clone()),
1077 _ => None,
1078 })
1079 .expect("a door always declares its listener");
1080 assert_eq!(listen, format!("127.0.0.1:{}", listen_port("noisetable")));
1081
1082 let table = &spec.files[0].content;
1083 assert!(table.contains("100.64.0.3:8080"), "{table}");
1084 assert!(table.contains("100.64.0.3:14001"), "{table}");
1085 assert!(table.contains("cross-origin-embedder-policy"), "{table}");
1086 }
1087
1088 /// A unit that resolved to nothing is ABSENT from the map rather than
1089 /// present-and-empty — which is what makes `routes_file`'s refusal the
1090 /// single place a missing address is reported.
1091 #[test]
1092 fn an_unresolvable_unit_is_absent_rather_than_defaulted() {
1093 let svc = service(
1094 "noisetable",
1095 vec![
1096 component("site", None, DeployTier::Bundle),
1097 component("account", Some("app"), DeployTier::Workload),
1098 ],
1099 );
1100 let plan = plan(&svc, &BTreeMap::new()).unwrap().unwrap();
1101 let addresses = plan.resolve_addresses("noisetable", |ident| {
1102 (ident == "noisetable").then(|| "100.64.0.3:8080".to_string())
1103 });
1104 assert_eq!(addresses.len(), 1);
1105 assert!(!addresses.contains_key(&DeployedUnit::Component("account".into())));
1106 assert!(plan
1107 .workload(plan.listen_port(), |unit| addresses.get(unit).cloned())
1108 .is_err());
1109 }
1110
1111 // ── R898-F2: the inner door and the compiled table are one join ──────────
1112
1113 /// A placement that resolves the fixture's `static` routes. Nothing here
1114 /// asserts on origins — the point is that the HEADER answer the door plans
1115 /// and the header answer the compiled table carries come from one walk.
1116 fn placement() -> CdnPlacement {
1117 CdnPlacement {
1118 cdn_base: "https://cdn.test".to_string(),
1119 env: "prod".to_string(),
1120 ..Default::default()
1121 }
1122 }
1123
1124 fn compiled(domains: &BTreeMap<String, DomainConfig>) -> RouteTable {
1125 domains["test"]
1126 .route_table(&placement())
1127 .expect("the fixture's routes all resolve")
1128 }
1129
1130 /// passway spells the root mount `""`; the request that reaches it is `/`.
1131 fn mount_path(mount: &str) -> &str {
1132 if mount.is_empty() {
1133 "/"
1134 } else {
1135 mount
1136 }
1137 }
1138
1139 /// A two-unit service whose domain declares a headered `/app/*` above a
1140 /// headerless catch-all — a legal manifest under first-match-wins.
1141 fn seam_fixture() -> (ServiceConfig, BTreeMap<String, DomainConfig>) {
1142 let svc = service(
1143 "noisetable",
1144 vec![
1145 component("site", None, DeployTier::Bundle),
1146 component("account", Some("app"), DeployTier::Workload),
1147 ],
1148 );
1149 let domains = domains(
1150 "noisetable",
1151 &[
1152 (
1153 "/app/*",
1154 &[("cross-origin-opener-policy", "same-origin")],
1155 ),
1156 ("/*", &[("x-frame-options", "DENY")]),
1157 ],
1158 );
1159 (svc, domains)
1160 }
1161
1162 /// **THE SEAM (R898-F2).** Every mount's headers are the compiled route
1163 /// table's answer for that mount's path — not a second join that happens to
1164 /// agree. If the two ever diverge, the inner door serves headers the table
1165 /// says are served somewhere else.
1166 #[test]
1167 fn every_mounts_headers_are_the_compiled_route_tables_answer() {
1168 let (svc, domains) = seam_fixture();
1169 let table = compiled(&domains);
1170 let plan = plan(&svc, &domains).unwrap().expect("two units");
1171
1172 for m in &plan.mounts {
1173 let expected = table
1174 .match_path(mount_path(&m.mount))
1175 .map(|e| e.headers.clone())
1176 .unwrap_or_default();
1177 assert_eq!(m.headers, expected, "mount {:?}", m.mount);
1178 }
1179
1180 // Non-trivially: the fixture gives both mounts headers, and DIFFERENT
1181 // ones, so an implementation that returned `Default::default()` for
1182 // everything could not pass the loop above.
1183 assert_eq!(
1184 plan.mounts
1185 .iter()
1186 .map(|m| m.headers.keys().cloned().collect::<Vec<_>>())
1187 .collect::<Vec<_>>(),
1188 vec![
1189 vec!["x-frame-options".to_string()],
1190 vec!["cross-origin-opener-policy".to_string()],
1191 ]
1192 );
1193 }
1194
1195 /// The property the seam exists for: a header edited in the domain manifest
1196 /// reaches the inner door's table without a second join being touched, and
1197 /// both tiers move together.
1198 #[test]
1199 fn a_header_changed_in_the_manifest_moves_both_tiers_at_once() {
1200 let (svc, mut domains) = seam_fixture();
1201 let before = plan(&svc, &domains).unwrap().unwrap();
1202 assert_eq!(
1203 before.mounts[1]
1204 .headers
1205 .get("cross-origin-opener-policy")
1206 .map(String::as_str),
1207 Some("same-origin")
1208 );
1209
1210 // One edit, in the manifest, to the route that governs `/app`.
1211 let route = domains
1212 .get_mut("test")
1213 .unwrap()
1214 .routes
1215 .iter_mut()
1216 .find(|r| r.path == "/app/*")
1217 .unwrap();
1218 route.headers.insert(
1219 "cross-origin-embedder-policy".to_string(),
1220 "require-corp".to_string(),
1221 );
1222
1223 let after = plan(&svc, &domains).unwrap().unwrap();
1224 let table = compiled(&domains);
1225 assert_eq!(
1226 after.mounts[1].headers,
1227 table.match_path("/app").unwrap().headers,
1228 "the door's mount and the compiled entry must move together"
1229 );
1230 assert_eq!(
1231 after.mounts[1]
1232 .headers
1233 .get("cross-origin-embedder-policy")
1234 .map(String::as_str),
1235 Some("require-corp")
1236 );
1237 // And only that mount moved — no merging across rules.
1238 assert_eq!(before.mounts[0].headers, after.mounts[0].headers);
1239 }
1240
1241 /// The order-dependence the shared rule brings, stated as a test rather
1242 /// than left to be discovered: a catch-all declared ABOVE `/app/*` claims
1243 /// `/app` at every tier, and the inner door now agrees instead of quietly
1244 /// disagreeing. This is the manifest being wrong, not the door.
1245 #[test]
1246 fn a_catch_all_declared_first_claims_every_mount_at_both_tiers() {
1247 let svc = service(
1248 "noisetable",
1249 vec![
1250 component("site", None, DeployTier::Bundle),
1251 component("account", Some("app"), DeployTier::Workload),
1252 ],
1253 );
1254 let domains = domains(
1255 "noisetable",
1256 &[
1257 ("/*", &[("x-frame-options", "DENY")]),
1258 ("/app/*", &[("cross-origin-opener-policy", "same-origin")]),
1259 ],
1260 );
1261 let table = compiled(&domains);
1262 let plan = plan(&svc, &domains).unwrap().unwrap();
1263
1264 assert_eq!(
1265 table.match_path("/app").unwrap().path,
1266 "/*",
1267 "first match wins over the compiled table"
1268 );
1269 assert_eq!(plan.mounts[1].mount, "/app");
1270 assert_eq!(
1271 plan.mounts[1].headers.keys().cloned().collect::<Vec<_>>(),
1272 vec!["x-frame-options".to_string()],
1273 "and the inner door reports the same route's headers, not a second join's"
1274 );
1275 }
1276}