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