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 cloud
25//! tiers. Tiers with **no observation** (`None`) resolve to `unknown` sync /
26//! `missing` health, which is the honest state today for `cloud`/`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. cloud/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    /// (cloud/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`/`cloud`/`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 (`local-static`) 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/cloud 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 `local-static` 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    pub domain: String,
215    /// One cell per declared mirror, keyed by env. Tiers with no
216    /// `mirrors/<env>.toml` are simply absent — the UI renders those as
217    /// "undeclared" against its canonical tier list (dev/sim/cloud/ha).
218    pub cells: BTreeMap<String, CellStatus>,
219}
220
221/// Roll-up counts across a set of services, for the catalog's summary badges.
222#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
223pub struct StatusSummary {
224    pub synced: usize,
225    pub out_of_sync: usize,
226    pub unknown: usize,
227    pub healthy: usize,
228    pub progressing: usize,
229    pub degraded: usize,
230    pub missing: usize,
231    pub idle: usize,
232}
233
234/// Compute the status of one mirror cell from its declared manifest and an
235/// optional live observation.
236///
237/// Policy:
238/// - **No observation** → `unknown` sync, `missing` health. Honest default
239///   for tiers with no live source (cloud/ha today).
240/// - **Sync**: drift present, or a known live revision that differs from the
241///   declared revision → `out-of-sync`; otherwise `synced`. (A `None` live
242///   revision never forces out-of-sync — absence of info is not divergence.)
243/// - **Health**: `errored` → `degraded`; `running && ready` → `healthy`;
244///   `running && !ready` → `progressing`; `!running` → `idle` for a local
245///   (on-demand) mirror, else `missing`.
246pub fn compute_cell(
247    env: &str,
248    mirror: &MirrorConfig,
249    obs: Option<&MirrorObservation>,
250) -> CellStatus {
251    let runtime = Runtime::from_mirror(mirror);
252    let declared_revision = declared_revision(mirror);
253    let provider_label = provider_label(mirror);
254
255    let (sync, health, live_revision, drift) = match obs {
256        None => (SyncState::Unknown, HealthState::Missing, None, Vec::new()),
257        Some(o) => {
258            let drift = compute_drift(mirror, o);
259            let rev_diverges = matches!(
260                (&declared_revision, &o.live_revision),
261                (Some(d), Some(l)) if d != l
262            );
263            let sync = if !drift.is_empty() || rev_diverges {
264                SyncState::OutOfSync
265            } else {
266                SyncState::Synced
267            };
268            let health = if o.errored {
269                HealthState::Degraded
270            } else if o.running && o.ready {
271                HealthState::Healthy
272            } else if o.running {
273                HealthState::Progressing
274            } else if mirror.shape == MirrorShape::Local {
275                HealthState::Idle
276            } else {
277                HealthState::Missing
278            };
279            (sync, health, o.live_revision.clone(), drift)
280        }
281    };
282
283    CellStatus {
284        env: env.to_string(),
285        sync,
286        health,
287        runtime,
288        shape: mirror.shape,
289        declared_revision,
290        live_revision,
291        provider_label,
292        drift,
293        workload_status: None,
294    }
295}
296
297/// Compute the status of one service across every mirror it declares.
298/// `observations` supplies a live snapshot per env; envs absent from the map
299/// are treated as unobserved (`None`).
300pub fn compute_service(
301    svc: &ServiceWithMirrors,
302    observations: &BTreeMap<String, MirrorObservation>,
303) -> ServiceStatus {
304    let cells = svc
305        .mirrors
306        .iter()
307        .map(|(env, mirror)| {
308            let cell = compute_cell(env, mirror, observations.get(env));
309            (env.clone(), cell)
310        })
311        .collect();
312    ServiceStatus {
313        name: svc.service.name.clone(),
314        domain: svc.service.domain.clone(),
315        cells,
316    }
317}
318
319/// Tally sync + health states across every cell of every service.
320pub fn summarize(services: &[ServiceStatus]) -> StatusSummary {
321    let mut s = StatusSummary::default();
322    for svc in services {
323        for cell in svc.cells.values() {
324            match cell.sync {
325                SyncState::Synced => s.synced += 1,
326                SyncState::OutOfSync => s.out_of_sync += 1,
327                SyncState::Unknown => s.unknown += 1,
328            }
329            match cell.health {
330                HealthState::Healthy => s.healthy += 1,
331                HealthState::Progressing => s.progressing += 1,
332                HealthState::Degraded => s.degraded += 1,
333                HealthState::Missing => s.missing += 1,
334                HealthState::Idle => s.idle += 1,
335            }
336        }
337    }
338    s
339}
340
341// ─── field helpers ──────────────────────────────────────────────────────────
342
343/// The version-bearing fields we recognise on a provider slot, in priority
344/// order. `image` carries its own tag (`caddy:2.8.1`) so it wins.
345const REVISION_KEYS: [&str; 3] = ["image", "version", "tag"];
346
347/// Best-effort declared revision: scan slots (sorted by role for
348/// determinism) for the first `image`/`version`/`tag` field.
349fn declared_revision(mirror: &MirrorConfig) -> Option<String> {
350    for slot in mirror.providers.values() {
351        let fields = slot_fields(slot);
352        for key in REVISION_KEYS {
353            if let Some(v) = fields.get(key).and_then(toml_value_to_string) {
354                return Some(v);
355            }
356        }
357    }
358    None
359}
360
361/// Operator-facing provider label: the distinct slot providers joined with
362/// ` + ` (e.g. `miniflare-container + minio-container`, or `cloudflare`).
363fn provider_label(mirror: &MirrorConfig) -> String {
364    let mut seen: Vec<String> = Vec::new();
365    for slot in mirror.providers.values() {
366        let label = match slot {
367            MirrorProviderSlot::Reference { provider_id, .. } => provider_id.clone(),
368            MirrorProviderSlot::Inline { kind, .. } => provider_kind_label(*kind),
369        };
370        if !seen.contains(&label) {
371            seen.push(label);
372        }
373    }
374    seen.join(" + ")
375}
376
377/// Diff each declared slot field against the observed live value. Only emits
378/// entries for keys the observation actually reports — an empty `live_fields`
379/// (the common case today) yields no drift.
380fn compute_drift(mirror: &MirrorConfig, obs: &MirrorObservation) -> Vec<DriftEntry> {
381    let mut out = Vec::new();
382    for (role, slot) in &mirror.providers {
383        let Some(live_slot) = obs.live_fields.get(role) else {
384            continue;
385        };
386        let declared = slot_fields(slot);
387        for (key, live_val) in live_slot {
388            let desired = declared.get(key).and_then(toml_value_to_string);
389            // Drift only when declared has a value AND it differs from live.
390            if let Some(desired) = desired {
391                if &desired != live_val {
392                    out.push(DriftEntry {
393                        path: format!("providers.{role}.{key}"),
394                        desired,
395                        live: live_val.clone(),
396                    });
397                }
398            }
399        }
400    }
401    out.sort_by(|a, b| a.path.cmp(&b.path));
402    out
403}
404
405fn slot_fields(slot: &MirrorProviderSlot) -> &BTreeMap<String, toml::Value> {
406    match slot {
407        MirrorProviderSlot::Reference { fields, .. } => fields,
408        MirrorProviderSlot::Inline { fields, .. } => fields,
409    }
410}
411
412/// Render a scalar TOML value as a string for revision/drift display.
413/// Non-scalar values (tables/arrays) are not version-bearing → `None`.
414fn toml_value_to_string(v: &toml::Value) -> Option<String> {
415    match v {
416        toml::Value::String(s) => Some(s.clone()),
417        toml::Value::Integer(n) => Some(n.to_string()),
418        toml::Value::Float(f) => Some(f.to_string()),
419        toml::Value::Boolean(b) => Some(b.to_string()),
420        _ => None,
421    }
422}
423
424/// Kebab-case label for an inline provider kind (matches the serde wire form).
425fn provider_kind_label(kind: Provider) -> String {
426    match kind {
427        Provider::Cloudflare => "cloudflare",
428        Provider::Hetzner => "hetzner",
429        Provider::Vultr => "vultr",
430        Provider::Static => "static",
431        Provider::LocalStatic => "local-static",
432        Provider::LocalContainer => "local-container",
433        Provider::LocalProcess => "local-process",
434        Provider::MiniflareContainer => "miniflare-container",
435        Provider::MinioContainer => "minio-container",
436        Provider::LocalPgDev => "local-pg-dev",
437    }
438    .to_string()
439}
440
441// ─── sync-history store ───────────────────────────────────────────────────
442
443/// One recorded sync operation for a (service, env) pair.
444///
445/// Written to `.yah/jit/services/<service>/<env>/syncs/<id>.json` on
446/// completion (R323-F10). Terminal-only — no in-progress state.
447#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct SyncHistoryEntry {
449    pub id: String,
450    pub service: String,
451    pub env: String,
452    pub status: SyncOutcome,
453    pub started_at: chrono::DateTime<chrono::Utc>,
454    pub completed_at: chrono::DateTime<chrono::Utc>,
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub triggered_by: Option<String>,
457    /// Declared revision that was synced to (e.g. `caddy:2.8.1`).
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub rev: Option<String>,
460    pub workload_count: u32,
461}
462
463/// Terminal outcome of a sync operation.
464#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
465#[serde(rename_all = "lowercase")]
466pub enum SyncOutcome {
467    Success,
468    Failed,
469    Cancelled,
470}
471
472/// Generate a random 8-byte hex string suitable for sync history entry IDs.
473pub fn new_sync_id() -> String {
474    let mut bytes = [0u8; 8];
475    getrandom::getrandom(&mut bytes).unwrap_or(());
476    hex::encode(bytes)
477}
478
479// ─── tests ────────────────────────────────────────────────────────────────
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use crate::config::{ServiceConfig, ServiceWithMirrors};
485
486    /// Parse a mirror from inline TOML (the on-disk form), so tests exercise
487    /// the same path the loader uses.
488    fn mirror(src: &str) -> MirrorConfig {
489        toml::from_str(src).expect("mirror toml")
490    }
491
492    fn local_static_mirror() -> MirrorConfig {
493        mirror(
494            "schema_version = 1\nshape = \"local\"\n\n[providers.static]\nkind = \"local-static\"\nport = 4321\n",
495        )
496    }
497
498    fn cloudflare_mirror() -> MirrorConfig {
499        mirror(
500            "schema_version = 1\nshape = \"single-machine\"\n\n[providers.static]\nuse = \"cloudflare\"\nimage = \"caddy:2.8.1\"\n",
501        )
502    }
503
504    fn sim_miniflare_mirror() -> MirrorConfig {
505        mirror(
506            "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",
507        )
508    }
509
510    #[test]
511    fn runtime_process_for_local_static_only() {
512        assert_eq!(
513            Runtime::from_mirror(&local_static_mirror()),
514            Runtime::Process
515        );
516    }
517
518    #[test]
519    fn runtime_containers_for_inline_container_kinds() {
520        assert_eq!(
521            Runtime::from_mirror(&sim_miniflare_mirror()),
522            Runtime::Containers
523        );
524    }
525
526    #[test]
527    fn runtime_containers_for_referenced_provider() {
528        assert_eq!(
529            Runtime::from_mirror(&cloudflare_mirror()),
530            Runtime::Containers
531        );
532    }
533
534    #[test]
535    fn no_observation_is_unknown_missing() {
536        let cell = compute_cell("ha", &cloudflare_mirror(), None);
537        assert_eq!(cell.sync, SyncState::Unknown);
538        assert_eq!(cell.health, HealthState::Missing);
539        assert!(cell.drift.is_empty());
540        assert_eq!(cell.live_revision, None);
541    }
542
543    #[test]
544    fn running_ready_local_is_synced_healthy() {
545        let obs = MirrorObservation {
546            running: true,
547            ready: true,
548            ..Default::default()
549        };
550        let cell = compute_cell("dev", &local_static_mirror(), Some(&obs));
551        assert_eq!(cell.sync, SyncState::Synced);
552        assert_eq!(cell.health, HealthState::Healthy);
553        assert_eq!(cell.runtime, Runtime::Process);
554    }
555
556    #[test]
557    fn declared_but_down_local_is_idle_not_missing() {
558        // A local (on-demand) mirror that isn't running is idle — nothing
559        // is wrong, it just hasn't been brought up. Distinct from missing.
560        let obs = MirrorObservation {
561            running: false,
562            ..Default::default()
563        };
564        let cell = compute_cell("sim", &sim_miniflare_mirror(), Some(&obs));
565        assert_eq!(cell.health, HealthState::Idle);
566        assert_eq!(cell.sync, SyncState::Synced);
567    }
568
569    #[test]
570    fn down_continuous_tier_is_missing() {
571        // A single-machine (continuously-live) mirror observed as not running
572        // is missing, not idle.
573        let obs = MirrorObservation {
574            running: false,
575            ..Default::default()
576        };
577        let cell = compute_cell("cloud", &cloudflare_mirror(), Some(&obs));
578        assert_eq!(cell.health, HealthState::Missing);
579    }
580
581    #[test]
582    fn running_not_ready_is_progressing() {
583        let obs = MirrorObservation {
584            running: true,
585            ready: false,
586            ..Default::default()
587        };
588        let cell = compute_cell("cloud", &cloudflare_mirror(), Some(&obs));
589        assert_eq!(cell.health, HealthState::Progressing);
590    }
591
592    #[test]
593    fn errored_is_degraded() {
594        let obs = MirrorObservation {
595            running: true,
596            ready: true,
597            errored: true,
598            ..Default::default()
599        };
600        let cell = compute_cell("cloud", &cloudflare_mirror(), Some(&obs));
601        assert_eq!(cell.health, HealthState::Degraded);
602    }
603
604    #[test]
605    fn diverging_live_revision_is_out_of_sync() {
606        let obs = MirrorObservation {
607            running: true,
608            ready: true,
609            live_revision: Some("caddy:2.7.6".into()),
610            ..Default::default()
611        };
612        let cell = compute_cell("cloud", &cloudflare_mirror(), Some(&obs));
613        assert_eq!(cell.declared_revision.as_deref(), Some("caddy:2.8.1"));
614        assert_eq!(cell.live_revision.as_deref(), Some("caddy:2.7.6"));
615        assert_eq!(cell.sync, SyncState::OutOfSync);
616    }
617
618    #[test]
619    fn matching_live_revision_is_synced() {
620        let obs = MirrorObservation {
621            running: true,
622            ready: true,
623            live_revision: Some("caddy:2.8.1".into()),
624            ..Default::default()
625        };
626        let cell = compute_cell("cloud", &cloudflare_mirror(), Some(&obs));
627        assert_eq!(cell.sync, SyncState::Synced);
628    }
629
630    #[test]
631    fn unknown_live_revision_does_not_force_out_of_sync() {
632        // We know the declared rev but not the live one — that's not enough
633        // to call divergence. Stays synced.
634        let obs = MirrorObservation {
635            running: true,
636            ready: true,
637            live_revision: None,
638            ..Default::default()
639        };
640        let cell = compute_cell("cloud", &cloudflare_mirror(), Some(&obs));
641        assert_eq!(cell.sync, SyncState::Synced);
642    }
643
644    #[test]
645    fn field_drift_is_detected_and_makes_out_of_sync() {
646        let mut live_fields = BTreeMap::new();
647        let mut static_slot = BTreeMap::new();
648        static_slot.insert("image".to_string(), "caddy:2.7.6".to_string());
649        static_slot.insert("port".to_string(), "8080".to_string()); // matches declared
650        live_fields.insert("static".to_string(), static_slot);
651
652        let obs = MirrorObservation {
653            running: true,
654            ready: true,
655            live_fields,
656            ..Default::default()
657        };
658        let cell = compute_cell("sim", &sim_miniflare_mirror(), Some(&obs));
659        assert_eq!(cell.sync, SyncState::OutOfSync);
660        assert_eq!(cell.drift_count(), 1, "only the image field drifts");
661        assert_eq!(cell.drift[0].path, "providers.static.image");
662        assert_eq!(cell.drift[0].desired, "caddy:2.8.1");
663        assert_eq!(cell.drift[0].live, "caddy:2.7.6");
664    }
665
666    #[test]
667    fn provider_label_joins_inline_kinds() {
668        // Order follows the role-key (BTreeMap) order — deterministic:
669        // `object_store` (minio) sorts before `static` (miniflare).
670        assert_eq!(
671            provider_label(&sim_miniflare_mirror()),
672            "minio-container + miniflare-container"
673        );
674        assert_eq!(provider_label(&cloudflare_mirror()), "cloudflare");
675    }
676
677    #[test]
678    fn declared_revision_none_when_no_version_field() {
679        assert_eq!(declared_revision(&local_static_mirror()), None);
680    }
681
682    #[test]
683    fn compute_service_and_summary_roll_up() {
684        let svc = ServiceWithMirrors {
685            service: ServiceConfig {
686                schema_version: 1,
687                name: "yah-dev".into(),
688                domain: "yah.dev".into(),
689                components: vec![],
690                db: crate::DbCatalog::default(),
691            },
692            mirrors: BTreeMap::from([
693                ("dev".to_string(), local_static_mirror()),
694                ("cloud".to_string(), cloudflare_mirror()),
695            ]),
696            component_transform_recipes: BTreeMap::new(),
697        };
698
699        let mut obs = BTreeMap::new();
700        obs.insert(
701            "dev".to_string(),
702            MirrorObservation {
703                running: true,
704                ready: true,
705                ..Default::default()
706            },
707        );
708        // No observation for "cloud" → unknown/missing.
709
710        let status = compute_service(&svc, &obs);
711        assert_eq!(status.name, "yah-dev");
712        assert_eq!(status.cells.len(), 2);
713        assert_eq!(status.cells["dev"].sync, SyncState::Synced);
714        assert_eq!(status.cells["dev"].health, HealthState::Healthy);
715        assert_eq!(status.cells["cloud"].sync, SyncState::Unknown);
716        assert_eq!(status.cells["cloud"].health, HealthState::Missing);
717
718        let summary = summarize(&[status]);
719        assert_eq!(summary.synced, 1);
720        assert_eq!(summary.unknown, 1);
721        assert_eq!(summary.healthy, 1);
722        assert_eq!(summary.missing, 1);
723    }
724
725    #[test]
726    fn states_serialize_in_kebab_case_for_the_wire() {
727        assert_eq!(
728            serde_json::to_string(&SyncState::OutOfSync).unwrap(),
729            "\"out-of-sync\""
730        );
731        assert_eq!(
732            serde_json::to_string(&HealthState::Idle).unwrap(),
733            "\"idle\""
734        );
735        assert_eq!(
736            serde_json::to_string(&Runtime::Containers).unwrap(),
737            "\"containers\""
738        );
739    }
740}