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::MiniflareContainer => "miniflare-container",
443        Provider::MinioContainer => "minio-container",
444        Provider::LocalPgDev => "local-pg-dev",
445        Provider::LocalMailcrab => "local-mailcrab",
446        Provider::LocalS3Fs => "local-s3-fs",
447    }
448    .to_string()
449}
450
451// ─── sync-history store ───────────────────────────────────────────────────
452
453/// One recorded sync operation for a (service, env) pair.
454///
455/// Written to `.yah/jit/services/<service>/<env>/syncs/<id>.json` on
456/// completion (R323-F10). Terminal-only — no in-progress state.
457#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct SyncHistoryEntry {
459    pub id: String,
460    pub service: String,
461    pub env: String,
462    pub status: SyncOutcome,
463    pub started_at: chrono::DateTime<chrono::Utc>,
464    pub completed_at: chrono::DateTime<chrono::Utc>,
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub triggered_by: Option<String>,
467    /// Declared revision that was synced to (e.g. `caddy:2.8.1`).
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub rev: Option<String>,
470    pub workload_count: u32,
471}
472
473/// Terminal outcome of a sync operation.
474#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
475#[serde(rename_all = "lowercase")]
476pub enum SyncOutcome {
477    Success,
478    Failed,
479    Cancelled,
480}
481
482/// Generate a random 8-byte hex string suitable for sync history entry IDs.
483pub fn new_sync_id() -> String {
484    let mut bytes = [0u8; 8];
485    getrandom::getrandom(&mut bytes).unwrap_or(());
486    hex::encode(bytes)
487}
488
489// ─── tests ────────────────────────────────────────────────────────────────
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::config::{ServiceConfig, ServiceWithMirrors};
495
496    /// Parse a mirror from inline TOML (the on-disk form), so tests exercise
497    /// the same path the loader uses.
498    fn mirror(src: &str) -> MirrorConfig {
499        toml::from_str(src).expect("mirror toml")
500    }
501
502    fn local_static_mirror() -> MirrorConfig {
503        mirror(
504            "schema_version = 1\nshape = \"local\"\n\n[providers.static]\nkind = \"miniflare-native\"\nport = 4321\n",
505        )
506    }
507
508    fn cloudflare_mirror() -> MirrorConfig {
509        mirror(
510            "schema_version = 1\nshape = \"single-machine\"\n\n[providers.static]\nuse = \"cloudflare\"\nimage = \"caddy:2.8.1\"\n",
511        )
512    }
513
514    fn sim_miniflare_mirror() -> MirrorConfig {
515        mirror(
516            "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",
517        )
518    }
519
520    #[test]
521    fn runtime_process_for_local_static_only() {
522        assert_eq!(
523            Runtime::from_mirror(&local_static_mirror()),
524            Runtime::Process
525        );
526    }
527
528    #[test]
529    fn runtime_containers_for_inline_container_kinds() {
530        assert_eq!(
531            Runtime::from_mirror(&sim_miniflare_mirror()),
532            Runtime::Containers
533        );
534    }
535
536    #[test]
537    fn runtime_containers_for_referenced_provider() {
538        assert_eq!(
539            Runtime::from_mirror(&cloudflare_mirror()),
540            Runtime::Containers
541        );
542    }
543
544    #[test]
545    fn no_observation_is_unknown_missing() {
546        let cell = compute_cell("ha", &cloudflare_mirror(), None);
547        assert_eq!(cell.sync, SyncState::Unknown);
548        assert_eq!(cell.health, HealthState::Missing);
549        assert!(cell.drift.is_empty());
550        assert_eq!(cell.live_revision, None);
551    }
552
553    #[test]
554    fn running_ready_local_is_synced_healthy() {
555        let obs = MirrorObservation {
556            running: true,
557            ready: true,
558            ..Default::default()
559        };
560        let cell = compute_cell("dev", &local_static_mirror(), Some(&obs));
561        assert_eq!(cell.sync, SyncState::Synced);
562        assert_eq!(cell.health, HealthState::Healthy);
563        assert_eq!(cell.runtime, Runtime::Process);
564    }
565
566    #[test]
567    fn declared_but_down_local_is_idle_not_missing() {
568        // A local (on-demand) mirror that isn't running is idle — nothing
569        // is wrong, it just hasn't been brought up. Distinct from missing.
570        let obs = MirrorObservation {
571            running: false,
572            ..Default::default()
573        };
574        let cell = compute_cell("sim", &sim_miniflare_mirror(), Some(&obs));
575        assert_eq!(cell.health, HealthState::Idle);
576        assert_eq!(cell.sync, SyncState::Synced);
577    }
578
579    #[test]
580    fn down_continuous_tier_is_missing() {
581        // A single-machine (continuously-live) mirror observed as not running
582        // is missing, not idle.
583        let obs = MirrorObservation {
584            running: false,
585            ..Default::default()
586        };
587        let cell = compute_cell("prod", &cloudflare_mirror(), Some(&obs));
588        assert_eq!(cell.health, HealthState::Missing);
589    }
590
591    #[test]
592    fn running_not_ready_is_progressing() {
593        let obs = MirrorObservation {
594            running: true,
595            ready: false,
596            ..Default::default()
597        };
598        let cell = compute_cell("prod", &cloudflare_mirror(), Some(&obs));
599        assert_eq!(cell.health, HealthState::Progressing);
600    }
601
602    #[test]
603    fn errored_is_degraded() {
604        let obs = MirrorObservation {
605            running: true,
606            ready: true,
607            errored: true,
608            ..Default::default()
609        };
610        let cell = compute_cell("prod", &cloudflare_mirror(), Some(&obs));
611        assert_eq!(cell.health, HealthState::Degraded);
612    }
613
614    #[test]
615    fn diverging_live_revision_is_out_of_sync() {
616        let obs = MirrorObservation {
617            running: true,
618            ready: true,
619            live_revision: Some("caddy:2.7.6".into()),
620            ..Default::default()
621        };
622        let cell = compute_cell("prod", &cloudflare_mirror(), Some(&obs));
623        assert_eq!(cell.declared_revision.as_deref(), Some("caddy:2.8.1"));
624        assert_eq!(cell.live_revision.as_deref(), Some("caddy:2.7.6"));
625        assert_eq!(cell.sync, SyncState::OutOfSync);
626    }
627
628    #[test]
629    fn matching_live_revision_is_synced() {
630        let obs = MirrorObservation {
631            running: true,
632            ready: true,
633            live_revision: Some("caddy:2.8.1".into()),
634            ..Default::default()
635        };
636        let cell = compute_cell("prod", &cloudflare_mirror(), Some(&obs));
637        assert_eq!(cell.sync, SyncState::Synced);
638    }
639
640    #[test]
641    fn unknown_live_revision_does_not_force_out_of_sync() {
642        // We know the declared rev but not the live one — that's not enough
643        // to call divergence. Stays synced.
644        let obs = MirrorObservation {
645            running: true,
646            ready: true,
647            live_revision: None,
648            ..Default::default()
649        };
650        let cell = compute_cell("prod", &cloudflare_mirror(), Some(&obs));
651        assert_eq!(cell.sync, SyncState::Synced);
652    }
653
654    #[test]
655    fn field_drift_is_detected_and_makes_out_of_sync() {
656        let mut live_fields = BTreeMap::new();
657        let mut static_slot = BTreeMap::new();
658        static_slot.insert("image".to_string(), "caddy:2.7.6".to_string());
659        static_slot.insert("port".to_string(), "8080".to_string()); // matches declared
660        live_fields.insert("static".to_string(), static_slot);
661
662        let obs = MirrorObservation {
663            running: true,
664            ready: true,
665            live_fields,
666            ..Default::default()
667        };
668        let cell = compute_cell("sim", &sim_miniflare_mirror(), Some(&obs));
669        assert_eq!(cell.sync, SyncState::OutOfSync);
670        assert_eq!(cell.drift_count(), 1, "only the image field drifts");
671        assert_eq!(cell.drift[0].path, "providers.static.image");
672        assert_eq!(cell.drift[0].desired, "caddy:2.8.1");
673        assert_eq!(cell.drift[0].live, "caddy:2.7.6");
674    }
675
676    #[test]
677    fn provider_label_joins_inline_kinds() {
678        // Order follows the role-key (BTreeMap) order — deterministic:
679        // `object_store` (minio) sorts before `static` (miniflare).
680        assert_eq!(
681            provider_label(&sim_miniflare_mirror()),
682            "minio-container + miniflare-container"
683        );
684        assert_eq!(provider_label(&cloudflare_mirror()), "cloudflare");
685    }
686
687    #[test]
688    fn declared_revision_none_when_no_version_field() {
689        assert_eq!(declared_revision(&local_static_mirror()), None);
690    }
691
692    #[test]
693    fn compute_service_and_summary_roll_up() {
694        let svc = ServiceWithMirrors {
695            service: ServiceConfig {
696                schema_version: 1,
697                name: "yah-dev".into(),
698                address: crate::config::ServiceAddress::front_door("yah.dev"),
699                description: None,
700                components: vec![],
701                db: crate::DbCatalog::default(),
702            },
703            mirrors: BTreeMap::from([
704                ("dev".to_string(), local_static_mirror()),
705                ("prod".to_string(), cloudflare_mirror()),
706            ]),
707            component_transform_recipes: BTreeMap::new(),
708            passway_machines: BTreeMap::new(),
709        };
710
711        let mut obs = BTreeMap::new();
712        obs.insert(
713            "dev".to_string(),
714            MirrorObservation {
715                running: true,
716                ready: true,
717                ..Default::default()
718            },
719        );
720        // No observation for "prod" → unknown/missing.
721
722        let status = compute_service(&svc, &obs);
723        assert_eq!(status.name, "yah-dev");
724        assert_eq!(status.cells.len(), 2);
725        assert_eq!(status.cells["dev"].sync, SyncState::Synced);
726        assert_eq!(status.cells["dev"].health, HealthState::Healthy);
727        assert_eq!(status.cells["prod"].sync, SyncState::Unknown);
728        assert_eq!(status.cells["prod"].health, HealthState::Missing);
729
730        let summary = summarize(&[status]);
731        assert_eq!(summary.synced, 1);
732        assert_eq!(summary.unknown, 1);
733        assert_eq!(summary.healthy, 1);
734        assert_eq!(summary.missing, 1);
735    }
736
737    #[test]
738    fn states_serialize_in_kebab_case_for_the_wire() {
739        assert_eq!(
740            serde_json::to_string(&SyncState::OutOfSync).unwrap(),
741            "\"out-of-sync\""
742        );
743        assert_eq!(
744            serde_json::to_string(&HealthState::Idle).unwrap(),
745            "\"idle\""
746        );
747        assert_eq!(
748            serde_json::to_string(&Runtime::Containers).unwrap(),
749            "\"containers\""
750        );
751    }
752}