cloud/reconciler/mesofact_bundle.rs
1//! W272 bundle tier for `mesofact-static` / `mesofact-spa` components — the
2//! config half of the services-tab sync arm.
3//!
4//! Part of R599-F8 — the canonical ticket annotation lives in
5//! `app/yah/cli/src/cloud.rs`, which owns the orchestration half. This module
6//! only owns *what the mirror declares*: parsing the `[providers.bundle]` slot
7//! and resolving which machines the built bundle gets deployed to.
8//!
9//! **The component kind does not change.** A mesofact site is a mesofact site;
10//! the mirror decides how its bytes are distributed. A mirror with a
11//! `[providers.static]` slot rides the historical build-and-publish-to-CDN path
12//! ([`super::mesofact_static`]); a mirror that declares `[providers.bundle]`
13//! rides the W272 chain instead:
14//!
15//! ```text
16//! build → bundle assembly (per-file blake3) → R2 publish → workload deploy
17//! → node materializes → kamaji forks the serve binary
18//! ```
19//!
20//! The deploy leg lives at the apply layer (`app/yah/cli/src/cloud.rs`) rather
21//! than in a `Reconciler::up`, for the same reason
22//! [`super::mesofact_runner`] does: machine resolution needs [`CloudConfig`],
23//! which [`ReconcileCtx`] deliberately does not carry. What runs here is the
24//! validation a desktop-side bring-up can still do offline —
25//! [`MesofactBundleReconciler`] checks the slot parses and the placement
26//! resolves, then bails with a pointer at the CLI.
27//!
28//! @yah:ticket(R703-T7, "Stamp a publish beacon into the W272 bundle so a passway apex can be serving-verified too")
29//! @yah:status(review)
30//! @yah:at(2026-08-08T23:55:25Z)
31//! @yah:assignee(agent:bundle-anthropic-ashguard)
32//! @yah:parent(R703)
33//! @yah:next("R703-B4 added a publish beacon (prefix/.well-known/yah-publish.json, oss/yubaba/crates/cloud/src/reconciler/publish_beacon.rs) written by the R2 publish path, and the reconciler fetches it back through the declared front door to fail an apply whose bytes nobody serves. A passway apex serves a W272 bundle, NOT the R2 prefix, so it has nothing to answer that probe with -- the front-door check can only ever pass there once the bundle carries an equivalent stamp.")
34//! @yah:next("SCOPE: stamp a PublishBeacon into the bundle at build time using the same digest shape (PublishBeacon::new + digest_of are already public and take a BTreeMap of key -> sha256; reuse them rather than inventing a second digest). It must be reachable at /.well-known/yah-publish.json through mesofact serve, which serves bundle paths directly, so it needs to be a bundle entry at exactly that path.")
35//! @yah:next("THEN: the mesofact_bundle sync path gains the same call mesofact_static::verify_serving makes. That is where the check becomes symmetric -- today only the R2 arm can prove it is being read.")
36//! @yah:verify("A bundle built for yah-marketing contains /.well-known/yah-publish.json, and curl https://yah.dev/.well-known/yah-publish.json through a passway apex returns a beacon whose digest matches the bundle that was synced.")
37//! @yah:gotcha("GATED ON R546 REGARDLESS. The bundle tier cannot sync at all until the musl serve_bins at target/x86_64-unknown-linux-musl/release/mesofact exists; slot_ready is false and .yah/services/yah-marketing/mirrors/cloud.toml falls back to the static chain. There is nothing to verify until that lands, which is why R703-B4 filed this rather than doing it in-pass.")
38//! @yah:tier(Cleric) — the digest and probe shapes are already built and public; this is threading a known artifact through the bundle builder, not a design.
39//! @yah:handoff("SHIPPED. A W272 bundle now carries a publish beacon and the bundle sync arm verifies it through the apex, so both serving tiers can prove they are being read rather than merely written.")
40//! @yah:handoff("publish_beacon.rs: BUNDLE_BEACON_PATH = app/dist/html/.well-known/yah-publish.json (the one bundle entry mesofact serve answers /.well-known/yah-publish.json from), PublishBeacon::for_bundle, bundle_beacon(), stamp_bundle(). Reuses digest_of over a BTreeMap of path -> hash as the ticket asked; no second digest was invented.")
41//! @yah:handoff("DESIGN CALL worth reviewing: the bundle stamp is clock-free. published_at became Option<String> (serde default, so beacons already in R2 still parse) and for_bundle sets None. A wall clock inside an entry of a content-addressed unit would flip the bundle digest on every assembly, breaking W272 immutability, the blob dedupe that makes a re-publish a no-op, and the assembly_is_deterministic test. The digest already names the exact immutable unit, so nothing diagnostic is lost; messages render via published_label().")
42//! @yah:handoff("Symmetry, the third next bullet: mesofact_static::verify_serving and the new cloud.rs verify_bundle_serving both call one shared publish_beacon::check_serving -> ServingVerdict, rather than the bundle arm growing a second copy that drifts. probe_urls() is the pure half (three collapses: static probes prefix + apex separately, a bundle collapses onto the apex, bucket-direct collapses onto the origin) so it is testable with no network. Probe budgets split: EDGE_PROBE (4 x 5s, CDN propagation) vs NODE_PROBE (20 x 6s) because a bundle deploy has to fetch blobs, materialize, and restart the serve process.")
43//! @yah:handoff("Stamped in assemble_component_bundle_with_sidecars (app/yah/cli/src/cloud.rs), not in the sync arm, so yah cloud bundle build and a sync still emit byte-identical trees. BundleSlot gained verify_serving (default true, non-bool rejected rather than defaulted) and an optional zone (defaults to the service domain) + serving_zone(). Documented both in the [providers.bundle] block of .yah/services/yah-marketing/mirrors/cloud.toml.")
44//! @yah:handoff("DISCOVERED WORK, outside the ticket title, done in-pass. Two claims this ticket rests on were inference, not verification, so I pinned them. (1) oss/mesofact/crates/mesofact/src/server.rs:1450 — serves_the_publish_beacon_from_a_dot_well_known_path proves GET /.well-known/yah-publish.json really returns 200 application/json through Server::from_bundle (a leading-dot directory is exactly the shape a static server tends to reject or rewrite), plus an_unstamped_bundle_does_not_answer_the_beacon_url_with_200 so an unstamped bundle 404s instead of 200-ing HTML. (2) oss/yah-base/crates/mesofact-bundle/src/store.rs:439 — a_dot_directory_entry_publishes_and_materializes proves publish_bundle + materialize_bundle round-trip the first dot-directory entry a bundle has ever carried; if checked_rel were ever tightened to a naive no-dot-segment rule, a node would refuse to materialize a bundle it had already accepted.")
45//! @yah:verify("cargo test -p yah-cloud --lib (in oss/yubaba): 741 passed, 0 failed. 23 in reconciler::publish_beacon (13 new), 34 in reconciler::mesofact_bundle (4 new).")
46//! @yah:verify("cargo test -p yah --lib: 1035 passed, 0 failed. Includes the new cloud::bundle_assembly_tests::an_assembled_bundle_carries_its_publish_beacon, and assembly_is_deterministic still passes with the stamp in place, which is the evidence the clock-free design holds W272 immutability.")
47//! @yah:verify("cargo test -p mesofact --lib server:: (in oss/mesofact): 34 passed, 0 failed. cargo test -p yah-mesofact-bundle --features store (in oss/yah-base): 31 passed, 0 failed.")
48//! @yah:verify("cargo check --workspace --exclude desktop: clean. cargo check --workspace in oss/yubaba: clean. cargo test -p xtask --test schema_drift: 3 passed, so no generated-artifact drift. yah cloud validate --path .: ok, no alias or port collisions, re-run after the mirror comment edit.")
49//! @yah:gotcha("THE SECOND HALF OF THE VERIFY LINE IS NOT DONE AND COULD NOT BE. curl https://yah.dev/.well-known/yah-publish.json still 404s, because the bundle tier cannot sync at all until R546 produces target/x86_64-unknown-linux-musl/release/{mesofact,almanac-feed}. slot_ready is false, yah-marketing still falls back to the static chain, and no bundle has been assembled by this code against the live apex. Everything is proven by test, nothing by a live apply. R703 now carries a notify_on(R546) that spells out the live run.")
50//! @yah:gotcha("When the bundle tier first turns on, expect the apply to FAIL the serving check for a while, and read that as the check working. us-east-001 is serving a hand-placed bundle from before this code existed, which carries no stamp, so the apex will answer the probe 404 (Missing) until a bundle assembled by THIS code is deployed there. Do not reach for verify_serving = false; deploy the stamped bundle.")
51//! @yah:next("LIVE VERIFY, gated on R546 and the only thing left: build the two musl binaries, yah cloud apply --service yah-marketing --env cloud, confirm it takes the bundle arm, then curl https://yah.dev/.well-known/yah-publish.json and check the digest equals bundle_beacon() over the synced manifest.")
52//!
53//! @yah:ticket(R752-B7, "revalidate routes allowlist is parsed, shipped, then dropped - the receiver accepts pokes for every route")
54//! @yah:status(review)
55//! @yah:at(2026-08-13T00:22:14Z)
56//! @yah:assignee(agent:bundle-anthropic-ashguard)
57//! @yah:parent(R752)
58//! @yah:severity(medium)
59//! @yah:gotcha("Found 2026-08-12 while wiring R330-F13's sidecar to the live receiver. `[providers.bundle.revalidate] routes` is documented as an allowlist ('empty = all routes', mesofact_bundle.rs:218), is parsed into RevalidateSlot.routes, is copied into MesofactRevalidateReceiver.routes (mesofact_bundle.rs:264), and is shipped over the wire to kamaji. kamaji then never reads it: bundle_workload_spec_revalidate (oss/kamaji/crates/kamaji-bin/src/server.rs:2117) builds the receiver's argv from publish_config + listen and its env from receiver.env, and `routes` appears nowhere. grep confirms server.rs touches receiver.feeds / feed_interval_secs / feed_project_prefix / publish_config / env and never receiver.routes.")
60//! @yah:gotcha("MEASURED, not inferred: with .yah/services/yah-marketing/mirrors/cloud.toml declaring routes = [\"/releases\"], POST http://100.64.0.3:8081/revalidate {\"routes\":[\"/issues\"]} returned 202 on us-east-001 and went on to re-render and republish /issues. An undeclared route was accepted and acted on.")
61//! @yah:gotcha("Severity is medium not high because the receiver is not publicly reachable (mesh IP, and it is the tenant's own render path) — but it IS an unauthenticated write-shaped endpoint today: its process env carries no MESOFACT_MIRROR_KEY, so mirror_key_env is unresolved too. The declared scoping control and the declared bearer are BOTH inert, which is worth knowing before anyone treats either as a boundary.")
62//! @yah:next("Decide whether the allowlist is real. If yes, pass it to the receiver (argv or env) in bundle_workload_spec_revalidate and enforce it there; if no, delete the field rather than leaving a documented control that does nothing.")
63//! @yah:next("If it becomes enforced, .yah/services/yah-marketing/mirrors/cloud.toml already lists both \"/releases\" and \"/issues\" — R330-F13 added /issues precisely so enforcement does not silently break the now-working issue-filing path.")
64//! @yah:next("Same question for mirror_key_env: it resolves to nothing today, so the receiver runs open. Whatever change starts resolving it must set the matching ALMANAC_MIRROR_KEY on the issue-tracker unit on us-east-001 in the SAME change, or the sidecar's poke starts 401ing and /issues silently stops updating.")
65//! @yah:handoff("OPERATOR CALL 2026-08-12: the allowlist is real - enforce it IF present. Auth is a separate, pluggable axis (cheers auth, preshared key, or unauthenticated are all legitimate for an almanac route); the allowlist is scoping, not authentication, and the two are now independent controls end to end.")
66//! @yah:handoff("Node leg (oss/kamaji/crates/kamaji-bin/src/server.rs, bundle_workload_spec_revalidate): each declared route is rendered as one `--allow-route <route>` on the receiver's argv. An empty list emits no flag at all, which keeps the documented 'empty = all routes' meaning - `--allow-route \"\"` would have scoped the receiver to a route that cannot exist and silently killed every revalidation.")
67//! @yah:handoff("Receiver leg (oss/mesofact/crates/mesofact/src/revalidate.rs): RevalidateConfig gained `routes`, fed by a new repeatable `--allow-route` flag on `mesofact serve`. Enforced in BOTH shapes a poke can take - an explicit `{\"route\": ...}` outside the list gets a synchronous 403 and never enqueues, and a whole-site poke (no route named) is NARROWED to the list at render time. The narrowing is the half that matters: the escape actually measured on us-east-001 sent {\"routes\":[\"/issues\"]}, which the receiver's body type does not have a field for, so it deserialized to route=None and ran as a whole-site render. A handler-only check would still have let that through.")
68//! @yah:handoff("Route selection was split out of render_routes into a pure `render_targets(workload, route, allow)` so the scoping rule is testable without booting V8 - a security-shaped control whose only evidence was 'it compiles' is how this got shipped inert in the first place. It also errors on a disallowed explicit route rather than rendering nothing, so an in-process caller cannot get a silent success.")
69//! @yah:handoff("The allowlist is intersected with the manifest, not unioned: a listed route the manifest cannot render (ssr, deferred, or a typo) is skipped instead of turning every whole-site poke into an error.")
70//! @yah:handoff("Config docs corrected where they now lie: RevalidateSlot.routes in oss/yubaba/crates/cloud/src/reconciler/mesofact_bundle.rs and the block in .yah/services/yah-marketing/mirrors/cloud.toml both said the field was inert. The cloud.toml note now says the list is LOAD-BEARING - a route absent from it stops being republished after the next deploy of that mirror.")
71//! @yah:handoff("tenants.rs (multi-tenant receiver) passes an empty allowlist with a comment naming the shape to copy - tenants/<id>.toml has no routes key yet, so per-tenant scoping is unmodelled rather than silently unenforced.")
72//! @yah:verify("cargo test -p mesofact --all-features (oss/mesofact) - 104 passed, 0 failed, including 8 new: out-of-list route 403s and does not enqueue, in-list route accepted, a correct mirror_key does NOT widen the allowlist, empty allowlist accepts anything, whole-site poke accepted then narrowed, whole-site targets = manifest INTERSECT allowlist, an allowlisted route absent from the manifest is not rendered, explicit disallowed route errors at render time.")
73//! @yah:verify("cargo test -p kamaji-bin --all-features (oss/kamaji) - 239 passed, 0 failed. The pre-existing revalidate_spec_argv_matches_mesofact_serve_clap_shape test is the one that should have caught this: it declared routes = [\"/releases\"] and pinned an argv that never mentioned it, green the whole time. It now asserts the --allow-route pair, plus two new tests for the empty-list and two-route cases.")
74//! @yah:verify("cargo test -p yah-cloud --lib mesofact_bundle (oss/yubaba) - 44 passed, 0 failed.")
75//! @yah:verify("cargo test -p xtask --test schema_drift - 3 passed; the doc-comment edits touch no schemars-derived type, so no generated artifact moved.")
76//! @yah:verify("cargo clippy --all-features --all-targets on both changed crates - no new warnings from the changed files (mesofact-core/mesofact-build/server.rs warnings are pre-existing).")
77//! @yah:verify("Checked the roll is safe BEFORE it happens: the only mirror in the tree declaring [providers.bundle.revalidate] is yah-marketing/cloud.toml, and it lists both /releases and /issues. The only live pokers name exactly those - issue-tracker sends Poke::route(\"/issues\") (crates/yah/issue-tracker/src/main.rs:86) and the almanac on_change arms in .yah/almanac/{releases,yah-desktop}.toml both name /releases. fleet.toml uses kind=\"reload\", which pokes almanac's own receiver, not this one. So nothing that works today starts 403ing.")
78//! @yah:gotcha("NOT DEPLOYED - code only. Enforcement starts at the next `yah cloud` sync of yah-marketing, which re-forks the receiver with the new argv. Deliberately not rolled from this session: it is an outward-facing change to a live node, and deployment belongs to R330-F13/R523. Before that roll, the live receiver still accepts a poke for any route.")
79//! @yah:next("mirror_key_env is still inert and the receiver still runs OPEN - untouched here, because the operator's call put auth on its own axis. Whatever change starts resolving it must set the matching ALMANAC_MIRROR_KEY on the issue-tracker unit on us-east-001 in the SAME change, or the sidecar's poke starts 403ing and /issues silently stops updating.")
80//! @yah:next("Public front door: R752-F9 filed for the low-security platform key that ships with the browser bundle for POST /api/issues (the operator's second decision). Different endpoint, different key namespace - do not collapse it with MESOFACT_MIRROR_KEY.")
81//! @yah:gotcha("BEHAVIOUR CHANGE worth knowing: after the roll, a whole-site poke at yah-marketing re-renders ONLY /releases and /issues, not / and /404. That is the intended reading of the declared list, but it means the landing page can no longer be refreshed by poking the receiver - it is republished by a full deploy. If someone wants / kept fresh from a feed, add it to routes in cloud.toml.")
82
83use std::collections::BTreeMap;
84use std::path::PathBuf;
85
86use anyhow::{bail, Context, Result};
87use async_trait::async_trait;
88
89use workload_spec::{
90 BlakeHash, BundleLifecycle, MesofactRevalidateReceiver, MesofactServeBundle, Millis,
91};
92
93use super::{ReconcileCtx, Reconciler, RunningWorkload};
94use crate::config::CloudConfig;
95use crate::MirrorConfig;
96
97/// Mirror provider role that opts a mesofact component into the bundle tier.
98pub const SLOT_ROLE: &str = "bundle";
99
100/// Sub-key under `[providers.bundle]` that declares the revalidate receiver
101/// (R330-F12 almanac push endpoint).
102pub const REVALIDATE_KEY: &str = "revalidate";
103
104/// Default idle TTL for an `on-demand` (JIT) bundle when the slot doesn't name
105/// one: five minutes with zero connections before kamaji reaps the process.
106pub const DEFAULT_IDLE_TTL_MS: u64 = 300_000;
107
108/// Every key `[providers.bundle]` is allowed to carry (R556-B14).
109///
110/// The mirror schema's `MirrorProviderSlot` is `additionalProperties: true` by
111/// construction — it is one flattened `BTreeMap<String, toml::Value>` shared by
112/// every provider role, so it cannot know what any single role reads. That
113/// leniency is fine at the schema layer and is the wrong default here: a slot
114/// whose key nobody reads is not "extra metadata", it is an operator's
115/// instruction being ignored. Both instances that motivated this were
116/// **parses clean, deploys, wrong at request time** — a typo'd `prot = 8081`
117/// falls back to kamaji's node default, which post-R599-F12 is whatever OTHER
118/// bundle already holds 8080 on that node; and a `[providers.bundle.env]` block
119/// was, before R556-T12, read by nothing at all while looking exactly like it
120/// worked.
121///
122/// The set is the UNION of what every consumer of this slot reads, not just
123/// what [`BundleSlot::parse`] reads — `plan_ingress` reads four of its own off
124/// the same table (`reconciler::ingress`), and `MirrorProviderSlot::required`
125/// reads `required`. Scoping it to one consumer would reject live mirrors.
126///
127/// `use` / `kind` are absent deliberately: they are captured by the
128/// `MirrorProviderSlot` enum variant itself and never appear in `fields()`.
129const ALLOWED_SLOT_KEYS: &[&str] = &[
130 // BundleSlot::parse
131 "account",
132 "bucket",
133 "env",
134 "idle_ttl_ms",
135 "lifecycle",
136 "machines",
137 "name",
138 "port",
139 REVALIDATE_KEY,
140 "runtime_version",
141 "serve_bins",
142 "serve_build",
143 "verify_serving",
144 "zone",
145 // MirrorProviderSlot::required — F16 placement, read via the slot, not here
146 "required",
147 // reconciler::ingress::plan_ingress — the front-door planner reads the same
148 // table. `machines`, `port` and `zone` are shared with the list above.
149 "machine",
150 "upstream_host",
151];
152
153/// True when this mirror opts its mesofact components into the W272 bundle
154/// tier — i.e. declares a `[providers.bundle]` slot.
155///
156/// Checked at the dispatch layer before the static reconciler runs, so the
157/// two tiers are mutually exclusive per mirror rather than per component.
158pub fn slot_declared(mirror: &MirrorConfig) -> bool {
159 mirror.providers.contains_key(SLOT_ROLE)
160}
161
162/// Resolve a slot-declared binary path against the workspace root.
163///
164/// Slot paths are workspace-relative unless absolute — the operator writes them
165/// in a mirror file, not from a shell cwd.
166pub fn resolve_slot_path(workspace_root: &std::path::Path, path: &std::path::Path) -> PathBuf {
167 if path.is_absolute() {
168 path.to_path_buf()
169 } else {
170 workspace_root.join(path)
171 }
172}
173
174/// Declared-but-absent binaries, as `(label, resolved path)`.
175///
176/// The label is the config coordinate (`providers.bundle.serve_bins.<triple>`)
177/// so a caller can name the exact line an operator has to fix.
178pub fn missing_bins(slot: &BundleSlot, workspace_root: &std::path::Path) -> Vec<(String, PathBuf)> {
179 let serve = slot
180 .serve_bins
181 .iter()
182 .map(|(triple, path)| (format!("providers.{SLOT_ROLE}.serve_bins.{triple}"), path));
183 let feed = slot.revalidate.iter().flat_map(|rv| {
184 rv.feed_bins.iter().map(|(triple, path)| {
185 (
186 format!("providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feed_bins.{triple}"),
187 path,
188 )
189 })
190 });
191 serve
192 .chain(feed)
193 .filter_map(|(label, path)| {
194 let resolved = resolve_slot_path(workspace_root, path);
195 (!resolved.is_file()).then_some((label, resolved))
196 })
197 .collect()
198}
199
200/// True when the bundle tier can actually **serve**, not merely when it has
201/// been declared.
202///
203/// R330-B43 — THIS DISTINCTION IS THE WHOLE POINT, and getting it wrong froze
204/// yah.dev for 19 days. Dispatch used to switch tiers on [`slot_declared`]
205/// alone, so writing a `[providers.bundle]` block instantly disabled the
206/// working `[providers.static]` publish chain — while the bundle tier itself
207/// could not come up, because its `serve_bins` binaries had never been built.
208/// The old path was off, the new path could not turn on, and the site quietly
209/// served stale bytes at HTTP 200 with no error anywhere.
210///
211/// A cut-over must never be able to disable a serving path before its
212/// successor can serve. So the switch keys on the binaries EXISTING, and a
213/// declared-but-unready slot falls back to the static chain (loudly) instead
214/// of taking over and stranding the site.
215///
216/// A slot with no `serve_bins` at all is "ready" here on purpose: that is the
217/// vanilla-runtime shape, which fails later for a different, well-reported
218/// reason rather than being a half-built self-contained bundle.
219///
220/// R746-F2: a `serve_build` slot is likewise ready, and for a stronger reason —
221/// the sync can *produce* the binary it needs by dispatching the declared QED
222/// recipe, so there is no such thing as a path an operator forgot to build.
223/// That is the whole point of the declaration: B43's failure was "declared but
224/// nobody can build it here", and a recipe is exactly the thing that removes
225/// the "here".
226pub fn slot_ready(slot: &BundleSlot, workspace_root: &std::path::Path) -> bool {
227 missing_bins(slot, workspace_root).is_empty()
228}
229
230/// Parsed `[providers.bundle]` slot — everything the sync arm needs that is
231/// *declared* rather than *derived*.
232///
233/// ```toml
234/// [providers.bundle]
235/// use = "cloudflare" # R2 credentials resolve via this provider
236/// bucket = "yah-dev-bundles" # the append-only bundle store
237/// machines = ["us-east-001"] # explicit placement (or `required = {…}`)
238/// name = "yah-marketing" # stable workload handle; defaults to the service name
239/// lifecycle = "keep-alive" # or "on-demand"
240/// idle_ttl_ms = 300000 # on-demand only
241/// runtime_version = "0.8.20" # vanilla bundles only (no serve binary)
242/// serve_bins = { x86_64-unknown-linux-musl = "target/…/mesofact-serve" }
243/// # …or, instead of naming pre-built paths, name the recipe that builds them:
244/// # [providers.bundle.serve_build]
245/// # pipeline = "mesofact-musl"
246/// # binary = "mesofact"
247/// # triples = ["x86_64-unknown-linux-musl"]
248/// zone = "yah.dev" # front door to serving-verify; defaults
249/// # to the service's own domain
250/// verify_serving = true # default; see the field docs
251///
252/// # Environment for the serve process, as source URIs resolved at deploy
253/// # (R556-T12). An SSR route reading a private source needs this or it gets
254/// # a credential-less server on the node.
255/// [providers.bundle.env]
256/// ANALYTICS_R2_ACCESS_KEY = "vault:cloudflare-r2-access-key-id"
257/// ANALYTICS_R2_BUCKET = "yah-analytics" # bare literal: not a secret
258/// ```
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct BundleSlot {
261 /// R2 bucket holding the bundle store. Append-only, blob-deduped.
262 pub bucket: String,
263 /// Cloudflare account id override. `None` → resolve from the workspace's
264 /// cloudflare provider config / `CF_ACCOUNT_ID`.
265 pub account: Option<String>,
266 /// Stable operator-facing workload name. yubaba requires one for a bundle
267 /// deploy: the digest is the *content* and changes on every rebuild, so it
268 /// is not a usable handle for `list` / `stop`.
269 pub name: Option<String>,
270 /// Explicitly named target machines, in deploy order. Empty → fall back to
271 /// the slot's `required = {…}` placement spec.
272 pub machines: Vec<String>,
273 /// Stock runtime version recorded as `runtime = "mesofact/<version>"` for a
274 /// vanilla bundle. Ignored when `serve_bins` is non-empty. `None` → the
275 /// caller's own version.
276 pub runtime_version: Option<String>,
277 /// `<triple> → <path to serve binary>`. Any entry makes this a
278 /// `runtime = "self"` bundle that carries its own serve binaries.
279 pub serve_bins: BTreeMap<String, PathBuf>,
280 /// Build the serve binaries on demand instead of naming pre-built paths
281 /// (R746-F2). Mutually exclusive with `serve_bins`; either one makes this a
282 /// `runtime = "self"` bundle.
283 pub serve_build: Option<BinBuild>,
284 /// How kamaji supervises the served bundle.
285 pub lifecycle: BundleLifecycle,
286 /// Port the served bundle listens on (R599-F12). `None` → kamaji's
287 /// node-wide default (8080), which is only correct while the node hosts a
288 /// single bundle; declare one per workload to put several on a node.
289 pub port: Option<u16>,
290 /// `[providers.bundle.env]` — environment for the **serve** process, as
291 /// `NAME → source URI` (R556-T12).
292 ///
293 /// Values are the source *declaration*, kept verbatim and resolved
294 /// deploy-side by `yah cloud apply` — `vault:<slot>`, `env:<VAR>`, a
295 /// pipe-joined fallback chain of either, or a bare literal for a
296 /// known-non-secret value. Same grammar `~/.yah/qed/secrets.toml` uses, so
297 /// there is one source-URI vocabulary in the camp rather than two.
298 ///
299 /// Parsing stays here and resolution does not: this crate is offline by
300 /// construction (a misconfigured mirror must fail before a build runs), and
301 /// only the syncing machine has the vault. The `RevalidateSlot::mirror_key_env`
302 /// → [`RevalidateSlot::to_workload_payload`] split is the same shape one
303 /// level down.
304 pub env: BTreeMap<String, String>,
305 /// Optional revalidate receiver config (R330-F12). `Some` → the deploy
306 /// also stands up a `mesofact serve --revalidate` process.
307 pub revalidate: Option<RevalidateSlot>,
308 /// Public zone whose front door is checked after a deploy (R703-T7).
309 /// `None` → the service's own `domain`, which is the shape every mirror in
310 /// tree uses; declare one only when the bundle serves a zone that isn't it.
311 ///
312 /// Unlike `[providers.static]`, this is optional: the static slot's `zone`
313 /// is load-bearing for the Worker route and cache purge, whereas here it
314 /// only names what to probe.
315 pub zone: Option<String>,
316 /// Whether a deploy is checked against the live front door (R703-T7).
317 ///
318 /// Defaults to **true**, and the only reason to turn it off is a
319 /// deliberately in-flight front-door migration — with a comment naming the
320 /// ticket. It is declared in config rather than passed as a CLI flag for
321 /// the same reason the static slot's is: switching it off should be a
322 /// reviewable diff, not an invocation habit that quietly becomes permanent.
323 pub verify_serving: bool,
324}
325
326/// A binary the bundle needs, declared as **the recipe that builds it** rather
327/// than as a path someone is expected to have already produced (R746-F2).
328///
329/// ```toml
330/// [providers.bundle.serve_build]
331/// pipeline = "mesofact-musl" # .yah/qed/<name>.toml
332/// binary = "mesofact" # matches a step's `produces.binary`
333/// triples = ["x86_64-unknown-linux-musl"] # what the placed nodes run
334/// ```
335///
336/// # Why this is a declaration and not a fallback
337///
338/// The alternative shape — "use `serve_bins` if the path exists, otherwise
339/// build" — makes the deployed artifact a function of what happens to be on the
340/// operator's disk. Two machines syncing the same mirror would then ship
341/// different binaries, and the one with a stale path would ship the stale one
342/// silently. The mirror says which shape it is; the sync obeys.
343///
344/// Declaring both this and `serve_bins` is refused for the same reason.
345#[derive(Debug, Clone, PartialEq, Eq)]
346pub struct BinBuild {
347 /// QED pipeline name, resolved under `.yah/qed/<pipeline>.toml`.
348 pub pipeline: String,
349 /// Logical binary name, matched against a step's `[[steps.produces]]
350 /// binary`.
351 pub binary: String,
352 /// Target triples to resolve, in declaration order. Non-empty: a build
353 /// declaration that names no target builds nothing.
354 pub triples: Vec<String>,
355}
356
357/// Parsed `[providers.bundle.revalidate]` sub-slot — declares the almanac
358/// revalidate receiver to fork alongside the static bundle server (R330-F12).
359///
360/// ```toml
361/// [providers.bundle.revalidate]
362/// routes = ["/releases"] # allowlist (empty = all routes)
363/// mirror_key_env = "YAH_MARKETING_MIRROR_KEY" # env var holding the bearer
364/// publish_config = "mesofact.config.toml" # default
365/// feeds = ["releases"] # .yah/almanac/<name>.toml to keep fresh
366/// feed_interval_secs = 300 # default
367/// feed_runtime = "almanac-feed/0.8.22" # vanilla: node resolves the fetcher
368/// # …or, for a self-contained bundle, stage it in and name the built paths:
369/// # feed_bins = { x86_64-unknown-linux-musl = "target/…/almanac-feed" }
370/// ```
371#[derive(Debug, Clone, PartialEq, Eq)]
372pub struct RevalidateSlot {
373 /// Routes the receiver accepts pokes for (allowlist).
374 /// Empty → all routes in the workload manifest.
375 ///
376 /// Enforced on the node since R752-B7: kamaji renders this list as one
377 /// `--allow-route` per entry on the receiver's argv, `mesofact serve`
378 /// refuses an explicit poke outside it (403) and narrows a whole-site poke
379 /// to it. Before that it was parsed here, shipped over the wire, and read
380 /// by nobody — declaring it bought exactly nothing. Scoping only: `who may
381 /// poke` is `mirror_key_env`, and the two are independent.
382 pub routes: Vec<String>,
383 /// Env var name holding the tenant bearer secret. Deploy resolves it
384 /// and sets `MESOFACT_MIRROR_KEY` on the receiver process.
385 /// `None` → open receiver (no bearer check).
386 pub mirror_key_env: Option<String>,
387 /// Path to `mesofact.config.toml` with the `[publish]` block, relative
388 /// to the workload directory. `None` → default `"mesofact.config.toml"`.
389 pub publish_config: Option<PathBuf>,
390 /// Almanac feed names (`.yah/almanac/<name>.toml`) the on-node fetch tier
391 /// keeps fresh (R330-F31). Empty → no fetcher, and the receiver re-renders
392 /// whatever data the bundle was built with.
393 pub feeds: Vec<String>,
394 /// Seconds between feed-fetch ticks. `None` → the spec default.
395 pub feed_interval_secs: Option<u64>,
396 /// Per-triple path to the `almanac-feed` binary staged into the bundle as a
397 /// sidecar. The self-contained shape's answer to "how does the fetcher
398 /// reach the node".
399 ///
400 /// Mutually exclusive with [`feed_runtime`](Self::feed_runtime), for the
401 /// same reason `serve_bins` and `serve_build` are: the mirror declares
402 /// which shape it is, and a use-whichever-exists fallback would make the
403 /// deployed binary a function of the syncing machine's disk.
404 pub feed_bins: BTreeMap<String, PathBuf>,
405 /// Runtime ref the fetcher resolves from the node's shared runtime-asset
406 /// cache — `feed_runtime = "almanac-feed/0.8.22"` (R746-T3).
407 ///
408 /// This is the **vanilla** shape's answer, and it is what makes a vanilla
409 /// bundle with a feed tier possible at all: `feed_bins` is a path someone
410 /// must have cross-built, so a bundle that carries no serve binary but
411 /// still needs a sidecar path has only moved the toolchain requirement,
412 /// not removed it.
413 pub feed_runtime: Option<String>,
414}
415
416impl RevalidateSlot {
417 /// Build the [`MesofactRevalidateReceiver`] payload for the workload spec,
418 /// given the env vars resolved at deploy time and the feed definitions read
419 /// from the camp's `.yah/almanac/` tree.
420 ///
421 /// Feed definitions travel by value: reading them is the deploy side's job
422 /// (it is the only participant that has the camp checkout), and the node
423 /// gets a self-contained payload.
424 pub fn to_workload_payload(
425 &self,
426 env: BTreeMap<String, String>,
427 feeds: Vec<workload_spec::AlmanacFeed>,
428 feed_project_prefix: Option<String>,
429 ) -> MesofactRevalidateReceiver {
430 MesofactRevalidateReceiver {
431 routes: self.routes.clone(),
432 publish_config: self
433 .publish_config
434 .as_ref()
435 .map(|p| p.to_string_lossy().into_owned())
436 .unwrap_or_else(|| "mesofact.config.toml".to_string()),
437 mirror_key_env: self.mirror_key_env.clone(),
438 env,
439 feeds,
440 feed_interval_secs: self
441 .feed_interval_secs
442 .unwrap_or(DEFAULT_FEED_INTERVAL_SECS),
443 feed_project_prefix,
444 feed_runtime: self.feed_runtime.clone(),
445 }
446 }
447}
448
449/// Mirrors `workload_spec`'s own default. Duplicated rather than exported
450/// because the spec keeps its serde defaults private; the parse tests below
451/// pin the two together.
452pub const DEFAULT_FEED_INTERVAL_SECS: u64 = 300;
453
454/// Bundle path segment the fetch tier's sidecar binary is staged under —
455/// `bins/<triple>/almanac-feed`, next to `bins/<triple>/serve` — and the
456/// filename it lands under in the node runtime-asset cache when a *vanilla*
457/// bundle resolves it by name instead (R746-T3).
458///
459/// Re-exported from `yah_mesofact_bundle` rather than re-typed: this crate and
460/// kamaji both used to declare their own copy, pinned together only by an
461/// argv-shape test. One `const` in the crate they both already depend on
462/// removes the drift instead of detecting it.
463pub use yah_mesofact_bundle::FEED_BIN as FEED_BIN_NAME;
464
465impl BundleSlot {
466 /// Parse the mirror's `[providers.bundle]` slot.
467 ///
468 /// Every failure names the offending field plus the service and env, so the
469 /// operator gets a file to open rather than a type error. Validation is
470 /// total and offline — nothing here touches the network, so a misconfigured
471 /// mirror fails before a build runs (R330-B5 fail-fast discipline).
472 pub fn parse(mirror: &MirrorConfig, service: &str, env: &str) -> Result<Self> {
473 let slot = mirror.providers.get(SLOT_ROLE).with_context(|| {
474 format!(
475 "mirror has no `providers.{SLOT_ROLE}` slot — required for the W272 bundle tier \
476 (service={service}, env={env})"
477 )
478 })?;
479 let fields = slot.fields();
480
481 // R556-B14. Unknown keys are rejected BEFORE anything is read, so the
482 // operator gets the typo rather than a downstream complaint about the
483 // field the typo was supposed to be. Nearest-match is offered because
484 // the realistic failure is one transposed character, and an error that
485 // only says "unknown" makes the reader diff the docs by eye.
486 for key in fields.keys() {
487 if ALLOWED_SLOT_KEYS.contains(&key.as_str()) {
488 continue;
489 }
490 let hint = nearest_slot_key(key)
491 .map(|k| format!(" — did you mean `{k}`?"))
492 .unwrap_or_default();
493 bail!(
494 "providers.{SLOT_ROLE} has an unknown key `{key}`{hint} (service={service}, \
495 env={env}). Every key this slot reads is one of: {}. An unrecognized key is \
496 refused rather than ignored because the failure it hides is silent: a typo'd \
497 `port` deploys onto whatever bundle already holds the node default, and a \
498 mistyped credential block deploys a serve process with no credentials at all.",
499 ALLOWED_SLOT_KEYS.join(", "),
500 );
501 }
502
503 let bucket = fields
504 .get("bucket")
505 .and_then(|v| v.as_str())
506 .filter(|s| !s.is_empty())
507 .with_context(|| {
508 format!(
509 "providers.{SLOT_ROLE} has no `bucket` — name the R2 bundle store in \
510 .yah/services/{service}/mirrors/{env}.toml"
511 )
512 })?
513 .to_string();
514
515 let account = fields
516 .get("account")
517 .and_then(|v| v.as_str())
518 .filter(|s| !s.is_empty())
519 .map(str::to_string);
520
521 let name = fields
522 .get("name")
523 .and_then(|v| v.as_str())
524 .filter(|s| !s.is_empty())
525 .map(str::to_string);
526
527 let machines = match fields.get("machines") {
528 None => Vec::new(),
529 Some(v) => {
530 let list = v.as_array().with_context(|| {
531 format!(
532 "providers.{SLOT_ROLE}.machines must be an array of machine names \
533 (service={service}, env={env})"
534 )
535 })?;
536 list.iter()
537 .map(|entry| {
538 entry
539 .as_str()
540 .filter(|s| !s.is_empty())
541 .map(str::to_string)
542 .with_context(|| {
543 format!(
544 "providers.{SLOT_ROLE}.machines holds a non-string (or empty) \
545 entry (service={service}, env={env})"
546 )
547 })
548 })
549 .collect::<Result<Vec<_>>>()?
550 }
551 };
552
553 let runtime_version = fields
554 .get("runtime_version")
555 .and_then(|v| v.as_str())
556 .filter(|s| !s.is_empty())
557 .map(str::to_string);
558
559 let serve_bins = match fields.get("serve_bins") {
560 None => BTreeMap::new(),
561 Some(v) => {
562 let table = v.as_table().with_context(|| {
563 format!(
564 "providers.{SLOT_ROLE}.serve_bins must be a table of \
565 <target-triple> = <path> (service={service}, env={env})"
566 )
567 })?;
568 table
569 .iter()
570 .map(|(triple, path)| {
571 let path = path.as_str().filter(|s| !s.is_empty()).with_context(|| {
572 format!(
573 "providers.{SLOT_ROLE}.serve_bins.{triple} must be a non-empty \
574 path (service={service}, env={env})"
575 )
576 })?;
577 Ok((triple.clone(), PathBuf::from(path)))
578 })
579 .collect::<Result<BTreeMap<_, _>>>()?
580 }
581 };
582
583 let serve_build = parse_bin_build(
584 fields.get("serve_build"),
585 &format!("providers.{SLOT_ROLE}.serve_build"),
586 service,
587 env,
588 )?;
589
590 if serve_build.is_some() && !serve_bins.is_empty() {
591 bail!(
592 "providers.{SLOT_ROLE} declares BOTH `serve_bins` and `serve_build` — pick one \
593 (service={service}, env={env}). `serve_bins` names binaries you have already \
594 built; `serve_build` names the QED recipe that builds them. Accepting both \
595 would make the deployed binary depend on what happens to be on the syncing \
596 machine's disk, which is how one operator ships a stale binary while another \
597 ships a fresh one from the same mirror."
598 );
599 }
600
601 // R599-F12. Parsed strictly: a port is either absent or a real one, and
602 // a typo that silently fell back to 8080 would collide with whatever
603 // bundle already holds that port on the node — a failure that surfaces
604 // as the wrong site being served, not as an error.
605 let port = match fields.get("port") {
606 None => None,
607 Some(v) => {
608 let n = v.as_integer().with_context(|| {
609 format!(
610 "providers.{SLOT_ROLE}.port must be an integer TCP port \
611 (service={service}, env={env})"
612 )
613 })?;
614 Some(u16::try_from(n).ok().filter(|p| *p != 0).with_context(|| {
615 format!(
616 "providers.{SLOT_ROLE}.port = {n} is not a usable TCP port \
617 (1..=65535) (service={service}, env={env})"
618 )
619 })?)
620 }
621 };
622
623 // R556-T12. Env for the serve process. Declared as source URIs and
624 // stored verbatim — resolution is the deploy side's job (see the field
625 // docs). Every value is required to be a non-empty string: an empty
626 // source is a var that would silently reach the node unset, which is
627 // the exact failure mode this slot exists to remove.
628 let serve_env = match fields.get("env") {
629 None => BTreeMap::new(),
630 Some(v) => {
631 let table = v.as_table().with_context(|| {
632 format!(
633 "providers.{SLOT_ROLE}.env must be a table of <ENV_NAME> = \
634 \"<source-uri>\" (service={service}, env={env})"
635 )
636 })?;
637 table
638 .iter()
639 .map(|(name, source)| {
640 let source = source
641 .as_str()
642 .filter(|s| !s.trim().is_empty())
643 .with_context(|| {
644 format!(
645 "providers.{SLOT_ROLE}.env.{name} must be a non-empty source \
646 string — \"vault:<slot>\", \"env:<VAR>\", a pipe-joined \
647 chain of either, or a bare literal for a non-secret \
648 (service={service}, env={env})"
649 )
650 })?;
651 Ok((name.clone(), source.to_string()))
652 })
653 .collect::<Result<BTreeMap<_, _>>>()?
654 }
655 };
656
657 let lifecycle = parse_lifecycle(
658 fields.get("lifecycle").and_then(|v| v.as_str()),
659 fields.get("idle_ttl_ms").and_then(|v| v.as_integer()),
660 service,
661 env,
662 )?;
663
664 let revalidate = parse_revalidate_slot(fields, service, env)?;
665
666 let zone = fields
667 .get("zone")
668 .and_then(|v| v.as_str())
669 .filter(|s| !s.is_empty())
670 .map(str::to_string);
671
672 // R703-T7. Parsed strictly rather than `unwrap_or(true)` on a bad type:
673 // `verify_serving = "false"` silently reading as *enabled* is the
674 // friendlier-looking failure, but an operator who typed it believes the
675 // check is off and will be surprised by an apply that fails on a
676 // migration they thought they had silenced.
677 let verify_serving = match fields.get("verify_serving") {
678 None => true,
679 Some(v) => v.as_bool().with_context(|| {
680 format!(
681 "providers.{SLOT_ROLE}.verify_serving must be a boolean \
682 (service={service}, env={env})"
683 )
684 })?,
685 };
686
687 // R746-T3: a vanilla bundle carries no `bins/` by construction, so a
688 // sidecar declared as a PATH has nowhere to be staged into. Caught here
689 // rather than at assembly so the operator gets the mirror file and the
690 // remedy, offline, before a build runs.
691 let slot = Self {
692 bucket,
693 account,
694 name,
695 machines,
696 runtime_version,
697 serve_bins,
698 serve_build,
699 lifecycle,
700 port,
701 env: serve_env,
702 revalidate,
703 zone,
704 verify_serving,
705 };
706 if !slot.is_self_contained() {
707 if let Some(rv) = slot.revalidate.as_ref() {
708 if !rv.feed_bins.is_empty() {
709 anyhow::bail!(
710 "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feed_bins is declared but this is \
711 a VANILLA bundle (no serve_bins / serve_build), which carries no bins/ \
712 at all — replace it with feed_runtime = \"{FEED_BIN_NAME}/<version>\" \
713 and publish that asset once per triple with `yah cloud bundle \
714 publish-runtime` (service={service}, env={env})"
715 );
716 }
717 }
718 }
719 Ok(slot)
720 }
721
722 /// The zone whose front door a deploy of this bundle is checked against:
723 /// the slot's `zone`, else the service's own domain.
724 pub fn serving_zone<'a>(&'a self, service_domain: &'a str) -> &'a str {
725 self.zone.as_deref().unwrap_or(service_domain)
726 }
727
728 /// Stable workload handle: the slot's `name`, else the service name.
729 pub fn workload_name<'a>(&'a self, service: &'a str) -> &'a str {
730 self.name.as_deref().unwrap_or(service)
731 }
732
733 /// True when the assembled bundle carries its own serve binaries
734 /// (`runtime = "self"`) rather than resolving a stock node runtime asset.
735 ///
736 /// Keyed on the *declaration*, not on what is on disk: a `serve_build` slot
737 /// is self-contained before its binary has ever been built, because the
738 /// mirror said so. Deriving the shape from disk state instead is the bug
739 /// this relay exists to remove — it makes a bundle's shape depend on which
740 /// machine ran the sync.
741 pub fn is_self_contained(&self) -> bool {
742 !self.serve_bins.is_empty() || self.serve_build.is_some()
743 }
744
745 /// Build the `{digest, runtime, lifecycle}` triple a `mesofact-static`
746 /// workload carries once its bundle is published.
747 ///
748 /// `runtime` wire-mirrors `yah_mesofact_bundle::BundleRuntime`, so it is
749 /// taken from the manifest the assembler actually wrote rather than
750 /// re-derived here — the manifest is what the node will verify against.
751 ///
752 /// `env` is the **resolved** serve environment, passed in rather than read
753 /// off `self.env`: this crate holds source URIs, and only the syncing
754 /// machine can turn a `vault:<slot>` into a value. Same by-value handoff
755 /// [`RevalidateSlot::to_workload_payload`] takes, for the same reason —
756 /// the node must never see a keystore slot name (R556-T12).
757 pub fn serve_bundle(
758 &self,
759 digest: &str,
760 runtime: &str,
761 env: BTreeMap<String, String>,
762 ) -> MesofactServeBundle {
763 MesofactServeBundle {
764 digest: BlakeHash(digest.to_string()),
765 runtime: runtime.to_string(),
766 lifecycle: self.lifecycle.clone(),
767 // R599-F12: the slot's declared `port`, or `None` for kamaji's
768 // node-wide default. (@Ashguard:blade parked a `None` here to
769 // unblock the camp's build while this ticket was mid-flight; this
770 // is the real threading it named.)
771 port: self.port,
772 env,
773 }
774 }
775}
776
777/// Closest [`ALLOWED_SLOT_KEYS`] entry to `key`, or `None` when nothing is
778/// close enough to be worth suggesting (R556-B14).
779///
780/// The threshold scales with the key's length — one edit for a short key like
781/// `port`, two for a longer one — so `prot` suggests `port` while an entirely
782/// invented key suggests nothing. A confidently wrong suggestion is worse than
783/// none: it sends the operator to fix a line that was never the problem.
784fn nearest_slot_key(key: &str) -> Option<&'static str> {
785 let budget = if key.len() <= 5 { 1 } else { 2 };
786 ALLOWED_SLOT_KEYS
787 .iter()
788 .map(|candidate| (edit_distance(key, candidate), *candidate))
789 .filter(|(d, _)| *d <= budget)
790 .min()
791 .map(|(_, candidate)| candidate)
792}
793
794/// Optimal string alignment (Damerau-Levenshtein restricted to adjacent
795/// transpositions), three-row DP. Byte-wise: every key in this grammar is
796/// ASCII, and a multi-byte typo is not a case worth carrying a char-vec for.
797///
798/// Transposition counts as ONE edit, not two, and that is the whole reason to
799/// carry the extra row: `prot` for `port` is the motivating typo of R556-B14,
800/// and plain Levenshtein scores it 2 — far enough away that a threshold tight
801/// enough to avoid nonsense suggestions would refuse to suggest the one that
802/// matters.
803fn edit_distance(a: &str, b: &str) -> usize {
804 let (a, b) = (a.as_bytes(), b.as_bytes());
805 let mut prev2 = vec![0usize; b.len() + 1];
806 let mut prev: Vec<usize> = (0..=b.len()).collect();
807 let mut cur = vec![0usize; b.len() + 1];
808 for (i, &ac) in a.iter().enumerate() {
809 cur[0] = i + 1;
810 for (j, &bc) in b.iter().enumerate() {
811 let mut d = (prev[j] + usize::from(ac != bc))
812 .min(prev[j + 1] + 1)
813 .min(cur[j] + 1);
814 if i > 0 && j > 0 && ac == b[j - 1] && a[i - 1] == bc {
815 d = d.min(prev2[j - 1] + 1);
816 }
817 cur[j + 1] = d;
818 }
819 std::mem::swap(&mut prev2, &mut prev);
820 std::mem::swap(&mut prev, &mut cur);
821 }
822 prev[b.len()]
823}
824
825/// Parse a `[…serve_build]`-shaped table into a [`BinBuild`] (R746-F2).
826///
827/// Taken as a helper rather than inlined because the revalidate tier's
828/// `feed_bins` has the identical "a path someone must have built" problem and
829/// will want the identical declaration once a recipe produces `almanac-feed`.
830/// Every message names the full config coordinate so the operator gets a line
831/// to open.
832fn parse_bin_build(
833 value: Option<&toml::Value>,
834 label: &str,
835 service: &str,
836 env: &str,
837) -> Result<Option<BinBuild>> {
838 let Some(value) = value else {
839 return Ok(None);
840 };
841 let table = value.as_table().with_context(|| {
842 format!("{label} must be a table of pipeline/binary/triples (service={service}, env={env})")
843 })?;
844
845 let pipeline = table
846 .get("pipeline")
847 .and_then(|v| v.as_str())
848 .filter(|s| !s.is_empty())
849 .with_context(|| {
850 format!(
851 "{label}.pipeline must name a QED pipeline (.yah/qed/<name>.toml) \
852 (service={service}, env={env})"
853 )
854 })?
855 .to_string();
856
857 let binary = table
858 .get("binary")
859 .and_then(|v| v.as_str())
860 .filter(|s| !s.is_empty())
861 .with_context(|| {
862 format!(
863 "{label}.binary must name the produced binary — it is matched against the \
864 pipeline's `[[steps.produces]] binary` (service={service}, env={env})"
865 )
866 })?
867 .to_string();
868
869 let triples = table
870 .get("triples")
871 .and_then(|v| v.as_array())
872 .with_context(|| {
873 format!("{label}.triples must be an array of target triples (service={service}, env={env})")
874 })?
875 .iter()
876 .map(|entry| {
877 entry
878 .as_str()
879 .filter(|s| !s.is_empty())
880 .map(str::to_string)
881 .with_context(|| {
882 format!("{label}.triples holds a non-string (or empty) entry (service={service}, env={env})")
883 })
884 })
885 .collect::<Result<Vec<_>>>()?;
886
887 if triples.is_empty() {
888 bail!(
889 "{label}.triples is empty — a build declaration that names no target builds \
890 nothing, and the bundle would assemble with no serve binary at all \
891 (service={service}, env={env})"
892 );
893 }
894
895 Ok(Some(BinBuild {
896 pipeline,
897 binary,
898 triples,
899 }))
900}
901
902/// `lifecycle = "keep-alive" | "on-demand"` (+ `idle_ttl_ms` for the latter).
903fn parse_lifecycle(
904 raw: Option<&str>,
905 idle_ttl_ms: Option<i64>,
906 service: &str,
907 env: &str,
908) -> Result<BundleLifecycle> {
909 match raw.unwrap_or("keep-alive") {
910 "keep-alive" | "keepalive" => {
911 if idle_ttl_ms.is_some() {
912 bail!(
913 "providers.{SLOT_ROLE}.idle_ttl_ms only applies to `lifecycle = \"on-demand\"` \
914 — a keep-alive bundle is never reaped (service={service}, env={env})"
915 );
916 }
917 Ok(BundleLifecycle::KeepAlive)
918 }
919 "on-demand" | "ondemand" | "jit" => {
920 let ttl = idle_ttl_ms.unwrap_or(DEFAULT_IDLE_TTL_MS as i64);
921 if ttl <= 0 {
922 bail!(
923 "providers.{SLOT_ROLE}.idle_ttl_ms must be positive, got {ttl} \
924 (service={service}, env={env})"
925 );
926 }
927 Ok(BundleLifecycle::OnDemand {
928 idle_ttl: Millis::from_ms(ttl as u64),
929 })
930 }
931 other => bail!(
932 "providers.{SLOT_ROLE}.lifecycle must be \"keep-alive\" or \"on-demand\", got \
933 {other:?} (service={service}, env={env})"
934 ),
935 }
936}
937
938/// Parse the optional `[providers.bundle.revalidate]` sub-table.
939///
940/// `None` → no revalidate receiver declared (the common case). `Some` → the
941/// deploy also stands up a `mesofact serve --revalidate` process.
942fn parse_revalidate_slot(
943 fields: &BTreeMap<String, toml::Value>,
944 service: &str,
945 env: &str,
946) -> Result<Option<RevalidateSlot>> {
947 let sub = match fields.get(REVALIDATE_KEY) {
948 None => return Ok(None),
949 Some(v) => v.as_table().with_context(|| {
950 format!(
951 "providers.{SLOT_ROLE}.{REVALIDATE_KEY} must be a TOML table \
952 (service={service}, env={env})"
953 )
954 })?,
955 };
956
957 let routes = match sub.get("routes") {
958 None => Vec::new(),
959 Some(v) => {
960 let list = v.as_array().with_context(|| {
961 format!(
962 "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.routes must be an array of route \
963 patterns (service={service}, env={env})"
964 )
965 })?;
966 list.iter()
967 .map(|entry| {
968 entry
969 .as_str()
970 .filter(|s| !s.is_empty())
971 .map(str::to_string)
972 .with_context(|| {
973 format!(
974 "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.routes holds a non-string \
975 (or empty) entry (service={service}, env={env})"
976 )
977 })
978 })
979 .collect::<Result<Vec<_>>>()?
980 }
981 };
982
983 let mirror_key_env = sub
984 .get("mirror_key_env")
985 .and_then(|v| v.as_str())
986 .filter(|s| !s.is_empty())
987 .map(str::to_string);
988
989 let publish_config = sub
990 .get("publish_config")
991 .and_then(|v| v.as_str())
992 .filter(|s| !s.is_empty())
993 .map(PathBuf::from);
994
995 // ── Feed-fetch tier (R330-F31) ──────────────────────────────────────────
996 let feeds = match sub.get("feeds") {
997 None => Vec::new(),
998 Some(v) => {
999 let list = v.as_array().with_context(|| {
1000 format!(
1001 "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feeds must be an array of almanac \
1002 feed names (service={service}, env={env})"
1003 )
1004 })?;
1005 list.iter()
1006 .map(|entry| {
1007 entry
1008 .as_str()
1009 .filter(|s| !s.is_empty())
1010 .map(str::to_string)
1011 .with_context(|| {
1012 format!(
1013 "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feeds holds a non-string \
1014 (or empty) entry (service={service}, env={env})"
1015 )
1016 })
1017 })
1018 .collect::<Result<Vec<_>>>()?
1019 }
1020 };
1021
1022 let feed_interval_secs = match sub.get("feed_interval_secs") {
1023 None => None,
1024 Some(v) => {
1025 let secs = v.as_integer().filter(|n| *n > 0).with_context(|| {
1026 format!(
1027 "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feed_interval_secs must be a positive \
1028 integer number of seconds (service={service}, env={env})"
1029 )
1030 })?;
1031 Some(secs as u64)
1032 }
1033 };
1034
1035 let feed_bins = match sub.get("feed_bins") {
1036 None => BTreeMap::new(),
1037 Some(v) => {
1038 let table = v.as_table().with_context(|| {
1039 format!(
1040 "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feed_bins must be a table of \
1041 <target-triple> = <path> (service={service}, env={env})"
1042 )
1043 })?;
1044 table
1045 .iter()
1046 .map(|(triple, path)| {
1047 let p = path.as_str().filter(|s| !s.is_empty()).with_context(|| {
1048 format!(
1049 "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feed_bins.{triple} must be \
1050 a non-empty path string (service={service}, env={env})"
1051 )
1052 })?;
1053 Ok((triple.clone(), PathBuf::from(p)))
1054 })
1055 .collect::<Result<BTreeMap<_, _>>>()?
1056 }
1057 };
1058
1059 // R746-T3: the vanilla shape's fetcher. A ref, not a path — the node
1060 // resolves it from the shared runtime-asset cache the same way it resolves
1061 // `serve`, so no cross-built binary has to exist on the syncing machine.
1062 let feed_runtime = match sub.get("feed_runtime") {
1063 None => None,
1064 Some(v) => {
1065 let s = v.as_str().filter(|s| !s.is_empty()).with_context(|| {
1066 format!(
1067 "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feed_runtime must be a non-empty \
1068 runtime reference like \"{FEED_BIN_NAME}/0.8.22\" (service={service}, \
1069 env={env})"
1070 )
1071 })?;
1072 // Parse offline so a typo fails the apply with a file to open,
1073 // rather than a node failing to resolve it twenty minutes later.
1074 yah_mesofact_bundle::RuntimeRef::parse(s).with_context(|| {
1075 format!(
1076 "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feed_runtime (service={service}, \
1077 env={env})"
1078 )
1079 })?;
1080 Some(s.to_string())
1081 }
1082 };
1083
1084 // Declared, never inferred — the same rule serve_bins/serve_build follow.
1085 // "Use the path if it happens to exist, else the ref" would make the
1086 // deployed fetcher a function of the syncing machine's disk.
1087 if !feed_bins.is_empty() && feed_runtime.is_some() {
1088 anyhow::bail!(
1089 "providers.{SLOT_ROLE}.{REVALIDATE_KEY} declares BOTH feed_bins and feed_runtime — \
1090 pick one: feed_bins stages the `{FEED_BIN_NAME}` fetcher into the bundle (the \
1091 self-contained shape), feed_runtime resolves it from the node's runtime-asset \
1092 cache (the vanilla shape) (service={service}, env={env})"
1093 );
1094 }
1095
1096 // Declaring feeds without shipping the fetcher is the failure that looks
1097 // like success: the deploy goes green, the receiver serves, and the data
1098 // never moves again. Catch it here, offline, with the file to edit.
1099 if !feeds.is_empty() && feed_bins.is_empty() && feed_runtime.is_none() {
1100 anyhow::bail!(
1101 "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feeds declares {} feed(s) but neither \
1102 feed_bins nor feed_runtime — the node has no way to get the `{FEED_BIN_NAME}` \
1103 fetcher, so nothing would ever refresh them (service={service}, env={env})",
1104 feeds.len()
1105 );
1106 }
1107
1108 Ok(Some(RevalidateSlot {
1109 routes,
1110 mirror_key_env,
1111 publish_config,
1112 feeds,
1113 feed_interval_secs,
1114 feed_bins,
1115 feed_runtime,
1116 }))
1117}
1118
1119/// Resolve the machines a published bundle deploys to, in deploy order.
1120///
1121/// Two declaration forms, checked in that order:
1122/// 1. `machines = ["us-east-001", …]` — explicit, ordered, and the shape to
1123/// prefer while a bundle binds loopback (F10: one bundle per node, passway
1124/// co-located), because *which* nodes serve is then an operator decision
1125/// rather than a scheduler outcome.
1126/// 2. `required = { regions = […], mesh_tags = […] }` — F16 placement, same
1127/// grammar [`super::mesofact_runner::resolve_runner_machine`] uses. Resolves
1128/// to exactly one machine.
1129///
1130/// An undeclared / unresolvable placement is an error, not an empty deploy —
1131/// silently publishing a bundle nobody serves is the failure mode this avoids.
1132pub fn resolve_bundle_machines<'a>(
1133 cfg: &'a CloudConfig,
1134 mirror: &MirrorConfig,
1135 slot: &BundleSlot,
1136 service: &str,
1137 env: &str,
1138) -> Result<Vec<&'a crate::MachineConfig>> {
1139 if !slot.machines.is_empty() {
1140 return slot
1141 .machines
1142 .iter()
1143 .map(|name| {
1144 cfg.machine(name).with_context(|| {
1145 format!(
1146 "providers.{SLOT_ROLE}.machines names {name:?}, which is not declared in \
1147 .yah/infra/machines/ (service={service}, env={env})"
1148 )
1149 })
1150 })
1151 .collect();
1152 }
1153
1154 let required = mirror
1155 .providers
1156 .get(SLOT_ROLE)
1157 .and_then(|s| s.required())
1158 .filter(|r| !r.is_unconstrained())
1159 .with_context(|| {
1160 format!(
1161 "providers.{SLOT_ROLE} declares neither `machines = [...]` nor a constrained \
1162 `required = {{ … }}` placement — a bundle must name the nodes that serve it \
1163 (service={service}, env={env})"
1164 )
1165 })?;
1166
1167 let machine = cfg.resolve_machine(&required).with_context(|| {
1168 format!(
1169 "F16 placement: no machine satisfies providers.{SLOT_ROLE}.required ({}) — check \
1170 .yah/services/{service}/mirrors/{env}.toml against .yah/infra/machines/*.toml",
1171 required.describe(),
1172 )
1173 })?;
1174 Ok(vec![machine])
1175}
1176
1177/// Desktop-side (offline) half of the bundle tier: validate the mirror's
1178/// declaration and bail with a pointer at the CLI.
1179///
1180/// The real chain — build, assemble, publish, deploy — runs at the apply layer
1181/// where [`CloudConfig`] is in hand. This exists so a desktop bring-up of a
1182/// bundle-tier mirror reports a *configuration* verdict instead of "no
1183/// reconciler wired".
1184pub struct MesofactBundleReconciler;
1185
1186impl MesofactBundleReconciler {
1187 pub fn new() -> Self {
1188 Self
1189 }
1190}
1191
1192impl Default for MesofactBundleReconciler {
1193 fn default() -> Self {
1194 Self::new()
1195 }
1196}
1197
1198#[async_trait]
1199impl Reconciler for MesofactBundleReconciler {
1200 fn kind(&self) -> &'static str {
1201 super::mesofact_static::WORKLOAD_KIND
1202 }
1203
1204 async fn up(&self, ctx: ReconcileCtx<'_>) -> Result<RunningWorkload> {
1205 let slot = BundleSlot::parse(ctx.mirror, &ctx.service.name, ctx.env)?;
1206 // Name the DECLARED shape, not a count. R746-F2 added a third shape, and
1207 // a bare `0 serve binaries` reads identically for "vanilla, resolves the
1208 // node's stock runtime" and "self-contained, builds its binary on
1209 // demand" — two different deploys.
1210 let shape = match (&slot.serve_build, slot.serve_bins.len()) {
1211 (Some(build), _) => format!(
1212 "self-contained, serve binary built by QED recipe `{}` for [{}]",
1213 build.pipeline,
1214 build.triples.join(", "),
1215 ),
1216 (None, 0) => format!(
1217 "vanilla, node resolves runtime mesofact/{}",
1218 slot.runtime_version.as_deref().unwrap_or("<caller version>"),
1219 ),
1220 (None, n) => format!("self-contained, {n} declared serve binary path(s)"),
1221 };
1222 bail!(
1223 "bundle tier validated (bucket={}, workload={}, {shape}) for service={}, \
1224 env={}, but the sync arm runs at the apply layer — deploy with \
1225 `yah cloud mirror up {} --env {}` (machine placement needs the workspace's \
1226 machine set, which a desktop bring-up does not load)",
1227 slot.bucket,
1228 slot.workload_name(&ctx.service.name),
1229 ctx.service.name,
1230 ctx.env,
1231 ctx.service.name,
1232 ctx.env,
1233 )
1234 }
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239 use super::*;
1240 use crate::config::{MachineConfig, MirrorProviderSlot, MirrorShape, TopologyConfig};
1241 use std::path::PathBuf;
1242
1243 fn mirror_from(slots: BTreeMap<String, MirrorProviderSlot>) -> MirrorConfig {
1244 MirrorConfig {
1245 schema_version: 1,
1246 shape: MirrorShape::SingleMachine,
1247 providers: slots,
1248 ingress: Default::default(),
1249 ingress_machines: Vec::new(),
1250 drivers: Default::default(),
1251 asset_aliases: BTreeMap::new(),
1252 }
1253 }
1254
1255 /// Build a mirror whose `[providers.bundle]` slot is exactly `slot_toml`.
1256 fn mirror_with(slot_toml: &str) -> MirrorConfig {
1257 let slot: MirrorProviderSlot = toml::from_str(slot_toml).unwrap();
1258 let mut providers = BTreeMap::new();
1259 providers.insert(SLOT_ROLE.to_string(), slot);
1260 mirror_from(providers)
1261 }
1262
1263 fn machine(name: &str, region: &str) -> MachineConfig {
1264 MachineConfig {
1265 name: name.into(),
1266 provider: "static".into(),
1267 location: None,
1268 server_type: None,
1269 hosts_mirrors: vec![],
1270 mesh_tags: vec![],
1271 region: Some(region.into()),
1272 zone: None,
1273 arch: Some("x86_64".into()),
1274 bucket: None,
1275 vendor: None,
1276 nickname: None,
1277 legacy_hostkey_fingerprint: None,
1278 registration: Default::default(),
1279 ssh_keys: vec![],
1280 cloudflared: None,
1281 hosts_operator_bridge: false,
1282 connect: None,
1283 allocatable: None,
1284 taints: vec![],
1285 sovereign_group: None,
1286 sovereign_role: None,
1287 }
1288 }
1289
1290 fn cfg_with(machines: Vec<MachineConfig>) -> CloudConfig {
1291 CloudConfig {
1292 workspace_root: PathBuf::new(),
1293 machines,
1294 providers: vec![],
1295 machine_origins: BTreeMap::new(),
1296 provider_origins: BTreeMap::new(),
1297 services: BTreeMap::new(),
1298 domains: BTreeMap::new(),
1299 legacy_mirrors: vec![],
1300 workloads: vec![],
1301 topology: TopologyConfig::default(),
1302 legacy_services: vec![],
1303 }
1304 }
1305
1306 #[test]
1307 fn slot_declared_keys_off_the_bundle_role() {
1308 assert!(slot_declared(&mirror_with(
1309 r#"use = "cloudflare"
1310bucket = "b""#
1311 )));
1312 assert!(!slot_declared(&mirror_from(BTreeMap::new())));
1313 }
1314
1315 /// R330-B43 regression pin. This is the exact shape that froze yah.dev:
1316 /// a fully-valid `[providers.bundle]` slot whose serve binary was never
1317 /// built. `slot_declared` says yes (it only reads config), so dispatching
1318 /// on it alone handed the component to a tier that could not come up while
1319 /// taking the working static chain out of the picture. `slot_ready` is what
1320 /// the dispatch gate must ask instead.
1321 #[test]
1322 fn a_declared_slot_whose_serve_bin_is_absent_is_not_ready() {
1323 let root = tempfile::tempdir().unwrap();
1324 let mirror = mirror_with(
1325 r#"
1326use = "cloudflare"
1327bucket = "yah-dev"
1328
1329[serve_bins]
1330x86_64-unknown-linux-musl = "target/x86_64-unknown-linux-musl/release/mesofact"
1331"#,
1332 );
1333 let slot = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap();
1334
1335 assert!(slot_declared(&mirror), "config declares the slot");
1336 assert!(
1337 !slot_ready(&slot, root.path()),
1338 "but it cannot serve — the binary does not exist"
1339 );
1340
1341 let missing = missing_bins(&slot, root.path());
1342 assert_eq!(missing.len(), 1);
1343 assert_eq!(
1344 missing[0].0, "providers.bundle.serve_bins.x86_64-unknown-linux-musl",
1345 "the label must name the exact config line to fix"
1346 );
1347 }
1348
1349 #[test]
1350 fn a_slot_becomes_ready_once_its_bins_exist() {
1351 let root = tempfile::tempdir().unwrap();
1352 let bin = root.path().join("target/x86_64-unknown-linux-musl/release");
1353 std::fs::create_dir_all(&bin).unwrap();
1354 std::fs::write(bin.join("mesofact"), b"#!/bin/sh\n").unwrap();
1355
1356 let mirror = mirror_with(
1357 r#"
1358use = "cloudflare"
1359bucket = "yah-dev"
1360
1361[serve_bins]
1362x86_64-unknown-linux-musl = "target/x86_64-unknown-linux-musl/release/mesofact"
1363"#,
1364 );
1365 let slot = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap();
1366 assert!(slot_ready(&slot, root.path()));
1367 assert!(missing_bins(&slot, root.path()).is_empty());
1368 }
1369
1370 /// R746-F2. The shape B43 could not express: self-contained, declared, and
1371 /// buildable *from any machine* — so it is ready without anyone having a
1372 /// binary on disk, and there is no `missing:` line to print because nothing
1373 /// was ever promised to be there.
1374 #[test]
1375 fn a_serve_build_slot_is_self_contained_and_ready_with_no_binary_on_disk() {
1376 let root = tempfile::tempdir().unwrap();
1377 let mirror = mirror_with(
1378 r#"
1379use = "cloudflare"
1380bucket = "yah-dev"
1381
1382[serve_build]
1383pipeline = "mesofact-musl"
1384binary = "mesofact"
1385triples = ["x86_64-unknown-linux-musl"]
1386"#,
1387 );
1388 let slot = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap();
1389
1390 let build = slot.serve_build.as_ref().expect("serve_build parsed");
1391 assert_eq!(build.pipeline, "mesofact-musl");
1392 assert_eq!(build.binary, "mesofact");
1393 assert_eq!(build.triples, vec!["x86_64-unknown-linux-musl".to_string()]);
1394
1395 assert!(slot.is_self_contained(), "declared shape, not disk state");
1396 assert!(slot_ready(&slot, root.path()));
1397 assert!(missing_bins(&slot, root.path()).is_empty());
1398 }
1399
1400 /// R746-F2 verify #1, at the only layer that can pin it offline: a vanilla
1401 /// slot carries no build declaration at all, so the sync has nothing to
1402 /// dispatch. The cheapness of the vanilla path is structural, not a
1403 /// heuristic someone has to keep true.
1404 #[test]
1405 fn a_vanilla_slot_declares_no_build_so_a_sync_has_nothing_to_dispatch() {
1406 let mirror = mirror_with(
1407 r#"
1408use = "cloudflare"
1409bucket = "yah-dev"
1410runtime_version = "0.8.22"
1411"#,
1412 );
1413 let slot = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap();
1414 assert!(slot.serve_build.is_none());
1415 assert!(slot.serve_bins.is_empty());
1416 assert!(!slot.is_self_contained());
1417 assert_eq!(slot.runtime_version.as_deref(), Some("0.8.22"));
1418 }
1419
1420 /// The shape must stay DECLARED, never derived — so the two ways of naming
1421 /// a serve binary are mutually exclusive rather than one falling back to
1422 /// the other. A fallback would make the deployed binary a function of the
1423 /// syncing machine's disk.
1424 #[test]
1425 fn serve_bins_and_serve_build_together_are_refused() {
1426 let mirror = mirror_with(
1427 r#"
1428use = "cloudflare"
1429bucket = "yah-dev"
1430
1431[serve_bins]
1432x86_64-unknown-linux-musl = "some/path/mesofact"
1433
1434[serve_build]
1435pipeline = "mesofact-musl"
1436binary = "mesofact"
1437triples = ["x86_64-unknown-linux-musl"]
1438"#,
1439 );
1440 let err = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap_err();
1441 let msg = err.to_string();
1442 assert!(msg.contains("BOTH `serve_bins` and `serve_build`"), "{msg}");
1443 }
1444
1445 /// Each field is load-bearing, so each absence is refused by name rather
1446 /// than defaulted into a build that produces nothing.
1447 #[test]
1448 fn a_serve_build_missing_a_field_is_refused_naming_the_coordinate() {
1449 let cases = [
1450 (
1451 r#"[serve_build]
1452binary = "mesofact"
1453triples = ["x86_64-unknown-linux-musl"]"#,
1454 "serve_build.pipeline",
1455 ),
1456 (
1457 r#"[serve_build]
1458pipeline = "mesofact-musl"
1459triples = ["x86_64-unknown-linux-musl"]"#,
1460 "serve_build.binary",
1461 ),
1462 (
1463 r#"[serve_build]
1464pipeline = "mesofact-musl"
1465binary = "mesofact""#,
1466 "serve_build.triples",
1467 ),
1468 (
1469 r#"[serve_build]
1470pipeline = "mesofact-musl"
1471binary = "mesofact"
1472triples = []"#,
1473 "serve_build.triples is empty",
1474 ),
1475 ];
1476 for (fragment, expected) in cases {
1477 let mirror = mirror_with(&format!(
1478 "use = \"cloudflare\"\nbucket = \"yah-dev\"\n\n{fragment}\n"
1479 ));
1480 let err = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap_err();
1481 let msg = format!("{err:#}");
1482 assert!(
1483 msg.contains(expected),
1484 "expected {expected:?} in error, got: {msg}"
1485 );
1486 }
1487 }
1488
1489 /// A declared feed tier is part of "can it serve" — R330-F31 stages the
1490 /// fetcher as a sidecar, so a missing feed_bin strands the feed tier the
1491 /// same way a missing serve_bin strands the server.
1492 #[test]
1493 fn a_missing_feed_bin_also_blocks_readiness() {
1494 let root = tempfile::tempdir().unwrap();
1495 let bin = root.path().join("target/musl");
1496 std::fs::create_dir_all(&bin).unwrap();
1497 std::fs::write(bin.join("mesofact"), b"x").unwrap();
1498
1499 let mirror = mirror_with(
1500 r#"
1501use = "cloudflare"
1502bucket = "yah-dev"
1503
1504[serve_bins]
1505x86_64-unknown-linux-musl = "target/musl/mesofact"
1506
1507[revalidate]
1508routes = ["/releases"]
1509feeds = ["releases"]
1510
1511[revalidate.feed_bins]
1512x86_64-unknown-linux-musl = "target/musl/almanac-feed"
1513"#,
1514 );
1515 let slot = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap();
1516 let missing = missing_bins(&slot, root.path());
1517 assert_eq!(missing.len(), 1, "only the feed binary is absent");
1518 assert!(missing[0].0.contains("revalidate.feed_bins"));
1519 assert!(!slot_ready(&slot, root.path()));
1520 }
1521
1522 /// A vanilla-runtime slot declares no binaries at all. That is a different
1523 /// shape, not a half-built one, so it stays "ready" here and fails later
1524 /// with its own specific message rather than being silently downgraded.
1525 #[test]
1526 fn a_vanilla_slot_declaring_no_bins_is_ready() {
1527 let root = tempfile::tempdir().unwrap();
1528 let mirror = mirror_with(
1529 r#"use = "cloudflare"
1530bucket = "b""#,
1531 );
1532 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1533 assert!(!slot.is_self_contained());
1534 assert!(slot_ready(&slot, root.path()));
1535 }
1536
1537 #[test]
1538 fn parses_a_self_contained_keep_alive_slot() {
1539 let mirror = mirror_with(
1540 r#"
1541use = "cloudflare"
1542bucket = "yah-dev-bundles"
1543machines = ["us-east-001"]
1544name = "yah-marketing"
1545
1546[serve_bins]
1547x86_64-unknown-linux-musl = "target/x86_64-unknown-linux-musl/release/mesofact-serve"
1548"#,
1549 );
1550 let slot = BundleSlot::parse(&mirror, "yah-marketing", "ha").unwrap();
1551 assert_eq!(slot.bucket, "yah-dev-bundles");
1552 assert_eq!(slot.machines, vec!["us-east-001".to_string()]);
1553 assert_eq!(slot.workload_name("yah-marketing"), "yah-marketing");
1554 assert!(slot.is_self_contained());
1555 assert_eq!(slot.lifecycle, BundleLifecycle::KeepAlive);
1556 }
1557
1558 #[test]
1559 fn workload_name_falls_back_to_the_service_name() {
1560 let mirror = mirror_with(
1561 r#"use = "cloudflare"
1562bucket = "b""#,
1563 );
1564 let slot = BundleSlot::parse(&mirror, "scrabcake", "ha").unwrap();
1565 assert_eq!(slot.workload_name("scrabcake"), "scrabcake");
1566 assert!(!slot.is_self_contained());
1567 }
1568
1569 #[test]
1570 fn on_demand_takes_the_default_idle_ttl() {
1571 let mirror = mirror_with(
1572 r#"use = "cloudflare"
1573bucket = "b"
1574lifecycle = "on-demand""#,
1575 );
1576 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1577 assert_eq!(
1578 slot.lifecycle,
1579 BundleLifecycle::OnDemand {
1580 idle_ttl: Millis::from_ms(DEFAULT_IDLE_TTL_MS)
1581 }
1582 );
1583 }
1584
1585 #[test]
1586 fn on_demand_honors_an_explicit_idle_ttl() {
1587 let mirror = mirror_with(
1588 r#"use = "cloudflare"
1589bucket = "b"
1590lifecycle = "on-demand"
1591idle_ttl_ms = 15000"#,
1592 );
1593 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1594 assert_eq!(
1595 slot.lifecycle,
1596 BundleLifecycle::OnDemand {
1597 idle_ttl: Millis::from_ms(15_000)
1598 }
1599 );
1600 }
1601
1602 /// An idle TTL on a keep-alive bundle is a config mistake that would
1603 /// otherwise be silently ignored — the process is never reaped.
1604 #[test]
1605 fn idle_ttl_on_a_keep_alive_slot_is_rejected() {
1606 let mirror = mirror_with(
1607 r#"use = "cloudflare"
1608bucket = "b"
1609idle_ttl_ms = 15000"#,
1610 );
1611 let err = BundleSlot::parse(&mirror, "s", "e")
1612 .unwrap_err()
1613 .to_string();
1614 assert!(err.contains("idle_ttl_ms"), "{err}");
1615 assert!(err.contains("on-demand"), "{err}");
1616 }
1617
1618 #[test]
1619 fn unknown_lifecycle_names_the_legal_values() {
1620 let mirror = mirror_with(
1621 r#"use = "cloudflare"
1622bucket = "b"
1623lifecycle = "serverless""#,
1624 );
1625 let err = BundleSlot::parse(&mirror, "s", "e")
1626 .unwrap_err()
1627 .to_string();
1628 assert!(err.contains("keep-alive"), "{err}");
1629 assert!(err.contains("on-demand"), "{err}");
1630 }
1631
1632 /// R599-F12: the declared serving port reaches the workload spec. Without
1633 /// it every bundle rides kamaji's node-wide default, so a node can host
1634 /// exactly one.
1635 #[test]
1636 fn a_declared_port_reaches_the_serve_bundle() {
1637 let slot = BundleSlot::parse(
1638 &mirror_with(
1639 r#"use = "cloudflare"
1640bucket = "b"
1641port = 8081"#,
1642 ),
1643 "s",
1644 "e",
1645 )
1646 .unwrap();
1647 assert_eq!(slot.port, Some(8081));
1648 assert_eq!(
1649 slot.serve_bundle("a".repeat(64).as_str(), "self", BTreeMap::new())
1650 .port,
1651 Some(8081)
1652 );
1653
1654 // Absent → kamaji's node default, the pre-R599-F12 behaviour.
1655 let bare = BundleSlot::parse(
1656 &mirror_with(
1657 r#"use = "cloudflare"
1658bucket = "b""#,
1659 ),
1660 "s",
1661 "e",
1662 )
1663 .unwrap();
1664 assert_eq!(bare.port, None);
1665 assert_eq!(
1666 bare.serve_bundle("a".repeat(64).as_str(), "self", BTreeMap::new())
1667 .port,
1668 None
1669 );
1670 }
1671
1672 /// R556-B14: a misspelled key fails the parse naming itself, rather than
1673 /// deploying a wrong-but-plausible workload.
1674 ///
1675 /// `prot = 8081` is the motivating instance: it parses clean today, the
1676 /// port falls back to kamaji's node-wide default, and post-R599-F12 that
1677 /// default is whatever OTHER bundle already holds 8080 on the node. The
1678 /// operator sees the wrong site served, with nothing in any log naming the
1679 /// typo.
1680 #[test]
1681 fn an_unknown_slot_key_is_rejected_naming_the_key() {
1682 let err = BundleSlot::parse(
1683 &mirror_with(
1684 r#"use = "cloudflare"
1685bucket = "b"
1686prot = 8081"#,
1687 ),
1688 "yah-marketing",
1689 "cloud",
1690 )
1691 .unwrap_err()
1692 .to_string();
1693 assert!(err.contains("prot"), "the error must name the typo: {err}");
1694 assert!(
1695 err.contains("did you mean `port`"),
1696 "one transposed character is the realistic failure — suggest the \
1697 fix rather than making the operator diff the docs: {err}"
1698 );
1699 }
1700
1701 /// The suggester's distance metric counts a transposition as ONE edit.
1702 /// Plain Levenshtein scores `prot`→`port` at 2, which is far enough away
1703 /// that any threshold tight enough to suppress nonsense suggestions would
1704 /// also suppress the single typo this ticket was filed about.
1705 #[test]
1706 fn the_key_suggester_treats_a_transposition_as_one_edit() {
1707 assert_eq!(edit_distance("prot", "port"), 1);
1708 assert_eq!(edit_distance("bukcet", "bucket"), 1);
1709 assert_eq!(nearest_slot_key("prot"), Some("port"));
1710 assert_eq!(nearest_slot_key("bucket"), Some("bucket"));
1711 assert_eq!(nearest_slot_key("zzzzzzzzzzzzzz"), None);
1712 }
1713
1714 /// No suggestion when nothing is close. A confidently wrong hint sends the
1715 /// operator to edit a line that was never the problem.
1716 #[test]
1717 fn an_unrecognizable_slot_key_is_rejected_without_a_bogus_suggestion() {
1718 let err = BundleSlot::parse(
1719 &mirror_with(
1720 r#"use = "cloudflare"
1721bucket = "b"
1722ingress_tunnel_hostname = "analytics.yah.dev""#,
1723 ),
1724 "yah-analytics",
1725 "cloud",
1726 )
1727 .unwrap_err()
1728 .to_string();
1729 assert!(err.contains("ingress_tunnel_hostname"), "{err}");
1730 assert!(!err.contains("did you mean"), "{err}");
1731 }
1732
1733 /// R556-B14's regression criterion: the allowed set is the UNION of every
1734 /// consumer's reads, not just `BundleSlot::parse`'s. `plan_ingress` reads
1735 /// `machine` / `machines` / `port` / `upstream_host` off this same table
1736 /// and `MirrorProviderSlot::required` reads `required` — scoping the set to
1737 /// one consumer would reject the live yah-marketing mirror, which carries
1738 /// `upstream_host`.
1739 #[test]
1740 fn keys_read_by_other_consumers_of_this_slot_are_allowed() {
1741 // Every non-comment key of .yah/services/yah-marketing/mirrors/cloud.toml's
1742 // [providers.bundle] block, as of R556-B14.
1743 let slot = BundleSlot::parse(
1744 &mirror_with(
1745 r#"use = "cloudflare"
1746verify_serving = false
1747bucket = "yah-dev"
1748name = "yah-marketing"
1749machines = ["us-east-001"]
1750port = 8080
1751zone = "yah.dev"
1752upstream_host = "100.64.0.3"
1753lifecycle = "keep-alive"
1754runtime_version = "0.8.23"
1755
1756[revalidate]
1757routes = ["/releases", "/issues"]
1758mirror_key_env = "YAH_MARKETING_MIRROR_KEY"
1759feeds = ["releases", "yah-desktop"]
1760feed_interval_secs = 5
1761feed_runtime = "almanac-feed/0.8.22""#,
1762 ),
1763 "yah-marketing",
1764 "cloud",
1765 )
1766 .unwrap();
1767 assert_eq!(slot.bucket, "yah-dev");
1768 assert_eq!(slot.port, Some(8080));
1769 assert!(!slot.verify_serving);
1770
1771 // …and the F16 placement form, whose `required` is read through the
1772 // slot rather than by `parse`.
1773 BundleSlot::parse(
1774 &mirror_with(
1775 r#"use = "cloudflare"
1776bucket = "yah-dev"
1777
1778[required]
1779regions = ["us-east"]"#,
1780 ),
1781 "s",
1782 "e",
1783 )
1784 .unwrap();
1785 }
1786
1787 /// R556-T12: `[providers.bundle.env]` parses into source URIs, kept
1788 /// verbatim. Resolution is deliberately NOT done here — this crate is
1789 /// offline by construction and only the syncing machine holds the vault.
1790 #[test]
1791 fn env_sources_are_parsed_verbatim_and_not_resolved() {
1792 let slot = BundleSlot::parse(
1793 &mirror_with(
1794 r#"use = "cloudflare"
1795bucket = "b"
1796
1797[env]
1798ANALYTICS_R2_ACCESS_KEY = "vault:cloudflare-r2-access-key-id"
1799ANALYTICS_R2_SECRET_KEY = "vault:cloudflare-r2-secret-key|env:R2_SECRET"
1800ANALYTICS_R2_BUCKET = "yah-analytics""#,
1801 ),
1802 "s",
1803 "e",
1804 )
1805 .unwrap();
1806 assert_eq!(slot.env.len(), 3);
1807 assert_eq!(
1808 slot.env.get("ANALYTICS_R2_ACCESS_KEY").map(String::as_str),
1809 Some("vault:cloudflare-r2-access-key-id"),
1810 "the SOURCE is stored, never a resolved secret — this struct is \
1811 parsed on any machine and printed by diagnostics",
1812 );
1813 assert_eq!(
1814 slot.env.get("ANALYTICS_R2_SECRET_KEY").map(String::as_str),
1815 Some("vault:cloudflare-r2-secret-key|env:R2_SECRET"),
1816 "a pipe-joined fallback chain survives parsing intact",
1817 );
1818 assert_eq!(
1819 slot.env.get("ANALYTICS_R2_BUCKET").map(String::as_str),
1820 Some("yah-analytics"),
1821 "a bare literal is a legitimate non-secret source",
1822 );
1823
1824 // Absent block → empty, and the serve bundle carries whatever the
1825 // deploy resolved (nothing, here).
1826 let bare = BundleSlot::parse(
1827 &mirror_with("use = \"cloudflare\"\nbucket = \"b\""),
1828 "s",
1829 "e",
1830 )
1831 .unwrap();
1832 assert!(bare.env.is_empty());
1833 }
1834
1835 /// The resolved env reaches the workload payload — the leg that was missing
1836 /// entirely (R556-T12). Before it, `MesofactServeBundle` had nowhere to put
1837 /// credentials, so kamaji forked the serve process with an empty
1838 /// environment and an SSR route reading a private source 500'd per request.
1839 #[test]
1840 fn resolved_env_reaches_the_serve_bundle() {
1841 let slot = BundleSlot::parse(
1842 &mirror_with(
1843 r#"use = "cloudflare"
1844bucket = "b"
1845
1846[env]
1847ANALYTICS_R2_ACCESS_KEY = "vault:cloudflare-r2-access-key-id""#,
1848 ),
1849 "s",
1850 "e",
1851 )
1852 .unwrap();
1853
1854 let mut resolved = BTreeMap::new();
1855 resolved.insert("ANALYTICS_R2_ACCESS_KEY".to_string(), "AKIA".to_string());
1856 let sb = slot.serve_bundle(&"a".repeat(64), "self", resolved);
1857
1858 assert_eq!(
1859 sb.env.get("ANALYTICS_R2_ACCESS_KEY").map(String::as_str),
1860 Some("AKIA"),
1861 "the node receives the VALUE; a keystore slot name must never \
1862 cross the wire",
1863 );
1864 }
1865
1866 /// An env entry that is not a usable source string must fail the parse.
1867 /// The whole point of the slot is that a credential problem surfaces at
1868 /// sync, in milliseconds, rather than as a per-request 500 on a node.
1869 #[test]
1870 fn an_unusable_env_source_is_rejected() {
1871 for bad in [
1872 "[env]\nFOO = \"\"",
1873 "[env]\nFOO = \" \"",
1874 "[env]\nFOO = 8081",
1875 "env = \"vault:x\"",
1876 ] {
1877 let toml = format!("use = \"cloudflare\"\nbucket = \"b\"\n{bad}");
1878 let err = BundleSlot::parse(&mirror_with(&toml), "yah-marketing", "ha")
1879 .unwrap_err()
1880 .to_string();
1881 assert!(err.contains("env"), "{bad}: {err}");
1882 }
1883 }
1884
1885 /// A port typo must fail the parse, not silently fall back to 8080 — that
1886 /// fallback would land the workload on whatever bundle already holds the
1887 /// default port, and surface as the wrong site being served.
1888 #[test]
1889 fn an_unusable_port_is_rejected_rather_than_defaulted() {
1890 for bad in ["port = 0", "port = 70000", r#"port = "8081""#] {
1891 let toml = format!("use = \"cloudflare\"\nbucket = \"b\"\n{bad}");
1892 let err = BundleSlot::parse(&mirror_with(&toml), "yah-marketing", "ha")
1893 .unwrap_err()
1894 .to_string();
1895 assert!(err.contains("port"), "{bad}: {err}");
1896 }
1897 }
1898
1899 // ── serving verification (R703-T7) ──────────────────────────────────────
1900
1901 /// The check is on by default and probes the service's own domain, so a
1902 /// mirror that says nothing about it still gets verified.
1903 #[test]
1904 fn serving_verification_is_on_by_default_and_targets_the_service_domain() {
1905 let slot = BundleSlot::parse(
1906 &mirror_with(
1907 r#"use = "cloudflare"
1908bucket = "b""#,
1909 ),
1910 "yah-marketing",
1911 "cloud",
1912 )
1913 .unwrap();
1914 assert!(slot.verify_serving);
1915 assert_eq!(slot.zone, None);
1916 assert_eq!(slot.serving_zone("yah.dev"), "yah.dev");
1917 }
1918
1919 #[test]
1920 fn an_explicit_zone_overrides_the_service_domain() {
1921 let slot = BundleSlot::parse(
1922 &mirror_with(
1923 r#"use = "cloudflare"
1924bucket = "b"
1925zone = "staging.yah.dev""#,
1926 ),
1927 "yah-marketing",
1928 "cloud",
1929 )
1930 .unwrap();
1931 assert_eq!(slot.serving_zone("yah.dev"), "staging.yah.dev");
1932 }
1933
1934 #[test]
1935 fn verify_serving_can_be_switched_off_for_an_in_flight_migration() {
1936 let slot = BundleSlot::parse(
1937 &mirror_with(
1938 r#"use = "cloudflare"
1939bucket = "b"
1940verify_serving = false"#,
1941 ),
1942 "s",
1943 "e",
1944 )
1945 .unwrap();
1946 assert!(!slot.verify_serving);
1947 }
1948
1949 /// `verify_serving = "false"` reading as *enabled* would leave an operator
1950 /// certain they had silenced a check that then fails their apply.
1951 #[test]
1952 fn a_non_boolean_verify_serving_is_rejected_rather_than_defaulted() {
1953 let err = BundleSlot::parse(
1954 &mirror_with(
1955 r#"use = "cloudflare"
1956bucket = "b"
1957verify_serving = "false""#,
1958 ),
1959 "yah-marketing",
1960 "cloud",
1961 )
1962 .unwrap_err()
1963 .to_string();
1964 assert!(err.contains("verify_serving"), "{err}");
1965 assert!(err.contains("boolean"), "{err}");
1966 }
1967
1968 #[test]
1969 fn a_slot_without_a_bucket_names_the_file_to_edit() {
1970 let mirror = mirror_with(r#"use = "cloudflare""#);
1971 let err = BundleSlot::parse(&mirror, "yah-marketing", "ha")
1972 .unwrap_err()
1973 .to_string();
1974 assert!(err.contains("bucket"), "{err}");
1975 assert!(
1976 err.contains(".yah/services/yah-marketing/mirrors/ha.toml"),
1977 "{err}"
1978 );
1979 }
1980
1981 #[test]
1982 fn explicit_machines_resolve_in_declaration_order() {
1983 let mirror = mirror_with(
1984 r#"use = "cloudflare"
1985bucket = "b"
1986machines = ["us-south-001", "us-east-001"]"#,
1987 );
1988 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1989 let cfg = cfg_with(vec![
1990 machine("us-east-001", "us-east"),
1991 machine("us-south-001", "us-south"),
1992 ]);
1993 let resolved = resolve_bundle_machines(&cfg, &mirror, &slot, "s", "e").unwrap();
1994 let names: Vec<_> = resolved.iter().map(|m| m.name.as_str()).collect();
1995 assert_eq!(names, vec!["us-south-001", "us-east-001"]);
1996 }
1997
1998 #[test]
1999 fn an_undeclared_machine_is_an_error_not_a_skip() {
2000 let mirror = mirror_with(
2001 r#"use = "cloudflare"
2002bucket = "b"
2003machines = ["us-west-999"]"#,
2004 );
2005 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
2006 let cfg = cfg_with(vec![machine("us-east-001", "us-east")]);
2007 let err = resolve_bundle_machines(&cfg, &mirror, &slot, "s", "e")
2008 .unwrap_err()
2009 .to_string();
2010 assert!(err.contains("us-west-999"), "{err}");
2011 assert!(err.contains(".yah/infra/machines/"), "{err}");
2012 }
2013
2014 #[test]
2015 fn falls_back_to_f16_required_placement() {
2016 let mirror = mirror_with(
2017 r#"use = "cloudflare"
2018bucket = "b"
2019required = { regions = ["us-east"] }"#,
2020 );
2021 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
2022 let cfg = cfg_with(vec![
2023 machine("us-east-001", "us-east"),
2024 machine("us-south-001", "us-south"),
2025 ]);
2026 let resolved = resolve_bundle_machines(&cfg, &mirror, &slot, "s", "e").unwrap();
2027 assert_eq!(resolved.len(), 1);
2028 assert_eq!(resolved[0].name, "us-east-001");
2029 }
2030
2031 /// Publishing a bundle no node serves is the silent failure this guards.
2032 #[test]
2033 fn no_placement_at_all_is_rejected() {
2034 let mirror = mirror_with(
2035 r#"use = "cloudflare"
2036bucket = "b""#,
2037 );
2038 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
2039 let cfg = cfg_with(vec![machine("us-east-001", "us-east")]);
2040 let err = resolve_bundle_machines(&cfg, &mirror, &slot, "s", "e")
2041 .unwrap_err()
2042 .to_string();
2043 assert!(err.contains("machines"), "{err}");
2044 assert!(err.contains("required"), "{err}");
2045 }
2046
2047 #[test]
2048 fn serve_bundle_carries_the_manifest_runtime_verbatim() {
2049 let mirror = mirror_with(
2050 r#"use = "cloudflare"
2051bucket = "b""#,
2052 );
2053 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
2054 let digest = "a".repeat(64);
2055 let sb = slot.serve_bundle(&digest, "mesofact/0.8.20", BTreeMap::new());
2056 assert_eq!(sb.digest.0, digest);
2057 assert_eq!(sb.runtime, "mesofact/0.8.20");
2058 assert_eq!(sb.lifecycle, BundleLifecycle::KeepAlive);
2059 }
2060
2061 // ── revalidate receiver parsing (R330-F12) ──────────────────────────────
2062
2063 #[test]
2064 fn parses_revalidate_slot_with_routes_and_mirror_key_env() {
2065 let mirror = mirror_with(
2066 r#"use = "cloudflare"
2067bucket = "b"
2068machines = ["us-east-001"]
2069
2070[revalidate]
2071routes = ["/releases"]
2072mirror_key_env = "YAH_MARKETING_MIRROR_KEY"
2073"#,
2074 );
2075 let slot = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap();
2076 let rv = slot.revalidate.expect("revalidate slot should parse");
2077 assert_eq!(rv.routes, vec!["/releases"]);
2078 assert_eq!(
2079 rv.mirror_key_env.as_deref(),
2080 Some("YAH_MARKETING_MIRROR_KEY")
2081 );
2082 assert!(rv.publish_config.is_none());
2083 }
2084
2085 #[test]
2086 fn parses_revalidate_with_custom_publish_config() {
2087 let mirror = mirror_with(
2088 r#"use = "cloudflare"
2089bucket = "b"
2090machines = ["us-east-001"]
2091
2092[revalidate]
2093routes = ["/releases", "/downloads"]
2094publish_config = "custom-mesofact.config.toml"
2095"#,
2096 );
2097 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
2098 let rv = slot.revalidate.unwrap();
2099 assert_eq!(rv.routes.len(), 2);
2100 assert_eq!(
2101 rv.publish_config.unwrap(),
2102 PathBuf::from("custom-mesofact.config.toml")
2103 );
2104 assert!(rv.mirror_key_env.is_none());
2105 }
2106
2107 #[test]
2108 fn no_revalidate_when_section_absent() {
2109 let mirror = mirror_with(
2110 r#"use = "cloudflare"
2111bucket = "b""#,
2112 );
2113 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
2114 assert!(slot.revalidate.is_none());
2115 }
2116
2117 #[test]
2118 fn revalidate_with_empty_routes_is_open_allowlist() {
2119 let mirror = mirror_with(
2120 r#"use = "cloudflare"
2121bucket = "b"
2122
2123[revalidate]
2124mirror_key_env = "BEARER"
2125"#,
2126 );
2127 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
2128 let rv = slot.revalidate.unwrap();
2129 assert!(rv.routes.is_empty());
2130 assert_eq!(rv.mirror_key_env.as_deref(), Some("BEARER"));
2131 }
2132
2133 #[test]
2134 fn to_workload_payload_maps_fields() {
2135 let slot = RevalidateSlot {
2136 routes: vec!["/releases".into()],
2137 mirror_key_env: Some("MY_KEY".into()),
2138 publish_config: Some(PathBuf::from("cfg.toml")),
2139 ..bare_revalidate_slot()
2140 };
2141 let mut env = BTreeMap::new();
2142 env.insert("MESOFACT_S3_ACCESS_KEY_ID".into(), "ak".into());
2143 env.insert("MESOFACT_MIRROR_KEY".into(), "bearer1".into());
2144 let payload = slot.to_workload_payload(env.clone(), vec![], None);
2145 assert_eq!(payload.routes, vec!["/releases"]);
2146 assert_eq!(payload.publish_config, "cfg.toml");
2147 assert_eq!(payload.mirror_key_env.as_deref(), Some("MY_KEY"));
2148 assert_eq!(payload.env.get("MESOFACT_S3_ACCESS_KEY_ID").unwrap(), "ak");
2149 assert_eq!(payload.env.get("MESOFACT_MIRROR_KEY").unwrap(), "bearer1");
2150 }
2151
2152 #[test]
2153 fn to_workload_payload_defaults_publish_config() {
2154 let payload = bare_revalidate_slot().to_workload_payload(BTreeMap::new(), vec![], None);
2155 assert_eq!(payload.publish_config, "mesofact.config.toml");
2156 assert!(payload.mirror_key_env.is_none());
2157 assert!(payload.routes.is_empty());
2158 }
2159
2160 // ── Feed-fetch tier (R330-F31) ──────────────────────────────────────────
2161
2162 fn bare_revalidate_slot() -> RevalidateSlot {
2163 RevalidateSlot {
2164 routes: vec![],
2165 mirror_key_env: None,
2166 publish_config: None,
2167 feeds: vec![],
2168 feed_interval_secs: None,
2169 feed_bins: BTreeMap::new(),
2170 feed_runtime: None,
2171 }
2172 }
2173
2174 #[test]
2175 fn parses_feed_tier_declaration() {
2176 // A staged sidecar belongs to a self-contained bundle, so this fixture
2177 // declares one — R746-T3 refuses feed_bins on a vanilla slot.
2178 let mirror = mirror_with(
2179 r#"use = "cloudflare"
2180bucket = "b"
2181
2182[serve_bins]
2183x86_64-unknown-linux-musl = "target/x86_64-unknown-linux-musl/release/mesofact"
2184
2185[revalidate]
2186routes = ["/releases"]
2187feeds = ["releases", "yah-desktop"]
2188feed_interval_secs = 60
2189
2190[revalidate.feed_bins]
2191x86_64-unknown-linux-musl = "target/x86_64-unknown-linux-musl/release/almanac-feed"
2192"#,
2193 );
2194 let rv = BundleSlot::parse(&mirror, "s", "e")
2195 .unwrap()
2196 .revalidate
2197 .unwrap();
2198 assert_eq!(rv.feeds, vec!["releases", "yah-desktop"]);
2199 assert_eq!(rv.feed_interval_secs, Some(60));
2200 assert_eq!(rv.feed_bins.len(), 1);
2201 assert!(rv.feed_bins["x86_64-unknown-linux-musl"].ends_with("almanac-feed"));
2202 assert!(rv.feed_runtime.is_none());
2203 }
2204
2205 /// R746-T3: the vanilla shape's feed tier. This is the declaration that
2206 /// makes yah-marketing deployable from a machine with no Rust toolchain —
2207 /// no path to a cross-built fetcher anywhere in it.
2208 #[test]
2209 fn a_vanilla_slot_declares_its_fetcher_as_a_runtime_ref() {
2210 let mirror = mirror_with(
2211 r#"use = "cloudflare"
2212bucket = "b"
2213runtime_version = "0.8.22"
2214
2215[revalidate]
2216routes = ["/releases"]
2217feeds = ["releases"]
2218feed_runtime = "almanac-feed/0.8.22"
2219"#,
2220 );
2221 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
2222 assert!(!slot.is_self_contained());
2223 let rv = slot.revalidate.unwrap();
2224 assert_eq!(rv.feed_runtime.as_deref(), Some("almanac-feed/0.8.22"));
2225 assert!(rv.feed_bins.is_empty());
2226 }
2227
2228 /// The whole point: a vanilla slot with a feed tier is READY with nothing
2229 /// on disk. `feed_bins` would have kept the cross-built-binary requirement
2230 /// alive on the syncing machine while pretending the bundle was vanilla.
2231 #[test]
2232 fn a_vanilla_feed_tier_needs_no_binary_on_the_syncing_machine() {
2233 let mirror = mirror_with(
2234 r#"use = "cloudflare"
2235bucket = "b"
2236runtime_version = "0.8.22"
2237
2238[revalidate]
2239feeds = ["releases"]
2240feed_runtime = "almanac-feed/0.8.22"
2241"#,
2242 );
2243 let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
2244 let empty = std::path::Path::new("/nonexistent-workspace-root");
2245 assert!(missing_bins(&slot, empty).is_empty());
2246 assert!(slot_ready(&slot, empty));
2247 }
2248
2249 /// Declared, never inferred — the rule serve_bins/serve_build already
2250 /// follow. "Use the path if it exists, else the ref" would make the
2251 /// deployed fetcher a function of the syncing machine's disk.
2252 #[test]
2253 fn feed_bins_and_feed_runtime_together_are_refused() {
2254 let mirror = mirror_with(
2255 r#"use = "cloudflare"
2256bucket = "b"
2257
2258[serve_bins]
2259x86_64-unknown-linux-musl = "target/mesofact"
2260
2261[revalidate]
2262feeds = ["releases"]
2263feed_runtime = "almanac-feed/0.8.22"
2264
2265[revalidate.feed_bins]
2266x86_64-unknown-linux-musl = "target/almanac-feed"
2267"#,
2268 );
2269 let err = BundleSlot::parse(&mirror, "s", "e").unwrap_err().to_string();
2270 assert!(err.contains("feed_bins") && err.contains("feed_runtime"), "got {err}");
2271 }
2272
2273 /// A vanilla bundle carries no `bins/`, so a path-declared sidecar has
2274 /// nowhere to be staged. Caught at parse, with the remedy in the message.
2275 #[test]
2276 fn feed_bins_on_a_vanilla_slot_is_refused_naming_feed_runtime() {
2277 let mirror = mirror_with(
2278 r#"use = "cloudflare"
2279bucket = "b"
2280runtime_version = "0.8.22"
2281
2282[revalidate]
2283feeds = ["releases"]
2284
2285[revalidate.feed_bins]
2286x86_64-unknown-linux-musl = "target/almanac-feed"
2287"#,
2288 );
2289 let err = BundleSlot::parse(&mirror, "s", "e").unwrap_err().to_string();
2290 assert!(err.contains("VANILLA"), "got {err}");
2291 assert!(err.contains("feed_runtime"), "got {err}");
2292 }
2293
2294 /// A typo in the ref fails the apply offline, not on a node twenty minutes
2295 /// into a deploy.
2296 #[test]
2297 fn an_unparseable_feed_runtime_is_refused_at_parse() {
2298 let mirror = mirror_with(
2299 r#"use = "cloudflare"
2300bucket = "b"
2301runtime_version = "0.8.22"
2302
2303[revalidate]
2304feeds = ["releases"]
2305feed_runtime = "almanac-feed"
2306"#,
2307 );
2308 let err = BundleSlot::parse(&mirror, "s", "e").unwrap_err().to_string();
2309 assert!(err.contains("feed_runtime"), "got {err}");
2310 }
2311
2312 /// The payload the node acts on must carry the ref, or kamaji has nothing
2313 /// to resolve and the fetcher silently never forks.
2314 #[test]
2315 fn the_feed_runtime_ref_reaches_the_workload_payload() {
2316 let mut slot = bare_revalidate_slot();
2317 slot.feed_runtime = Some("almanac-feed/0.8.22".to_string());
2318 let payload = slot.to_workload_payload(BTreeMap::new(), vec![], None);
2319 assert_eq!(payload.feed_runtime.as_deref(), Some("almanac-feed/0.8.22"));
2320 }
2321
2322 /// A receiver with no feed tier is the existing shape and must keep parsing
2323 /// — the fetcher is additive, not a new requirement on every mirror.
2324 #[test]
2325 fn revalidate_without_a_feed_tier_stays_empty() {
2326 let mirror = mirror_with(
2327 r#"use = "cloudflare"
2328bucket = "b"
2329
2330[revalidate]
2331routes = ["/releases"]
2332"#,
2333 );
2334 let rv = BundleSlot::parse(&mirror, "s", "e")
2335 .unwrap()
2336 .revalidate
2337 .unwrap();
2338 assert!(rv.feeds.is_empty());
2339 assert!(rv.feed_bins.is_empty());
2340 assert_eq!(rv.feed_interval_secs, None);
2341 }
2342
2343 /// Feeds declared with no fetcher binary is the silent-staleness trap: the
2344 /// deploy would go green and the data would never move. Fail at parse.
2345 #[test]
2346 fn feeds_without_feed_bins_is_rejected() {
2347 let mirror = mirror_with(
2348 r#"use = "cloudflare"
2349bucket = "b"
2350
2351[revalidate]
2352feeds = ["releases"]
2353"#,
2354 );
2355 let err = BundleSlot::parse(&mirror, "s", "e")
2356 .unwrap_err()
2357 .to_string();
2358 assert!(err.contains("feed_bins"), "got {err}");
2359 assert!(err.contains("feed_runtime"), "got {err}");
2360 assert!(err.contains(FEED_BIN_NAME), "got {err}");
2361 }
2362
2363 #[test]
2364 fn zero_feed_interval_is_rejected() {
2365 let mirror = mirror_with(
2366 r#"use = "cloudflare"
2367bucket = "b"
2368
2369[revalidate]
2370feed_interval_secs = 0
2371"#,
2372 );
2373 let err = BundleSlot::parse(&mirror, "s", "e")
2374 .unwrap_err()
2375 .to_string();
2376 assert!(err.contains("positive integer"), "got {err}");
2377 }
2378
2379 /// The reconciler's default and the workload-spec serde default are two
2380 /// copies of one number; this pins them together.
2381 #[test]
2382 fn feed_interval_default_matches_the_workload_spec_default() {
2383 let payload = bare_revalidate_slot().to_workload_payload(BTreeMap::new(), vec![], None);
2384 assert_eq!(payload.feed_interval_secs, DEFAULT_FEED_INTERVAL_SECS);
2385
2386 let from_spec: workload_spec::MesofactRevalidateReceiver =
2387 serde_json::from_str("{}").expect("all receiver fields have serde defaults");
2388 assert_eq!(from_spec.feed_interval_secs, DEFAULT_FEED_INTERVAL_SECS);
2389 }
2390}