Skip to main content

cloud/reconciler/
sync_status.rs

1//! Argo-style sync / health / drift computation for service mirrors.
2//!
3//! This is the **declared-vs-live** status query that backs the Services
4//! catalog matrix (R323-F3) and deploy panel (R323-F4). It is the
5//! service-mirror sibling of [`crate::status`] — where that module diffs a
6//! declared *machine* against live Hetzner, this one diffs a declared
7//! *mirror* (`.yah/services/<svc>/mirrors/<env>.toml`) against whatever the
8//! caller can observe is running.
9//!
10//! Borrows Argo CD's vocabulary (see `visiting/yah-cloud-design/screens/
11//! services.jsx`):
12//! - **Sync** — declared vs live: `synced` / `out-of-sync` / `unknown`.
13//!   Out-of-sync is the *actionable* signal (the operator's call to sync).
14//! - **Health** — runtime state: `healthy` / `progressing` / `degraded` /
15//!   `missing` / `idle`.
16//! - **Drift** — the per-field diff (declared "desired" vs observed "live")
17//!   that explains *why* a mirror is out-of-sync.
18//!
19//! ## Transport-free, like [`crate::status`]
20//!
21//! This module computes status from (a) the declared [`MirrorConfig`] and
22//! (b) a caller-supplied [`MirrorObservation`]. It never reaches out to a
23//! runtime itself — the desktop fills observations from its in-memory
24//! running-mirror registry; a future yubaba probe will fill them for prod
25//! tiers. Tiers with **no observation** (`None`) resolve to `unknown` sync /
26//! `missing` health, which is the honest state today for `prod`/`ha`
27//! (read-only, declared status only — see the Area-A arch doc).
28//!
29//! @yah:ticket(R323-F10, "Service sync-history store (recent-syncs timeline backend)")
30//! @yah:assignee(agent:claude)
31//! @yah:at(2026-05-26T15:20:26Z)
32//! @yah:status(review)
33//! @yah:phase(P2)
34//! @yah:parent(R323)
35//! @yah:next("Persist a per-(service,env) sync-event log (when/who/rev/result) so the deploy panel's 'recent syncs' timeline (R323-F4) has real data. No store exists today — runs are in-memory.")
36//! @yah:next("Expose a query (e.g. service_sync_history) + RPC the timeline reads; mirror the QED run-history pattern (R-QED) rather than inventing a second shape.")
37//! @yah:gotcha("Until this lands, F4's timeline has no backing data — render an empty/placeholder state, don't fabricate events.")
38
39use std::collections::BTreeMap;
40
41use serde::{Deserialize, Serialize};
42
43use crate::config::ServiceWithMirrors;
44use crate::{MirrorConfig, MirrorProviderSlot, MirrorShape, Provider};
45
46/// Declared-vs-live agreement for one mirror. Argo's "sync status".
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "kebab-case")]
49pub enum SyncState {
50    /// Live state matches the declared manifest.
51    Synced,
52    /// Live differs from declared — there is something to push. The
53    /// actionable signal that drives the matrix cell's border/fill.
54    OutOfSync,
55    /// No live observation available, so agreement can't be determined
56    /// (e.g. prod/ha today — declared only).
57    Unknown,
58}
59
60/// Runtime health of a mirror's workloads. Argo's "health status", plus an
61/// `idle` state for on-demand local mirrors that are declared + in sync but
62/// not currently running.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "kebab-case")]
65pub enum HealthState {
66    /// Running and reporting ready.
67    Healthy,
68    /// Running but not yet ready (rolling out / waiting on a port/probe).
69    Progressing,
70    /// Running with errors, or the last bring-up/sync failed.
71    Degraded,
72    /// Declared but absent where it is expected to be continuously live
73    /// (prod/ha tiers), or unobserved.
74    Missing,
75    /// Declared + in sync but intentionally not running. The resting state
76    /// of an on-demand local mirror (`shape = "local"`) you haven't brought
77    /// up. Distinct from `missing`: nothing is wrong.
78    Idle,
79}
80
81/// Runtime-observed container workload status. Serialized as a tagged union
82/// (`kind` field). Richer than [`HealthState`] for the Services tab chip
83/// row — carries numeric detail (exit code, restart count, timestamps) that
84/// `HealthState` deliberately elides.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(tag = "kind", rename_all = "kebab-case")]
87pub enum WireContainerStatus {
88    Running,
89    Restarting {
90        restart_count: u32,
91        last_exit_code: i32,
92        last_finished_at_unix_ms: u64,
93    },
94    Stopped,
95    Failed {
96        reason: String,
97    },
98}
99
100/// The substrate a mirror runs on. `dev` is the odd one out (a bare
101/// process); `sim`/`prod`/`ha` share the container substrate. Derived from
102/// the mirror's provider slots, not from the env name (env names are
103/// arbitrary file stems). See the RuntimeAxis grouping in the Area-A design.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "kebab-case")]
106pub enum Runtime {
107    /// A bare process (the built-in static server / a dev `axum` binary).
108    Process,
109    /// Containers (pond miniflare/minio, or a remote container substrate).
110    Containers,
111}
112
113impl Runtime {
114    /// Derive the runtime from a mirror's provider slots: any container or
115    /// remote-substrate slot makes the whole mirror `containers`; a mirror
116    /// whose only slots are bare-process inline kinds (`miniflare-native`) is
117    /// `process`. Empty/unknown defaults to `process` (the dev case).
118    pub fn from_mirror(mirror: &MirrorConfig) -> Self {
119        let any_container = mirror.providers.values().any(|slot| match slot {
120            // Inline container kinds, or the local container runtime itself.
121            MirrorProviderSlot::Inline { kind, .. } => matches!(
122                kind,
123                Provider::MiniflareContainer | Provider::MinioContainer | Provider::LocalContainer
124            ),
125            // A referenced provider (cloudflare / hetzner / local-container)
126            // is always a container/prod substrate.
127            MirrorProviderSlot::Reference { .. } => true,
128        });
129        if any_container {
130            Runtime::Containers
131        } else {
132            Runtime::Process
133        }
134    }
135}
136
137/// What the operator can observe about one mirror right now.
138///
139/// Callers fill this from whatever live source they have: the desktop from
140/// its in-memory running-mirror registry, a future CLI from a yubaba probe.
141/// `None` (no observation) is a first-class state — it yields `unknown`
142/// sync, which is honest for tiers with no live source yet.
143#[derive(Debug, Clone, Default, Serialize, Deserialize)]
144pub struct MirrorObservation {
145    /// Is the mirror's workload set currently up?
146    pub running: bool,
147    /// Did the runtime report the workloads as ready (port/HTTP up)? Only
148    /// meaningful when `running`.
149    pub ready: bool,
150    /// The last bring-up/sync failed or a workload is crashing.
151    pub errored: bool,
152    /// Live revision actually deployed, when the runtime can report it
153    /// (image tag / sha / dev hash). `None` when unknown — a `None` live
154    /// revision never *by itself* makes a mirror out-of-sync.
155    pub live_revision: Option<String>,
156    /// Observed live field values, keyed `slot -> field -> value`, for drift
157    /// diffing against the declared manifest. Empty when the runtime can't
158    /// report its effective config (the common case today).
159    pub live_fields: BTreeMap<String, BTreeMap<String, String>>,
160}
161
162/// One declared-vs-live field divergence. Rendered in the deploy panel's
163/// drift list as `path: − live · + desired`.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct DriftEntry {
166    /// Dotted path into the manifest, e.g. `providers.static.image`.
167    pub path: String,
168    /// Declared ("desired") value.
169    pub desired: String,
170    /// Observed ("live") value.
171    pub live: String,
172}
173
174/// Computed status for one matrix cell — a single (service, env) mirror.
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176pub struct CellStatus {
177    /// Env name (file stem of `mirrors/<env>.toml`).
178    pub env: String,
179    pub sync: SyncState,
180    pub health: HealthState,
181    pub runtime: Runtime,
182    pub shape: MirrorShape,
183    /// Best-effort declared revision (an `image`/`version`/`tag` field on a
184    /// provider slot). `None` when the manifest carries no version-bearing
185    /// field (e.g. a bare `miniflare-native` slot).
186    pub declared_revision: Option<String>,
187    /// Live revision, echoed from the observation when known.
188    pub live_revision: Option<String>,
189    /// Operator-facing provider label, e.g. `cloudflare` or
190    /// `miniflare-container + minio-container`.
191    pub provider_label: String,
192    /// Per-field divergences explaining an out-of-sync cell. Empty when in
193    /// sync or unobserved.
194    pub drift: Vec<DriftEntry>,
195    /// Runtime-observed container status. Set for pond cells in crash-loop
196    /// or running state; absent for non-pond and unobserved cells. Enriched
197    /// by the desktop after `compute_service` — not set by `compute_cell`.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub workload_status: Option<WireContainerStatus>,
200}
201
202impl CellStatus {
203    /// Convenience: number of drifted fields (the matrix cell's "N drift"
204    /// badge).
205    pub fn drift_count(&self) -> usize {
206        self.drift.len()
207    }
208}
209
210/// Computed status for one service across all its declared mirrors.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub struct ServiceStatus {
213    pub name: String,
214    /// Display-only one-liner for the service's address — a domain, or
215    /// `node:<prefix>/<alpn>` for a node-addressed service (R926-F1).
216    ///
217    /// Renamed from `domain` when addressing became a sum type: this
218    /// consumer never dialled the string, it printed it, and printing a
219    /// placeholder domain for a service that has none is what
220    /// `unset.yah-cloud.invalid` is. [`crate::ServiceAddress::label`]
221    /// always has something true to say.
222    pub address: String,
223    /// One cell per declared mirror, keyed by env. Tiers with no
224    /// `mirrors/<env>.toml` are simply absent — the UI renders those as
225    /// "undeclared" against its canonical tier list (dev/sim/prod/ha).
226    pub cells: BTreeMap<String, CellStatus>,
227}
228
229/// Roll-up counts across a set of services, for the catalog's summary badges.
230#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
231pub struct StatusSummary {
232    pub synced: usize,
233    pub out_of_sync: usize,
234    pub unknown: usize,
235    pub healthy: usize,
236    pub progressing: usize,
237    pub degraded: usize,
238    pub missing: usize,
239    pub idle: usize,
240}
241
242/// Compute the status of one mirror cell from its declared manifest and an
243/// optional live observation.
244///
245/// Policy:
246/// - **No observation** → `unknown` sync, `missing` health. Honest default
247///   for tiers with no live source (prod/ha today).
248/// - **Sync**: drift present, or a known live revision that differs from the
249///   declared revision → `out-of-sync`; otherwise `synced`. (A `None` live
250///   revision never forces out-of-sync — absence of info is not divergence.)
251/// - **Health**: `errored` → `degraded`; `running && ready` → `healthy`;
252///   `running && !ready` → `progressing`; `!running` → `idle` for a local
253///   (on-demand) mirror, else `missing`.
254pub fn compute_cell(
255    env: &str,
256    mirror: &MirrorConfig,
257    obs: Option<&MirrorObservation>,
258) -> CellStatus {
259    let runtime = Runtime::from_mirror(mirror);
260    let declared_revision = declared_revision(mirror);
261    let provider_label = provider_label(mirror);
262
263    let (sync, health, live_revision, drift) = match obs {
264        None => (SyncState::Unknown, HealthState::Missing, None, Vec::new()),
265        Some(o) => {
266            let drift = compute_drift(mirror, o);
267            let rev_diverges = matches!(
268                (&declared_revision, &o.live_revision),
269                (Some(d), Some(l)) if d != l
270            );
271            let sync = if !drift.is_empty() || rev_diverges {
272                SyncState::OutOfSync
273            } else {
274                SyncState::Synced
275            };
276            let health = if o.errored {
277                HealthState::Degraded
278            } else if o.running && o.ready {
279                HealthState::Healthy
280            } else if o.running {
281                HealthState::Progressing
282            } else if mirror.shape == MirrorShape::Local {
283                HealthState::Idle
284            } else {
285                HealthState::Missing
286            };
287            (sync, health, o.live_revision.clone(), drift)
288        }
289    };
290
291    CellStatus {
292        env: env.to_string(),
293        sync,
294        health,
295        runtime,
296        shape: mirror.shape,
297        declared_revision,
298        live_revision,
299        provider_label,
300        drift,
301        workload_status: None,
302    }
303}
304
305/// Compute the status of one service across every mirror it declares.
306/// `observations` supplies a live snapshot per env; envs absent from the map
307/// are treated as unobserved (`None`).
308pub fn compute_service(
309    svc: &ServiceWithMirrors,
310    observations: &BTreeMap<String, MirrorObservation>,
311) -> ServiceStatus {
312    let cells = svc
313        .mirrors
314        .iter()
315        .map(|(env, mirror)| {
316            let cell = compute_cell(env, mirror, observations.get(env));
317            (env.clone(), cell)
318        })
319        .collect();
320    ServiceStatus {
321        name: svc.service.name.clone(),
322        address: svc.service.address.label(),
323        cells,
324    }
325}
326
327/// Tally sync + health states across every cell of every service.
328pub fn summarize(services: &[ServiceStatus]) -> StatusSummary {
329    let mut s = StatusSummary::default();
330    for svc in services {
331        for cell in svc.cells.values() {
332            match cell.sync {
333                SyncState::Synced => s.synced += 1,
334                SyncState::OutOfSync => s.out_of_sync += 1,
335                SyncState::Unknown => s.unknown += 1,
336            }
337            match cell.health {
338                HealthState::Healthy => s.healthy += 1,
339                HealthState::Progressing => s.progressing += 1,
340                HealthState::Degraded => s.degraded += 1,
341                HealthState::Missing => s.missing += 1,
342                HealthState::Idle => s.idle += 1,
343            }
344        }
345    }
346    s
347}
348
349// ─── field helpers ──────────────────────────────────────────────────────────
350
351/// The version-bearing fields we recognise on a provider slot, in priority
352/// order. `image` carries its own tag (`caddy:2.8.1`) so it wins.
353const REVISION_KEYS: [&str; 3] = ["image", "version", "tag"];
354
355/// Best-effort declared revision: scan slots (sorted by role for
356/// determinism) for the first `image`/`version`/`tag` field.
357fn declared_revision(mirror: &MirrorConfig) -> Option<String> {
358    for slot in mirror.providers.values() {
359        let fields = slot_fields(slot);
360        for key in REVISION_KEYS {
361            if let Some(v) = fields.get(key).and_then(toml_value_to_string) {
362                return Some(v);
363            }
364        }
365    }
366    None
367}
368
369/// Operator-facing provider label: the distinct slot providers joined with
370/// ` + ` (e.g. `miniflare-container + minio-container`, or `cloudflare`).
371fn provider_label(mirror: &MirrorConfig) -> String {
372    let mut seen: Vec<String> = Vec::new();
373    for slot in mirror.providers.values() {
374        let label = match slot {
375            MirrorProviderSlot::Reference { provider_id, .. } => provider_id.clone(),
376            MirrorProviderSlot::Inline { kind, .. } => provider_kind_label(*kind),
377        };
378        if !seen.contains(&label) {
379            seen.push(label);
380        }
381    }
382    seen.join(" + ")
383}
384
385/// Diff each declared slot field against the observed live value. Only emits
386/// entries for keys the observation actually reports — an empty `live_fields`
387/// (the common case today) yields no drift.
388fn compute_drift(mirror: &MirrorConfig, obs: &MirrorObservation) -> Vec<DriftEntry> {
389    let mut out = Vec::new();
390    for (role, slot) in &mirror.providers {
391        let Some(live_slot) = obs.live_fields.get(role) else {
392            continue;
393        };
394        let declared = slot_fields(slot);
395        for (key, live_val) in live_slot {
396            let desired = declared.get(key).and_then(toml_value_to_string);
397            // Drift only when declared has a value AND it differs from live.
398            if let Some(desired) = desired {
399                if &desired != live_val {
400                    out.push(DriftEntry {
401                        path: format!("providers.{role}.{key}"),
402                        desired,
403                        live: live_val.clone(),
404                    });
405                }
406            }
407        }
408    }
409    out.sort_by(|a, b| a.path.cmp(&b.path));
410    out
411}
412
413fn slot_fields(slot: &MirrorProviderSlot) -> &BTreeMap<String, toml::Value> {
414    match slot {
415        MirrorProviderSlot::Reference { fields, .. } => fields,
416        MirrorProviderSlot::Inline { fields, .. } => fields,
417    }
418}
419
420/// Render a scalar TOML value as a string for revision/drift display.
421/// Non-scalar values (tables/arrays) are not version-bearing → `None`.
422fn toml_value_to_string(v: &toml::Value) -> Option<String> {
423    match v {
424        toml::Value::String(s) => Some(s.clone()),
425        toml::Value::Integer(n) => Some(n.to_string()),
426        toml::Value::Float(f) => Some(f.to_string()),
427        toml::Value::Boolean(b) => Some(b.to_string()),
428        _ => None,
429    }
430}
431
432/// Kebab-case label for an inline provider kind (matches the serde wire form).
433fn provider_kind_label(kind: Provider) -> String {
434    match kind {
435        Provider::Cloudflare => "cloudflare",
436        Provider::Hetzner => "hetzner",
437        Provider::Vultr => "vultr",
438        Provider::Static => "static",
439        Provider::MiniflareNative => "miniflare-native",
440        Provider::LocalContainer => "local-container",
441        Provider::LocalProcess => "local-process",
442        Provider::Device => "device",
443        Provider::MiniflareContainer => "miniflare-container",
444        Provider::MinioContainer => "minio-container",
445        Provider::LocalPgDev => "local-pg-dev",
446        Provider::LocalMailcrab => "local-mailcrab",
447        Provider::LocalS3Fs => "local-s3-fs",
448    }
449    .to_string()
450}
451
452// ─── sync-history store ───────────────────────────────────────────────────
453
454/// One recorded sync operation for a (service, env) pair.
455///
456/// Written to `.yah/jit/services/<service>/<env>/syncs/<id>.json` on
457/// completion (R323-F10). Terminal-only — no in-progress state.
458#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct SyncHistoryEntry {
460    pub id: String,
461    pub service: String,
462    pub env: String,
463    pub status: SyncOutcome,
464    pub started_at: chrono::DateTime<chrono::Utc>,
465    pub completed_at: chrono::DateTime<chrono::Utc>,
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub triggered_by: Option<String>,
468    /// Declared revision that was synced to (e.g. `caddy:2.8.1`).
469    #[serde(default, skip_serializing_if = "Option::is_none")]
470    pub rev: Option<String>,
471    pub workload_count: u32,
472}
473
474/// Terminal outcome of a sync operation.
475#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
476#[serde(rename_all = "lowercase")]
477pub enum SyncOutcome {
478    Success,
479    Failed,
480    Cancelled,
481}
482
483/// Generate a random 8-byte hex string suitable for sync history entry IDs.
484pub fn new_sync_id() -> String {
485    let mut bytes = [0u8; 8];
486    getrandom::getrandom(&mut bytes).unwrap_or(());
487    hex::encode(bytes)
488}
489
490// ─── tests ────────────────────────────────────────────────────────────────
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use crate::config::{ServiceConfig, ServiceWithMirrors};
496
497    /// Parse a mirror from inline TOML (the on-disk form), so tests exercise
498    /// the same path the loader uses.
499    fn mirror(src: &str) -> MirrorConfig {
500        toml::from_str(src).expect("mirror toml")
501    }
502
503    fn local_static_mirror() -> MirrorConfig {
504        mirror(
505            "schema_version = 1\nshape = \"local\"\n\n[providers.static]\nkind = \"miniflare-native\"\nport = 4321\n",
506        )
507    }
508
509    fn cloudflare_mirror() -> MirrorConfig {
510        mirror(
511            "schema_version = 1\nshape = \"single-machine\"\n\n[providers.static]\nuse = \"cloudflare\"\nimage = \"caddy:2.8.1\"\n",
512        )
513    }
514
515    fn sim_miniflare_mirror() -> MirrorConfig {
516        mirror(
517            "schema_version = 1\nshape = \"local\"\n\n[providers.static]\nkind = \"miniflare-container\"\nimage = \"caddy:2.8.1\"\nport = 8080\n\n[providers.object_store]\nkind = \"minio-container\"\n",
518        )
519    }
520
521    #[test]
522    fn runtime_process_for_local_static_only() {
523        assert_eq!(
524            Runtime::from_mirror(&local_static_mirror()),
525            Runtime::Process
526        );
527    }
528
529    #[test]
530    fn runtime_containers_for_inline_container_kinds() {
531        assert_eq!(
532            Runtime::from_mirror(&sim_miniflare_mirror()),
533            Runtime::Containers
534        );
535    }
536
537    #[test]
538    fn runtime_containers_for_referenced_provider() {
539        assert_eq!(
540            Runtime::from_mirror(&cloudflare_mirror()),
541            Runtime::Containers
542        );
543    }
544
545    #[test]
546    fn no_observation_is_unknown_missing() {
547        let cell = compute_cell("ha", &cloudflare_mirror(), None);
548        assert_eq!(cell.sync, SyncState::Unknown);
549        assert_eq!(cell.health, HealthState::Missing);
550        assert!(cell.drift.is_empty());
551        assert_eq!(cell.live_revision, None);
552    }
553
554    #[test]
555    fn running_ready_local_is_synced_healthy() {
556        let obs = MirrorObservation {
557            running: true,
558            ready: true,
559            ..Default::default()
560        };
561        let cell = compute_cell("dev", &local_static_mirror(), Some(&obs));
562        assert_eq!(cell.sync, SyncState::Synced);
563        assert_eq!(cell.health, HealthState::Healthy);
564        assert_eq!(cell.runtime, Runtime::Process);
565    }
566
567    #[test]
568    fn declared_but_down_local_is_idle_not_missing() {
569        // A local (on-demand) mirror that isn't running is idle — nothing
570        // is wrong, it just hasn't been brought up. Distinct from missing.
571        let obs = MirrorObservation {
572            running: false,
573            ..Default::default()
574        };
575        let cell = compute_cell("sim", &sim_miniflare_mirror(), Some(&obs));
576        assert_eq!(cell.health, HealthState::Idle);
577        assert_eq!(cell.sync, SyncState::Synced);
578    }
579
580    #[test]
581    fn down_continuous_tier_is_missing() {
582        // A single-machine (continuously-live) mirror observed as not running
583        // is missing, not idle.
584        let obs = MirrorObservation {
585            running: false,
586            ..Default::default()
587        };
588        let cell = compute_cell("prod", &cloudflare_mirror(), Some(&obs));
589        assert_eq!(cell.health, HealthState::Missing);
590    }
591
592    #[test]
593    fn running_not_ready_is_progressing() {
594        let obs = MirrorObservation {
595            running: true,
596            ready: false,
597            ..Default::default()
598        };
599        let cell = compute_cell("prod", &cloudflare_mirror(), Some(&obs));
600        assert_eq!(cell.health, HealthState::Progressing);
601    }
602
603    #[test]
604    fn errored_is_degraded() {
605        let obs = MirrorObservation {
606            running: true,
607            ready: true,
608            errored: true,
609            ..Default::default()
610        };
611        let cell = compute_cell("prod", &cloudflare_mirror(), Some(&obs));
612        assert_eq!(cell.health, HealthState::Degraded);
613    }
614
615    #[test]
616    fn diverging_live_revision_is_out_of_sync() {
617        let obs = MirrorObservation {
618            running: true,
619            ready: true,
620            live_revision: Some("caddy:2.7.6".into()),
621            ..Default::default()
622        };
623        let cell = compute_cell("prod", &cloudflare_mirror(), Some(&obs));
624        assert_eq!(cell.declared_revision.as_deref(), Some("caddy:2.8.1"));
625        assert_eq!(cell.live_revision.as_deref(), Some("caddy:2.7.6"));
626        assert_eq!(cell.sync, SyncState::OutOfSync);
627    }
628
629    #[test]
630    fn matching_live_revision_is_synced() {
631        let obs = MirrorObservation {
632            running: true,
633            ready: true,
634            live_revision: Some("caddy:2.8.1".into()),
635            ..Default::default()
636        };
637        let cell = compute_cell("prod", &cloudflare_mirror(), Some(&obs));
638        assert_eq!(cell.sync, SyncState::Synced);
639    }
640
641    #[test]
642    fn unknown_live_revision_does_not_force_out_of_sync() {
643        // We know the declared rev but not the live one — that's not enough
644        // to call divergence. Stays synced.
645        let obs = MirrorObservation {
646            running: true,
647            ready: true,
648            live_revision: None,
649            ..Default::default()
650        };
651        let cell = compute_cell("prod", &cloudflare_mirror(), Some(&obs));
652        assert_eq!(cell.sync, SyncState::Synced);
653    }
654
655    #[test]
656    fn field_drift_is_detected_and_makes_out_of_sync() {
657        let mut live_fields = BTreeMap::new();
658        let mut static_slot = BTreeMap::new();
659        static_slot.insert("image".to_string(), "caddy:2.7.6".to_string());
660        static_slot.insert("port".to_string(), "8080".to_string()); // matches declared
661        live_fields.insert("static".to_string(), static_slot);
662
663        let obs = MirrorObservation {
664            running: true,
665            ready: true,
666            live_fields,
667            ..Default::default()
668        };
669        let cell = compute_cell("sim", &sim_miniflare_mirror(), Some(&obs));
670        assert_eq!(cell.sync, SyncState::OutOfSync);
671        assert_eq!(cell.drift_count(), 1, "only the image field drifts");
672        assert_eq!(cell.drift[0].path, "providers.static.image");
673        assert_eq!(cell.drift[0].desired, "caddy:2.8.1");
674        assert_eq!(cell.drift[0].live, "caddy:2.7.6");
675    }
676
677    #[test]
678    fn provider_label_joins_inline_kinds() {
679        // Order follows the role-key (BTreeMap) order — deterministic:
680        // `object_store` (minio) sorts before `static` (miniflare).
681        assert_eq!(
682            provider_label(&sim_miniflare_mirror()),
683            "minio-container + miniflare-container"
684        );
685        assert_eq!(provider_label(&cloudflare_mirror()), "cloudflare");
686    }
687
688    #[test]
689    fn declared_revision_none_when_no_version_field() {
690        assert_eq!(declared_revision(&local_static_mirror()), None);
691    }
692
693    #[test]
694    fn compute_service_and_summary_roll_up() {
695        let svc = ServiceWithMirrors {
696            service: ServiceConfig {
697                schema_version: 1,
698                name: "yah-dev".into(),
699                address: crate::config::ServiceAddress::front_door("yah.dev"),
700                description: None,
701                components: vec![],
702                db: crate::DbCatalog::default(),
703            },
704            mirrors: BTreeMap::from([
705                ("dev".to_string(), local_static_mirror()),
706                ("prod".to_string(), cloudflare_mirror()),
707            ]),
708            component_transform_recipes: BTreeMap::new(),
709            passway_machines: BTreeMap::new(),
710        };
711
712        let mut obs = BTreeMap::new();
713        obs.insert(
714            "dev".to_string(),
715            MirrorObservation {
716                running: true,
717                ready: true,
718                ..Default::default()
719            },
720        );
721        // No observation for "prod" → unknown/missing.
722
723        let status = compute_service(&svc, &obs);
724        assert_eq!(status.name, "yah-dev");
725        assert_eq!(status.cells.len(), 2);
726        assert_eq!(status.cells["dev"].sync, SyncState::Synced);
727        assert_eq!(status.cells["dev"].health, HealthState::Healthy);
728        assert_eq!(status.cells["prod"].sync, SyncState::Unknown);
729        assert_eq!(status.cells["prod"].health, HealthState::Missing);
730
731        let summary = summarize(&[status]);
732        assert_eq!(summary.synced, 1);
733        assert_eq!(summary.unknown, 1);
734        assert_eq!(summary.healthy, 1);
735        assert_eq!(summary.missing, 1);
736    }
737
738    #[test]
739    fn states_serialize_in_kebab_case_for_the_wire() {
740        assert_eq!(
741            serde_json::to_string(&SyncState::OutOfSync).unwrap(),
742            "\"out-of-sync\""
743        );
744        assert_eq!(
745            serde_json::to_string(&HealthState::Idle).unwrap(),
746            "\"idle\""
747        );
748        assert_eq!(
749            serde_json::to_string(&Runtime::Containers).unwrap(),
750            "\"containers\""
751        );
752    }
753}