Skip to main content

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
53use std::collections::BTreeMap;
54use std::path::PathBuf;
55
56use anyhow::{bail, Context, Result};
57use async_trait::async_trait;
58
59use workload_spec::{
60    BlakeHash, BundleLifecycle, MesofactRevalidateReceiver, MesofactServeBundle, Millis,
61};
62
63use super::{ReconcileCtx, Reconciler, RunningWorkload};
64use crate::config::CloudConfig;
65use crate::MirrorConfig;
66
67/// Mirror provider role that opts a mesofact component into the bundle tier.
68pub const SLOT_ROLE: &str = "bundle";
69
70/// Sub-key under `[providers.bundle]` that declares the revalidate receiver
71/// (R330-F12 almanac push endpoint).
72pub const REVALIDATE_KEY: &str = "revalidate";
73
74/// Default idle TTL for an `on-demand` (JIT) bundle when the slot doesn't name
75/// one: five minutes with zero connections before kamaji reaps the process.
76pub const DEFAULT_IDLE_TTL_MS: u64 = 300_000;
77
78/// True when this mirror opts its mesofact components into the W272 bundle
79/// tier — i.e. declares a `[providers.bundle]` slot.
80///
81/// Checked at the dispatch layer before the static reconciler runs, so the
82/// two tiers are mutually exclusive per mirror rather than per component.
83pub fn slot_declared(mirror: &MirrorConfig) -> bool {
84    mirror.providers.contains_key(SLOT_ROLE)
85}
86
87/// Resolve a slot-declared binary path against the workspace root.
88///
89/// Slot paths are workspace-relative unless absolute — the operator writes them
90/// in a mirror file, not from a shell cwd.
91pub fn resolve_slot_path(workspace_root: &std::path::Path, path: &std::path::Path) -> PathBuf {
92    if path.is_absolute() {
93        path.to_path_buf()
94    } else {
95        workspace_root.join(path)
96    }
97}
98
99/// Declared-but-absent binaries, as `(label, resolved path)`.
100///
101/// The label is the config coordinate (`providers.bundle.serve_bins.<triple>`)
102/// so a caller can name the exact line an operator has to fix.
103pub fn missing_bins(slot: &BundleSlot, workspace_root: &std::path::Path) -> Vec<(String, PathBuf)> {
104    let serve = slot
105        .serve_bins
106        .iter()
107        .map(|(triple, path)| (format!("providers.{SLOT_ROLE}.serve_bins.{triple}"), path));
108    let feed = slot.revalidate.iter().flat_map(|rv| {
109        rv.feed_bins.iter().map(|(triple, path)| {
110            (
111                format!("providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feed_bins.{triple}"),
112                path,
113            )
114        })
115    });
116    serve
117        .chain(feed)
118        .filter_map(|(label, path)| {
119            let resolved = resolve_slot_path(workspace_root, path);
120            (!resolved.is_file()).then_some((label, resolved))
121        })
122        .collect()
123}
124
125/// True when the bundle tier can actually **serve**, not merely when it has
126/// been declared.
127///
128/// R330-B43 — THIS DISTINCTION IS THE WHOLE POINT, and getting it wrong froze
129/// yah.dev for 19 days. Dispatch used to switch tiers on [`slot_declared`]
130/// alone, so writing a `[providers.bundle]` block instantly disabled the
131/// working `[providers.static]` publish chain — while the bundle tier itself
132/// could not come up, because its `serve_bins` binaries had never been built.
133/// The old path was off, the new path could not turn on, and the site quietly
134/// served stale bytes at HTTP 200 with no error anywhere.
135///
136/// A cut-over must never be able to disable a serving path before its
137/// successor can serve. So the switch keys on the binaries EXISTING, and a
138/// declared-but-unready slot falls back to the static chain (loudly) instead
139/// of taking over and stranding the site.
140///
141/// A slot with no `serve_bins` at all is "ready" here on purpose: that is the
142/// vanilla-runtime shape, which fails later for a different, well-reported
143/// reason rather than being a half-built self-contained bundle.
144pub fn slot_ready(slot: &BundleSlot, workspace_root: &std::path::Path) -> bool {
145    missing_bins(slot, workspace_root).is_empty()
146}
147
148/// Parsed `[providers.bundle]` slot — everything the sync arm needs that is
149/// *declared* rather than *derived*.
150///
151/// ```toml
152/// [providers.bundle]
153/// use = "cloudflare"                  # R2 credentials resolve via this provider
154/// bucket = "yah-dev-bundles"          # the append-only bundle store
155/// machines = ["us-east-001"]          # explicit placement (or `required = {…}`)
156/// name = "yah-marketing"              # stable workload handle; defaults to the service name
157/// lifecycle = "keep-alive"            # or "on-demand"
158/// idle_ttl_ms = 300000                # on-demand only
159/// runtime_version = "0.8.20"          # vanilla bundles only (no serve_bins)
160/// serve_bins = { x86_64-unknown-linux-musl = "target/…/mesofact-serve" }
161/// zone = "yah.dev"                    # front door to serving-verify; defaults
162///                                     # to the service's own domain
163/// verify_serving = true               # default; see the field docs
164/// ```
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct BundleSlot {
167    /// R2 bucket holding the bundle store. Append-only, blob-deduped.
168    pub bucket: String,
169    /// Cloudflare account id override. `None` → resolve from the workspace's
170    /// cloudflare provider config / `CF_ACCOUNT_ID`.
171    pub account: Option<String>,
172    /// Stable operator-facing workload name. yubaba requires one for a bundle
173    /// deploy: the digest is the *content* and changes on every rebuild, so it
174    /// is not a usable handle for `list` / `stop`.
175    pub name: Option<String>,
176    /// Explicitly named target machines, in deploy order. Empty → fall back to
177    /// the slot's `required = {…}` placement spec.
178    pub machines: Vec<String>,
179    /// Stock runtime version recorded as `runtime = "mesofact/<version>"` for a
180    /// vanilla bundle. Ignored when `serve_bins` is non-empty. `None` → the
181    /// caller's own version.
182    pub runtime_version: Option<String>,
183    /// `<triple> → <path to serve binary>`. Any entry makes this a
184    /// `runtime = "self"` bundle that carries its own serve binaries.
185    pub serve_bins: BTreeMap<String, PathBuf>,
186    /// How kamaji supervises the served bundle.
187    pub lifecycle: BundleLifecycle,
188    /// Port the served bundle listens on (R599-F12). `None` → kamaji's
189    /// node-wide default (8080), which is only correct while the node hosts a
190    /// single bundle; declare one per workload to put several on a node.
191    pub port: Option<u16>,
192    /// Optional revalidate receiver config (R330-F12). `Some` → the deploy
193    /// also stands up a `mesofact serve --revalidate` process.
194    pub revalidate: Option<RevalidateSlot>,
195    /// Public zone whose front door is checked after a deploy (R703-T7).
196    /// `None` → the service's own `domain`, which is the shape every mirror in
197    /// tree uses; declare one only when the bundle serves a zone that isn't it.
198    ///
199    /// Unlike `[providers.static]`, this is optional: the static slot's `zone`
200    /// is load-bearing for the Worker route and cache purge, whereas here it
201    /// only names what to probe.
202    pub zone: Option<String>,
203    /// Whether a deploy is checked against the live front door (R703-T7).
204    ///
205    /// Defaults to **true**, and the only reason to turn it off is a
206    /// deliberately in-flight front-door migration — with a comment naming the
207    /// ticket. It is declared in config rather than passed as a CLI flag for
208    /// the same reason the static slot's is: switching it off should be a
209    /// reviewable diff, not an invocation habit that quietly becomes permanent.
210    pub verify_serving: bool,
211}
212
213/// Parsed `[providers.bundle.revalidate]` sub-slot — declares the almanac
214/// revalidate receiver to fork alongside the static bundle server (R330-F12).
215///
216/// ```toml
217/// [providers.bundle.revalidate]
218/// routes = ["/releases"]             # allowlist (empty = all routes)
219/// mirror_key_env = "YAH_MARKETING_MIRROR_KEY"   # env var holding the bearer
220/// publish_config = "mesofact.config.toml"        # default
221/// feeds = ["releases"]               # .yah/almanac/<name>.toml to keep fresh
222/// feed_interval_secs = 300           # default
223/// feed_bins = { x86_64-unknown-linux-musl = "target/…/almanac-feed" }
224/// ```
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct RevalidateSlot {
227    /// Routes the receiver accepts pokes for (allowlist).
228    /// Empty → all routes in the workload manifest.
229    pub routes: Vec<String>,
230    /// Env var name holding the tenant bearer secret. Deploy resolves it
231    /// and sets `MESOFACT_MIRROR_KEY` on the receiver process.
232    /// `None` → open receiver (no bearer check).
233    pub mirror_key_env: Option<String>,
234    /// Path to `mesofact.config.toml` with the `[publish]` block, relative
235    /// to the workload directory. `None` → default `"mesofact.config.toml"`.
236    pub publish_config: Option<PathBuf>,
237    /// Almanac feed names (`.yah/almanac/<name>.toml`) the on-node fetch tier
238    /// keeps fresh (R330-F31). Empty → no fetcher, and the receiver re-renders
239    /// whatever data the bundle was built with.
240    pub feeds: Vec<String>,
241    /// Seconds between feed-fetch ticks. `None` → the spec default.
242    pub feed_interval_secs: Option<u64>,
243    /// Per-triple path to the `almanac-feed` binary staged into the bundle as a
244    /// sidecar. Required when `feeds` is non-empty — the node has no other way
245    /// to get it.
246    pub feed_bins: BTreeMap<String, PathBuf>,
247}
248
249impl RevalidateSlot {
250    /// Build the [`MesofactRevalidateReceiver`] payload for the workload spec,
251    /// given the env vars resolved at deploy time and the feed definitions read
252    /// from the camp's `.yah/almanac/` tree.
253    ///
254    /// Feed definitions travel by value: reading them is the deploy side's job
255    /// (it is the only participant that has the camp checkout), and the node
256    /// gets a self-contained payload.
257    pub fn to_workload_payload(
258        &self,
259        env: BTreeMap<String, String>,
260        feeds: Vec<workload_spec::AlmanacFeed>,
261        feed_project_prefix: Option<String>,
262    ) -> MesofactRevalidateReceiver {
263        MesofactRevalidateReceiver {
264            routes: self.routes.clone(),
265            publish_config: self
266                .publish_config
267                .as_ref()
268                .map(|p| p.to_string_lossy().into_owned())
269                .unwrap_or_else(|| "mesofact.config.toml".to_string()),
270            mirror_key_env: self.mirror_key_env.clone(),
271            env,
272            feeds,
273            feed_interval_secs: self
274                .feed_interval_secs
275                .unwrap_or(DEFAULT_FEED_INTERVAL_SECS),
276            feed_project_prefix,
277        }
278    }
279}
280
281/// Mirrors `workload_spec`'s own default. Duplicated rather than exported
282/// because the spec keeps its serde defaults private; the parse tests below
283/// pin the two together.
284pub const DEFAULT_FEED_INTERVAL_SECS: u64 = 300;
285
286/// Bundle path segment the fetch tier's sidecar binary is staged under —
287/// `bins/<triple>/almanac-feed`, next to `bins/<triple>/serve`.
288pub const FEED_BIN_NAME: &str = "almanac-feed";
289
290impl BundleSlot {
291    /// Parse the mirror's `[providers.bundle]` slot.
292    ///
293    /// Every failure names the offending field plus the service and env, so the
294    /// operator gets a file to open rather than a type error. Validation is
295    /// total and offline — nothing here touches the network, so a misconfigured
296    /// mirror fails before a build runs (R330-B5 fail-fast discipline).
297    pub fn parse(mirror: &MirrorConfig, service: &str, env: &str) -> Result<Self> {
298        let slot = mirror.providers.get(SLOT_ROLE).with_context(|| {
299            format!(
300                "mirror has no `providers.{SLOT_ROLE}` slot — required for the W272 bundle tier \
301                 (service={service}, env={env})"
302            )
303        })?;
304        let fields = slot.fields();
305
306        let bucket = fields
307            .get("bucket")
308            .and_then(|v| v.as_str())
309            .filter(|s| !s.is_empty())
310            .with_context(|| {
311                format!(
312                    "providers.{SLOT_ROLE} has no `bucket` — name the R2 bundle store in \
313                     .yah/services/{service}/mirrors/{env}.toml"
314                )
315            })?
316            .to_string();
317
318        let account = fields
319            .get("account")
320            .and_then(|v| v.as_str())
321            .filter(|s| !s.is_empty())
322            .map(str::to_string);
323
324        let name = fields
325            .get("name")
326            .and_then(|v| v.as_str())
327            .filter(|s| !s.is_empty())
328            .map(str::to_string);
329
330        let machines = match fields.get("machines") {
331            None => Vec::new(),
332            Some(v) => {
333                let list = v.as_array().with_context(|| {
334                    format!(
335                        "providers.{SLOT_ROLE}.machines must be an array of machine names \
336                         (service={service}, env={env})"
337                    )
338                })?;
339                list.iter()
340                    .map(|entry| {
341                        entry
342                            .as_str()
343                            .filter(|s| !s.is_empty())
344                            .map(str::to_string)
345                            .with_context(|| {
346                                format!(
347                                    "providers.{SLOT_ROLE}.machines holds a non-string (or empty) \
348                                     entry (service={service}, env={env})"
349                                )
350                            })
351                    })
352                    .collect::<Result<Vec<_>>>()?
353            }
354        };
355
356        let runtime_version = fields
357            .get("runtime_version")
358            .and_then(|v| v.as_str())
359            .filter(|s| !s.is_empty())
360            .map(str::to_string);
361
362        let serve_bins = match fields.get("serve_bins") {
363            None => BTreeMap::new(),
364            Some(v) => {
365                let table = v.as_table().with_context(|| {
366                    format!(
367                        "providers.{SLOT_ROLE}.serve_bins must be a table of \
368                         <target-triple> = <path> (service={service}, env={env})"
369                    )
370                })?;
371                table
372                    .iter()
373                    .map(|(triple, path)| {
374                        let path = path.as_str().filter(|s| !s.is_empty()).with_context(|| {
375                            format!(
376                                "providers.{SLOT_ROLE}.serve_bins.{triple} must be a non-empty \
377                                 path (service={service}, env={env})"
378                            )
379                        })?;
380                        Ok((triple.clone(), PathBuf::from(path)))
381                    })
382                    .collect::<Result<BTreeMap<_, _>>>()?
383            }
384        };
385
386        // R599-F12. Parsed strictly: a port is either absent or a real one, and
387        // a typo that silently fell back to 8080 would collide with whatever
388        // bundle already holds that port on the node — a failure that surfaces
389        // as the wrong site being served, not as an error.
390        let port = match fields.get("port") {
391            None => None,
392            Some(v) => {
393                let n = v.as_integer().with_context(|| {
394                    format!(
395                        "providers.{SLOT_ROLE}.port must be an integer TCP port \
396                         (service={service}, env={env})"
397                    )
398                })?;
399                Some(u16::try_from(n).ok().filter(|p| *p != 0).with_context(|| {
400                    format!(
401                        "providers.{SLOT_ROLE}.port = {n} is not a usable TCP port \
402                         (1..=65535) (service={service}, env={env})"
403                    )
404                })?)
405            }
406        };
407
408        let lifecycle = parse_lifecycle(
409            fields.get("lifecycle").and_then(|v| v.as_str()),
410            fields.get("idle_ttl_ms").and_then(|v| v.as_integer()),
411            service,
412            env,
413        )?;
414
415        let revalidate = parse_revalidate_slot(fields, service, env)?;
416
417        let zone = fields
418            .get("zone")
419            .and_then(|v| v.as_str())
420            .filter(|s| !s.is_empty())
421            .map(str::to_string);
422
423        // R703-T7. Parsed strictly rather than `unwrap_or(true)` on a bad type:
424        // `verify_serving = "false"` silently reading as *enabled* is the
425        // friendlier-looking failure, but an operator who typed it believes the
426        // check is off and will be surprised by an apply that fails on a
427        // migration they thought they had silenced.
428        let verify_serving = match fields.get("verify_serving") {
429            None => true,
430            Some(v) => v.as_bool().with_context(|| {
431                format!(
432                    "providers.{SLOT_ROLE}.verify_serving must be a boolean \
433                     (service={service}, env={env})"
434                )
435            })?,
436        };
437
438        Ok(Self {
439            bucket,
440            account,
441            name,
442            machines,
443            runtime_version,
444            serve_bins,
445            lifecycle,
446            port,
447            revalidate,
448            zone,
449            verify_serving,
450        })
451    }
452
453    /// The zone whose front door a deploy of this bundle is checked against:
454    /// the slot's `zone`, else the service's own domain.
455    pub fn serving_zone<'a>(&'a self, service_domain: &'a str) -> &'a str {
456        self.zone.as_deref().unwrap_or(service_domain)
457    }
458
459    /// Stable workload handle: the slot's `name`, else the service name.
460    pub fn workload_name<'a>(&'a self, service: &'a str) -> &'a str {
461        self.name.as_deref().unwrap_or(service)
462    }
463
464    /// True when the assembled bundle carries its own serve binaries
465    /// (`runtime = "self"`) rather than resolving a stock node runtime asset.
466    pub fn is_self_contained(&self) -> bool {
467        !self.serve_bins.is_empty()
468    }
469
470    /// Build the `{digest, runtime, lifecycle}` triple a `mesofact-static`
471    /// workload carries once its bundle is published.
472    ///
473    /// `runtime` wire-mirrors `yah_mesofact_bundle::BundleRuntime`, so it is
474    /// taken from the manifest the assembler actually wrote rather than
475    /// re-derived here — the manifest is what the node will verify against.
476    pub fn serve_bundle(&self, digest: &str, runtime: &str) -> MesofactServeBundle {
477        MesofactServeBundle {
478            digest: BlakeHash(digest.to_string()),
479            runtime: runtime.to_string(),
480            lifecycle: self.lifecycle.clone(),
481            // R599-F12: the slot's declared `port`, or `None` for kamaji's
482            // node-wide default. (@Ashguard:blade parked a `None` here to
483            // unblock the camp's build while this ticket was mid-flight; this
484            // is the real threading it named.)
485            port: self.port,
486        }
487    }
488}
489
490/// `lifecycle = "keep-alive" | "on-demand"` (+ `idle_ttl_ms` for the latter).
491fn parse_lifecycle(
492    raw: Option<&str>,
493    idle_ttl_ms: Option<i64>,
494    service: &str,
495    env: &str,
496) -> Result<BundleLifecycle> {
497    match raw.unwrap_or("keep-alive") {
498        "keep-alive" | "keepalive" => {
499            if idle_ttl_ms.is_some() {
500                bail!(
501                    "providers.{SLOT_ROLE}.idle_ttl_ms only applies to `lifecycle = \"on-demand\"` \
502                     — a keep-alive bundle is never reaped (service={service}, env={env})"
503                );
504            }
505            Ok(BundleLifecycle::KeepAlive)
506        }
507        "on-demand" | "ondemand" | "jit" => {
508            let ttl = idle_ttl_ms.unwrap_or(DEFAULT_IDLE_TTL_MS as i64);
509            if ttl <= 0 {
510                bail!(
511                    "providers.{SLOT_ROLE}.idle_ttl_ms must be positive, got {ttl} \
512                     (service={service}, env={env})"
513                );
514            }
515            Ok(BundleLifecycle::OnDemand {
516                idle_ttl: Millis::from_ms(ttl as u64),
517            })
518        }
519        other => bail!(
520            "providers.{SLOT_ROLE}.lifecycle must be \"keep-alive\" or \"on-demand\", got \
521             {other:?} (service={service}, env={env})"
522        ),
523    }
524}
525
526/// Parse the optional `[providers.bundle.revalidate]` sub-table.
527///
528/// `None` → no revalidate receiver declared (the common case). `Some` → the
529/// deploy also stands up a `mesofact serve --revalidate` process.
530fn parse_revalidate_slot(
531    fields: &BTreeMap<String, toml::Value>,
532    service: &str,
533    env: &str,
534) -> Result<Option<RevalidateSlot>> {
535    let sub = match fields.get(REVALIDATE_KEY) {
536        None => return Ok(None),
537        Some(v) => v.as_table().with_context(|| {
538            format!(
539                "providers.{SLOT_ROLE}.{REVALIDATE_KEY} must be a TOML table \
540                     (service={service}, env={env})"
541            )
542        })?,
543    };
544
545    let routes = match sub.get("routes") {
546        None => Vec::new(),
547        Some(v) => {
548            let list = v.as_array().with_context(|| {
549                format!(
550                    "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.routes must be an array of route \
551                     patterns (service={service}, env={env})"
552                )
553            })?;
554            list.iter()
555                .map(|entry| {
556                    entry
557                        .as_str()
558                        .filter(|s| !s.is_empty())
559                        .map(str::to_string)
560                        .with_context(|| {
561                            format!(
562                                "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.routes holds a non-string \
563                                 (or empty) entry (service={service}, env={env})"
564                            )
565                        })
566                })
567                .collect::<Result<Vec<_>>>()?
568        }
569    };
570
571    let mirror_key_env = sub
572        .get("mirror_key_env")
573        .and_then(|v| v.as_str())
574        .filter(|s| !s.is_empty())
575        .map(str::to_string);
576
577    let publish_config = sub
578        .get("publish_config")
579        .and_then(|v| v.as_str())
580        .filter(|s| !s.is_empty())
581        .map(PathBuf::from);
582
583    // ── Feed-fetch tier (R330-F31) ──────────────────────────────────────────
584    let feeds = match sub.get("feeds") {
585        None => Vec::new(),
586        Some(v) => {
587            let list = v.as_array().with_context(|| {
588                format!(
589                    "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feeds must be an array of almanac \
590                     feed names (service={service}, env={env})"
591                )
592            })?;
593            list.iter()
594                .map(|entry| {
595                    entry
596                        .as_str()
597                        .filter(|s| !s.is_empty())
598                        .map(str::to_string)
599                        .with_context(|| {
600                            format!(
601                                "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feeds holds a non-string \
602                                 (or empty) entry (service={service}, env={env})"
603                            )
604                        })
605                })
606                .collect::<Result<Vec<_>>>()?
607        }
608    };
609
610    let feed_interval_secs = match sub.get("feed_interval_secs") {
611        None => None,
612        Some(v) => {
613            let secs = v.as_integer().filter(|n| *n > 0).with_context(|| {
614                format!(
615                    "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feed_interval_secs must be a positive \
616                     integer number of seconds (service={service}, env={env})"
617                )
618            })?;
619            Some(secs as u64)
620        }
621    };
622
623    let feed_bins = match sub.get("feed_bins") {
624        None => BTreeMap::new(),
625        Some(v) => {
626            let table = v.as_table().with_context(|| {
627                format!(
628                    "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feed_bins must be a table of \
629                     <target-triple> = <path> (service={service}, env={env})"
630                )
631            })?;
632            table
633                .iter()
634                .map(|(triple, path)| {
635                    let p = path.as_str().filter(|s| !s.is_empty()).with_context(|| {
636                        format!(
637                            "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feed_bins.{triple} must be \
638                                 a non-empty path string (service={service}, env={env})"
639                        )
640                    })?;
641                    Ok((triple.clone(), PathBuf::from(p)))
642                })
643                .collect::<Result<BTreeMap<_, _>>>()?
644        }
645    };
646
647    // Declaring feeds without shipping the fetcher is the failure that looks
648    // like success: the deploy goes green, the receiver serves, and the data
649    // never moves again. Catch it here, offline, with the file to edit.
650    if !feeds.is_empty() && feed_bins.is_empty() {
651        anyhow::bail!(
652            "providers.{SLOT_ROLE}.{REVALIDATE_KEY}.feeds declares {} feed(s) but no feed_bins — \
653             the node has no other way to get the `{FEED_BIN_NAME}` fetcher, so nothing would \
654             ever refresh them (service={service}, env={env})",
655            feeds.len()
656        );
657    }
658
659    Ok(Some(RevalidateSlot {
660        routes,
661        mirror_key_env,
662        publish_config,
663        feeds,
664        feed_interval_secs,
665        feed_bins,
666    }))
667}
668
669/// Resolve the machines a published bundle deploys to, in deploy order.
670///
671/// Two declaration forms, checked in that order:
672/// 1. `machines = ["us-east-001", …]` — explicit, ordered, and the shape to
673///    prefer while a bundle binds loopback (F10: one bundle per node, passway
674///    co-located), because *which* nodes serve is then an operator decision
675///    rather than a scheduler outcome.
676/// 2. `required = { regions = […], mesh_tags = […] }` — F16 placement, same
677///    grammar [`super::mesofact_runner::resolve_runner_machine`] uses. Resolves
678///    to exactly one machine.
679///
680/// An undeclared / unresolvable placement is an error, not an empty deploy —
681/// silently publishing a bundle nobody serves is the failure mode this avoids.
682pub fn resolve_bundle_machines<'a>(
683    cfg: &'a CloudConfig,
684    mirror: &MirrorConfig,
685    slot: &BundleSlot,
686    service: &str,
687    env: &str,
688) -> Result<Vec<&'a crate::MachineConfig>> {
689    if !slot.machines.is_empty() {
690        return slot
691            .machines
692            .iter()
693            .map(|name| {
694                cfg.machine(name).with_context(|| {
695                    format!(
696                        "providers.{SLOT_ROLE}.machines names {name:?}, which is not declared in \
697                         .yah/infra/machines/ (service={service}, env={env})"
698                    )
699                })
700            })
701            .collect();
702    }
703
704    let required = mirror
705        .providers
706        .get(SLOT_ROLE)
707        .and_then(|s| s.required())
708        .filter(|r| !r.is_unconstrained())
709        .with_context(|| {
710            format!(
711                "providers.{SLOT_ROLE} declares neither `machines = [...]` nor a constrained \
712                 `required = {{ … }}` placement — a bundle must name the nodes that serve it \
713                 (service={service}, env={env})"
714            )
715        })?;
716
717    let machine = cfg.resolve_machine(&required).with_context(|| {
718        format!(
719            "F16 placement: no machine satisfies providers.{SLOT_ROLE}.required ({}) — check \
720             .yah/services/{service}/mirrors/{env}.toml against .yah/infra/machines/*.toml",
721            required.describe(),
722        )
723    })?;
724    Ok(vec![machine])
725}
726
727/// Desktop-side (offline) half of the bundle tier: validate the mirror's
728/// declaration and bail with a pointer at the CLI.
729///
730/// The real chain — build, assemble, publish, deploy — runs at the apply layer
731/// where [`CloudConfig`] is in hand. This exists so a desktop bring-up of a
732/// bundle-tier mirror reports a *configuration* verdict instead of "no
733/// reconciler wired".
734pub struct MesofactBundleReconciler;
735
736impl MesofactBundleReconciler {
737    pub fn new() -> Self {
738        Self
739    }
740}
741
742impl Default for MesofactBundleReconciler {
743    fn default() -> Self {
744        Self::new()
745    }
746}
747
748#[async_trait]
749impl Reconciler for MesofactBundleReconciler {
750    fn kind(&self) -> &'static str {
751        super::mesofact_static::WORKLOAD_KIND
752    }
753
754    async fn up(&self, ctx: ReconcileCtx<'_>) -> Result<RunningWorkload> {
755        let slot = BundleSlot::parse(ctx.mirror, &ctx.service.name, ctx.env)?;
756        bail!(
757            "bundle tier validated (bucket={}, workload={}, {} serve binar{}) for service={}, \
758             env={}, but the sync arm runs at the apply layer — deploy with \
759             `yah cloud mirror up {} --env {}` (machine placement needs the workspace's \
760             machine set, which a desktop bring-up does not load)",
761            slot.bucket,
762            slot.workload_name(&ctx.service.name),
763            slot.serve_bins.len(),
764            if slot.serve_bins.len() == 1 {
765                "y"
766            } else {
767                "ies"
768            },
769            ctx.service.name,
770            ctx.env,
771            ctx.service.name,
772            ctx.env,
773        )
774    }
775}
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780    use crate::config::{MachineConfig, MirrorProviderSlot, MirrorShape, TopologyConfig};
781    use std::path::PathBuf;
782
783    fn mirror_from(slots: BTreeMap<String, MirrorProviderSlot>) -> MirrorConfig {
784        MirrorConfig {
785            schema_version: 1,
786            shape: MirrorShape::SingleMachine,
787            providers: slots,
788            ingress: Default::default(),
789            drivers: Default::default(),
790            asset_aliases: BTreeMap::new(),
791        }
792    }
793
794    /// Build a mirror whose `[providers.bundle]` slot is exactly `slot_toml`.
795    fn mirror_with(slot_toml: &str) -> MirrorConfig {
796        let slot: MirrorProviderSlot = toml::from_str(slot_toml).unwrap();
797        let mut providers = BTreeMap::new();
798        providers.insert(SLOT_ROLE.to_string(), slot);
799        mirror_from(providers)
800    }
801
802    fn machine(name: &str, region: &str) -> MachineConfig {
803        MachineConfig {
804            name: name.into(),
805            provider: "static".into(),
806            location: None,
807            server_type: None,
808            hosts_mirrors: vec![],
809            mesh_tags: vec![],
810            region: Some(region.into()),
811            zone: None,
812            arch: Some("x86_64".into()),
813            bucket: None,
814            vendor: None,
815            nickname: None,
816            legacy_hostkey_fingerprint: None,
817            registration: Default::default(),
818            ssh_keys: vec![],
819            cloudflared: None,
820            hosts_operator_bridge: false,
821            connect: None,
822            allocatable: None,
823            taints: vec![],
824        }
825    }
826
827    fn cfg_with(machines: Vec<MachineConfig>) -> CloudConfig {
828        CloudConfig {
829            workspace_root: PathBuf::new(),
830            machines,
831            providers: vec![],
832            machine_origins: BTreeMap::new(),
833            provider_origins: BTreeMap::new(),
834            services: BTreeMap::new(),
835            domains: BTreeMap::new(),
836            legacy_mirrors: vec![],
837            workloads: vec![],
838            topology: TopologyConfig::default(),
839            legacy_services: vec![],
840        }
841    }
842
843    #[test]
844    fn slot_declared_keys_off_the_bundle_role() {
845        assert!(slot_declared(&mirror_with(
846            r#"use = "cloudflare"
847bucket = "b""#
848        )));
849        assert!(!slot_declared(&mirror_from(BTreeMap::new())));
850    }
851
852    /// R330-B43 regression pin. This is the exact shape that froze yah.dev:
853    /// a fully-valid `[providers.bundle]` slot whose serve binary was never
854    /// built. `slot_declared` says yes (it only reads config), so dispatching
855    /// on it alone handed the component to a tier that could not come up while
856    /// taking the working static chain out of the picture. `slot_ready` is what
857    /// the dispatch gate must ask instead.
858    #[test]
859    fn a_declared_slot_whose_serve_bin_is_absent_is_not_ready() {
860        let root = tempfile::tempdir().unwrap();
861        let mirror = mirror_with(
862            r#"
863use = "cloudflare"
864bucket = "yah-dev"
865
866[serve_bins]
867x86_64-unknown-linux-musl = "target/x86_64-unknown-linux-musl/release/mesofact"
868"#,
869        );
870        let slot = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap();
871
872        assert!(slot_declared(&mirror), "config declares the slot");
873        assert!(
874            !slot_ready(&slot, root.path()),
875            "but it cannot serve — the binary does not exist"
876        );
877
878        let missing = missing_bins(&slot, root.path());
879        assert_eq!(missing.len(), 1);
880        assert_eq!(
881            missing[0].0, "providers.bundle.serve_bins.x86_64-unknown-linux-musl",
882            "the label must name the exact config line to fix"
883        );
884    }
885
886    #[test]
887    fn a_slot_becomes_ready_once_its_bins_exist() {
888        let root = tempfile::tempdir().unwrap();
889        let bin = root.path().join("target/x86_64-unknown-linux-musl/release");
890        std::fs::create_dir_all(&bin).unwrap();
891        std::fs::write(bin.join("mesofact"), b"#!/bin/sh\n").unwrap();
892
893        let mirror = mirror_with(
894            r#"
895use = "cloudflare"
896bucket = "yah-dev"
897
898[serve_bins]
899x86_64-unknown-linux-musl = "target/x86_64-unknown-linux-musl/release/mesofact"
900"#,
901        );
902        let slot = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap();
903        assert!(slot_ready(&slot, root.path()));
904        assert!(missing_bins(&slot, root.path()).is_empty());
905    }
906
907    /// A declared feed tier is part of "can it serve" — R330-F31 stages the
908    /// fetcher as a sidecar, so a missing feed_bin strands the feed tier the
909    /// same way a missing serve_bin strands the server.
910    #[test]
911    fn a_missing_feed_bin_also_blocks_readiness() {
912        let root = tempfile::tempdir().unwrap();
913        let bin = root.path().join("target/musl");
914        std::fs::create_dir_all(&bin).unwrap();
915        std::fs::write(bin.join("mesofact"), b"x").unwrap();
916
917        let mirror = mirror_with(
918            r#"
919use = "cloudflare"
920bucket = "yah-dev"
921
922[serve_bins]
923x86_64-unknown-linux-musl = "target/musl/mesofact"
924
925[revalidate]
926routes = ["/releases"]
927feeds = ["releases"]
928
929[revalidate.feed_bins]
930x86_64-unknown-linux-musl = "target/musl/almanac-feed"
931"#,
932        );
933        let slot = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap();
934        let missing = missing_bins(&slot, root.path());
935        assert_eq!(missing.len(), 1, "only the feed binary is absent");
936        assert!(missing[0].0.contains("revalidate.feed_bins"));
937        assert!(!slot_ready(&slot, root.path()));
938    }
939
940    /// A vanilla-runtime slot declares no binaries at all. That is a different
941    /// shape, not a half-built one, so it stays "ready" here and fails later
942    /// with its own specific message rather than being silently downgraded.
943    #[test]
944    fn a_vanilla_slot_declaring_no_bins_is_ready() {
945        let root = tempfile::tempdir().unwrap();
946        let mirror = mirror_with(
947            r#"use = "cloudflare"
948bucket = "b""#,
949        );
950        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
951        assert!(!slot.is_self_contained());
952        assert!(slot_ready(&slot, root.path()));
953    }
954
955    #[test]
956    fn parses_a_self_contained_keep_alive_slot() {
957        let mirror = mirror_with(
958            r#"
959use = "cloudflare"
960bucket = "yah-dev-bundles"
961machines = ["us-east-001"]
962name = "yah-marketing"
963
964[serve_bins]
965x86_64-unknown-linux-musl = "target/x86_64-unknown-linux-musl/release/mesofact-serve"
966"#,
967        );
968        let slot = BundleSlot::parse(&mirror, "yah-marketing", "ha").unwrap();
969        assert_eq!(slot.bucket, "yah-dev-bundles");
970        assert_eq!(slot.machines, vec!["us-east-001".to_string()]);
971        assert_eq!(slot.workload_name("yah-marketing"), "yah-marketing");
972        assert!(slot.is_self_contained());
973        assert_eq!(slot.lifecycle, BundleLifecycle::KeepAlive);
974    }
975
976    #[test]
977    fn workload_name_falls_back_to_the_service_name() {
978        let mirror = mirror_with(
979            r#"use = "cloudflare"
980bucket = "b""#,
981        );
982        let slot = BundleSlot::parse(&mirror, "scrabcake", "ha").unwrap();
983        assert_eq!(slot.workload_name("scrabcake"), "scrabcake");
984        assert!(!slot.is_self_contained());
985    }
986
987    #[test]
988    fn on_demand_takes_the_default_idle_ttl() {
989        let mirror = mirror_with(
990            r#"use = "cloudflare"
991bucket = "b"
992lifecycle = "on-demand""#,
993        );
994        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
995        assert_eq!(
996            slot.lifecycle,
997            BundleLifecycle::OnDemand {
998                idle_ttl: Millis::from_ms(DEFAULT_IDLE_TTL_MS)
999            }
1000        );
1001    }
1002
1003    #[test]
1004    fn on_demand_honors_an_explicit_idle_ttl() {
1005        let mirror = mirror_with(
1006            r#"use = "cloudflare"
1007bucket = "b"
1008lifecycle = "on-demand"
1009idle_ttl_ms = 15000"#,
1010        );
1011        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1012        assert_eq!(
1013            slot.lifecycle,
1014            BundleLifecycle::OnDemand {
1015                idle_ttl: Millis::from_ms(15_000)
1016            }
1017        );
1018    }
1019
1020    /// An idle TTL on a keep-alive bundle is a config mistake that would
1021    /// otherwise be silently ignored — the process is never reaped.
1022    #[test]
1023    fn idle_ttl_on_a_keep_alive_slot_is_rejected() {
1024        let mirror = mirror_with(
1025            r#"use = "cloudflare"
1026bucket = "b"
1027idle_ttl_ms = 15000"#,
1028        );
1029        let err = BundleSlot::parse(&mirror, "s", "e")
1030            .unwrap_err()
1031            .to_string();
1032        assert!(err.contains("idle_ttl_ms"), "{err}");
1033        assert!(err.contains("on-demand"), "{err}");
1034    }
1035
1036    #[test]
1037    fn unknown_lifecycle_names_the_legal_values() {
1038        let mirror = mirror_with(
1039            r#"use = "cloudflare"
1040bucket = "b"
1041lifecycle = "serverless""#,
1042        );
1043        let err = BundleSlot::parse(&mirror, "s", "e")
1044            .unwrap_err()
1045            .to_string();
1046        assert!(err.contains("keep-alive"), "{err}");
1047        assert!(err.contains("on-demand"), "{err}");
1048    }
1049
1050    /// R599-F12: the declared serving port reaches the workload spec. Without
1051    /// it every bundle rides kamaji's node-wide default, so a node can host
1052    /// exactly one.
1053    #[test]
1054    fn a_declared_port_reaches_the_serve_bundle() {
1055        let slot = BundleSlot::parse(
1056            &mirror_with(
1057                r#"use = "cloudflare"
1058bucket = "b"
1059port = 8081"#,
1060            ),
1061            "s",
1062            "e",
1063        )
1064        .unwrap();
1065        assert_eq!(slot.port, Some(8081));
1066        assert_eq!(
1067            slot.serve_bundle("a".repeat(64).as_str(), "self").port,
1068            Some(8081)
1069        );
1070
1071        // Absent → kamaji's node default, the pre-R599-F12 behaviour.
1072        let bare = BundleSlot::parse(
1073            &mirror_with(
1074                r#"use = "cloudflare"
1075bucket = "b""#,
1076            ),
1077            "s",
1078            "e",
1079        )
1080        .unwrap();
1081        assert_eq!(bare.port, None);
1082        assert_eq!(
1083            bare.serve_bundle("a".repeat(64).as_str(), "self").port,
1084            None
1085        );
1086    }
1087
1088    /// A port typo must fail the parse, not silently fall back to 8080 — that
1089    /// fallback would land the workload on whatever bundle already holds the
1090    /// default port, and surface as the wrong site being served.
1091    #[test]
1092    fn an_unusable_port_is_rejected_rather_than_defaulted() {
1093        for bad in ["port = 0", "port = 70000", r#"port = "8081""#] {
1094            let toml = format!("use = \"cloudflare\"\nbucket = \"b\"\n{bad}");
1095            let err = BundleSlot::parse(&mirror_with(&toml), "yah-marketing", "ha")
1096                .unwrap_err()
1097                .to_string();
1098            assert!(err.contains("port"), "{bad}: {err}");
1099        }
1100    }
1101
1102    // ── serving verification (R703-T7) ──────────────────────────────────────
1103
1104    /// The check is on by default and probes the service's own domain, so a
1105    /// mirror that says nothing about it still gets verified.
1106    #[test]
1107    fn serving_verification_is_on_by_default_and_targets_the_service_domain() {
1108        let slot = BundleSlot::parse(
1109            &mirror_with(
1110                r#"use = "cloudflare"
1111bucket = "b""#,
1112            ),
1113            "yah-marketing",
1114            "cloud",
1115        )
1116        .unwrap();
1117        assert!(slot.verify_serving);
1118        assert_eq!(slot.zone, None);
1119        assert_eq!(slot.serving_zone("yah.dev"), "yah.dev");
1120    }
1121
1122    #[test]
1123    fn an_explicit_zone_overrides_the_service_domain() {
1124        let slot = BundleSlot::parse(
1125            &mirror_with(
1126                r#"use = "cloudflare"
1127bucket = "b"
1128zone = "staging.yah.dev""#,
1129            ),
1130            "yah-marketing",
1131            "cloud",
1132        )
1133        .unwrap();
1134        assert_eq!(slot.serving_zone("yah.dev"), "staging.yah.dev");
1135    }
1136
1137    #[test]
1138    fn verify_serving_can_be_switched_off_for_an_in_flight_migration() {
1139        let slot = BundleSlot::parse(
1140            &mirror_with(
1141                r#"use = "cloudflare"
1142bucket = "b"
1143verify_serving = false"#,
1144            ),
1145            "s",
1146            "e",
1147        )
1148        .unwrap();
1149        assert!(!slot.verify_serving);
1150    }
1151
1152    /// `verify_serving = "false"` reading as *enabled* would leave an operator
1153    /// certain they had silenced a check that then fails their apply.
1154    #[test]
1155    fn a_non_boolean_verify_serving_is_rejected_rather_than_defaulted() {
1156        let err = BundleSlot::parse(
1157            &mirror_with(
1158                r#"use = "cloudflare"
1159bucket = "b"
1160verify_serving = "false""#,
1161            ),
1162            "yah-marketing",
1163            "cloud",
1164        )
1165        .unwrap_err()
1166        .to_string();
1167        assert!(err.contains("verify_serving"), "{err}");
1168        assert!(err.contains("boolean"), "{err}");
1169    }
1170
1171    #[test]
1172    fn a_slot_without_a_bucket_names_the_file_to_edit() {
1173        let mirror = mirror_with(r#"use = "cloudflare""#);
1174        let err = BundleSlot::parse(&mirror, "yah-marketing", "ha")
1175            .unwrap_err()
1176            .to_string();
1177        assert!(err.contains("bucket"), "{err}");
1178        assert!(
1179            err.contains(".yah/services/yah-marketing/mirrors/ha.toml"),
1180            "{err}"
1181        );
1182    }
1183
1184    #[test]
1185    fn explicit_machines_resolve_in_declaration_order() {
1186        let mirror = mirror_with(
1187            r#"use = "cloudflare"
1188bucket = "b"
1189machines = ["us-south-001", "us-east-001"]"#,
1190        );
1191        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1192        let cfg = cfg_with(vec![
1193            machine("us-east-001", "us-east"),
1194            machine("us-south-001", "us-south"),
1195        ]);
1196        let resolved = resolve_bundle_machines(&cfg, &mirror, &slot, "s", "e").unwrap();
1197        let names: Vec<_> = resolved.iter().map(|m| m.name.as_str()).collect();
1198        assert_eq!(names, vec!["us-south-001", "us-east-001"]);
1199    }
1200
1201    #[test]
1202    fn an_undeclared_machine_is_an_error_not_a_skip() {
1203        let mirror = mirror_with(
1204            r#"use = "cloudflare"
1205bucket = "b"
1206machines = ["us-west-999"]"#,
1207        );
1208        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1209        let cfg = cfg_with(vec![machine("us-east-001", "us-east")]);
1210        let err = resolve_bundle_machines(&cfg, &mirror, &slot, "s", "e")
1211            .unwrap_err()
1212            .to_string();
1213        assert!(err.contains("us-west-999"), "{err}");
1214        assert!(err.contains(".yah/infra/machines/"), "{err}");
1215    }
1216
1217    #[test]
1218    fn falls_back_to_f16_required_placement() {
1219        let mirror = mirror_with(
1220            r#"use = "cloudflare"
1221bucket = "b"
1222required = { regions = ["us-east"] }"#,
1223        );
1224        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1225        let cfg = cfg_with(vec![
1226            machine("us-east-001", "us-east"),
1227            machine("us-south-001", "us-south"),
1228        ]);
1229        let resolved = resolve_bundle_machines(&cfg, &mirror, &slot, "s", "e").unwrap();
1230        assert_eq!(resolved.len(), 1);
1231        assert_eq!(resolved[0].name, "us-east-001");
1232    }
1233
1234    /// Publishing a bundle no node serves is the silent failure this guards.
1235    #[test]
1236    fn no_placement_at_all_is_rejected() {
1237        let mirror = mirror_with(
1238            r#"use = "cloudflare"
1239bucket = "b""#,
1240        );
1241        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1242        let cfg = cfg_with(vec![machine("us-east-001", "us-east")]);
1243        let err = resolve_bundle_machines(&cfg, &mirror, &slot, "s", "e")
1244            .unwrap_err()
1245            .to_string();
1246        assert!(err.contains("machines"), "{err}");
1247        assert!(err.contains("required"), "{err}");
1248    }
1249
1250    #[test]
1251    fn serve_bundle_carries_the_manifest_runtime_verbatim() {
1252        let mirror = mirror_with(
1253            r#"use = "cloudflare"
1254bucket = "b""#,
1255        );
1256        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1257        let digest = "a".repeat(64);
1258        let sb = slot.serve_bundle(&digest, "mesofact/0.8.20");
1259        assert_eq!(sb.digest.0, digest);
1260        assert_eq!(sb.runtime, "mesofact/0.8.20");
1261        assert_eq!(sb.lifecycle, BundleLifecycle::KeepAlive);
1262    }
1263
1264    // ── revalidate receiver parsing (R330-F12) ──────────────────────────────
1265
1266    #[test]
1267    fn parses_revalidate_slot_with_routes_and_mirror_key_env() {
1268        let mirror = mirror_with(
1269            r#"use = "cloudflare"
1270bucket = "b"
1271machines = ["us-east-001"]
1272
1273[revalidate]
1274routes = ["/releases"]
1275mirror_key_env = "YAH_MARKETING_MIRROR_KEY"
1276"#,
1277        );
1278        let slot = BundleSlot::parse(&mirror, "yah-marketing", "cloud").unwrap();
1279        let rv = slot.revalidate.expect("revalidate slot should parse");
1280        assert_eq!(rv.routes, vec!["/releases"]);
1281        assert_eq!(
1282            rv.mirror_key_env.as_deref(),
1283            Some("YAH_MARKETING_MIRROR_KEY")
1284        );
1285        assert!(rv.publish_config.is_none());
1286    }
1287
1288    #[test]
1289    fn parses_revalidate_with_custom_publish_config() {
1290        let mirror = mirror_with(
1291            r#"use = "cloudflare"
1292bucket = "b"
1293machines = ["us-east-001"]
1294
1295[revalidate]
1296routes = ["/releases", "/downloads"]
1297publish_config = "custom-mesofact.config.toml"
1298"#,
1299        );
1300        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1301        let rv = slot.revalidate.unwrap();
1302        assert_eq!(rv.routes.len(), 2);
1303        assert_eq!(
1304            rv.publish_config.unwrap(),
1305            PathBuf::from("custom-mesofact.config.toml")
1306        );
1307        assert!(rv.mirror_key_env.is_none());
1308    }
1309
1310    #[test]
1311    fn no_revalidate_when_section_absent() {
1312        let mirror = mirror_with(
1313            r#"use = "cloudflare"
1314bucket = "b""#,
1315        );
1316        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1317        assert!(slot.revalidate.is_none());
1318    }
1319
1320    #[test]
1321    fn revalidate_with_empty_routes_is_open_allowlist() {
1322        let mirror = mirror_with(
1323            r#"use = "cloudflare"
1324bucket = "b"
1325
1326[revalidate]
1327mirror_key_env = "BEARER"
1328"#,
1329        );
1330        let slot = BundleSlot::parse(&mirror, "s", "e").unwrap();
1331        let rv = slot.revalidate.unwrap();
1332        assert!(rv.routes.is_empty());
1333        assert_eq!(rv.mirror_key_env.as_deref(), Some("BEARER"));
1334    }
1335
1336    #[test]
1337    fn to_workload_payload_maps_fields() {
1338        let slot = RevalidateSlot {
1339            routes: vec!["/releases".into()],
1340            mirror_key_env: Some("MY_KEY".into()),
1341            publish_config: Some(PathBuf::from("cfg.toml")),
1342            ..bare_revalidate_slot()
1343        };
1344        let mut env = BTreeMap::new();
1345        env.insert("MESOFACT_S3_ACCESS_KEY_ID".into(), "ak".into());
1346        env.insert("MESOFACT_MIRROR_KEY".into(), "bearer1".into());
1347        let payload = slot.to_workload_payload(env.clone(), vec![], None);
1348        assert_eq!(payload.routes, vec!["/releases"]);
1349        assert_eq!(payload.publish_config, "cfg.toml");
1350        assert_eq!(payload.mirror_key_env.as_deref(), Some("MY_KEY"));
1351        assert_eq!(payload.env.get("MESOFACT_S3_ACCESS_KEY_ID").unwrap(), "ak");
1352        assert_eq!(payload.env.get("MESOFACT_MIRROR_KEY").unwrap(), "bearer1");
1353    }
1354
1355    #[test]
1356    fn to_workload_payload_defaults_publish_config() {
1357        let payload = bare_revalidate_slot().to_workload_payload(BTreeMap::new(), vec![], None);
1358        assert_eq!(payload.publish_config, "mesofact.config.toml");
1359        assert!(payload.mirror_key_env.is_none());
1360        assert!(payload.routes.is_empty());
1361    }
1362
1363    // ── Feed-fetch tier (R330-F31) ──────────────────────────────────────────
1364
1365    fn bare_revalidate_slot() -> RevalidateSlot {
1366        RevalidateSlot {
1367            routes: vec![],
1368            mirror_key_env: None,
1369            publish_config: None,
1370            feeds: vec![],
1371            feed_interval_secs: None,
1372            feed_bins: BTreeMap::new(),
1373        }
1374    }
1375
1376    #[test]
1377    fn parses_feed_tier_declaration() {
1378        let mirror = mirror_with(
1379            r#"use = "cloudflare"
1380bucket = "b"
1381
1382[revalidate]
1383routes = ["/releases"]
1384feeds = ["releases", "yah-desktop"]
1385feed_interval_secs = 60
1386
1387[revalidate.feed_bins]
1388x86_64-unknown-linux-musl = "target/x86_64-unknown-linux-musl/release/almanac-feed"
1389"#,
1390        );
1391        let rv = BundleSlot::parse(&mirror, "s", "e")
1392            .unwrap()
1393            .revalidate
1394            .unwrap();
1395        assert_eq!(rv.feeds, vec!["releases", "yah-desktop"]);
1396        assert_eq!(rv.feed_interval_secs, Some(60));
1397        assert_eq!(rv.feed_bins.len(), 1);
1398        assert!(rv.feed_bins["x86_64-unknown-linux-musl"].ends_with("almanac-feed"));
1399    }
1400
1401    /// A receiver with no feed tier is the existing shape and must keep parsing
1402    /// — the fetcher is additive, not a new requirement on every mirror.
1403    #[test]
1404    fn revalidate_without_a_feed_tier_stays_empty() {
1405        let mirror = mirror_with(
1406            r#"use = "cloudflare"
1407bucket = "b"
1408
1409[revalidate]
1410routes = ["/releases"]
1411"#,
1412        );
1413        let rv = BundleSlot::parse(&mirror, "s", "e")
1414            .unwrap()
1415            .revalidate
1416            .unwrap();
1417        assert!(rv.feeds.is_empty());
1418        assert!(rv.feed_bins.is_empty());
1419        assert_eq!(rv.feed_interval_secs, None);
1420    }
1421
1422    /// Feeds declared with no fetcher binary is the silent-staleness trap: the
1423    /// deploy would go green and the data would never move. Fail at parse.
1424    #[test]
1425    fn feeds_without_feed_bins_is_rejected() {
1426        let mirror = mirror_with(
1427            r#"use = "cloudflare"
1428bucket = "b"
1429
1430[revalidate]
1431feeds = ["releases"]
1432"#,
1433        );
1434        let err = BundleSlot::parse(&mirror, "s", "e")
1435            .unwrap_err()
1436            .to_string();
1437        assert!(err.contains("feed_bins"), "got {err}");
1438        assert!(err.contains(FEED_BIN_NAME), "got {err}");
1439    }
1440
1441    #[test]
1442    fn zero_feed_interval_is_rejected() {
1443        let mirror = mirror_with(
1444            r#"use = "cloudflare"
1445bucket = "b"
1446
1447[revalidate]
1448feed_interval_secs = 0
1449"#,
1450        );
1451        let err = BundleSlot::parse(&mirror, "s", "e")
1452            .unwrap_err()
1453            .to_string();
1454        assert!(err.contains("positive integer"), "got {err}");
1455    }
1456
1457    /// The reconciler's default and the workload-spec serde default are two
1458    /// copies of one number; this pins them together.
1459    #[test]
1460    fn feed_interval_default_matches_the_workload_spec_default() {
1461        let payload = bare_revalidate_slot().to_workload_payload(BTreeMap::new(), vec![], None);
1462        assert_eq!(payload.feed_interval_secs, DEFAULT_FEED_INTERVAL_SECS);
1463
1464        let from_spec: workload_spec::MesofactRevalidateReceiver =
1465            serde_json::from_str("{}").expect("all receiver fields have serde defaults");
1466        assert_eq!(from_spec.feed_interval_secs, DEFAULT_FEED_INTERVAL_SECS);
1467    }
1468}