1use std::collections::BTreeMap;
40
41use serde::{Deserialize, Serialize};
42
43use crate::config::ServiceWithMirrors;
44use crate::{MirrorConfig, MirrorProviderSlot, MirrorShape, Provider};
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "kebab-case")]
49pub enum SyncState {
50 Synced,
52 OutOfSync,
55 Unknown,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "kebab-case")]
65pub enum HealthState {
66 Healthy,
68 Progressing,
70 Degraded,
72 Missing,
75 Idle,
79}
80
81#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "kebab-case")]
106pub enum Runtime {
107 Process,
109 Containers,
111}
112
113impl Runtime {
114 pub fn from_mirror(mirror: &MirrorConfig) -> Self {
119 let any_container = mirror.providers.values().any(|slot| match slot {
120 MirrorProviderSlot::Inline { kind, .. } => matches!(
122 kind,
123 Provider::MiniflareContainer | Provider::MinioContainer | Provider::LocalContainer
124 ),
125 MirrorProviderSlot::Reference { .. } => true,
128 });
129 if any_container {
130 Runtime::Containers
131 } else {
132 Runtime::Process
133 }
134 }
135}
136
137#[derive(Debug, Clone, Default, Serialize, Deserialize)]
144pub struct MirrorObservation {
145 pub running: bool,
147 pub ready: bool,
150 pub errored: bool,
152 pub live_revision: Option<String>,
156 pub live_fields: BTreeMap<String, BTreeMap<String, String>>,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct DriftEntry {
166 pub path: String,
168 pub desired: String,
170 pub live: String,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176pub struct CellStatus {
177 pub env: String,
179 pub sync: SyncState,
180 pub health: HealthState,
181 pub runtime: Runtime,
182 pub shape: MirrorShape,
183 pub declared_revision: Option<String>,
187 pub live_revision: Option<String>,
189 pub provider_label: String,
192 pub drift: Vec<DriftEntry>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub workload_status: Option<WireContainerStatus>,
200}
201
202impl CellStatus {
203 pub fn drift_count(&self) -> usize {
206 self.drift.len()
207 }
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub struct ServiceStatus {
213 pub name: String,
214 pub domain: String,
215 pub cells: BTreeMap<String, CellStatus>,
219}
220
221#[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
234pub 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
297pub 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
319pub 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
341const REVISION_KEYS: [&str; 3] = ["image", "version", "tag"];
346
347fn 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
361fn 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
377fn 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 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
412fn 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
424fn 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::MiniflareNative => "miniflare-native",
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 Provider::LocalMailcrab => "local-mailcrab",
438 Provider::LocalS3Fs => "local-s3-fs",
439 }
440 .to_string()
441}
442
443#[derive(Debug, Clone, Serialize, Deserialize)]
450pub struct SyncHistoryEntry {
451 pub id: String,
452 pub service: String,
453 pub env: String,
454 pub status: SyncOutcome,
455 pub started_at: chrono::DateTime<chrono::Utc>,
456 pub completed_at: chrono::DateTime<chrono::Utc>,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
458 pub triggered_by: Option<String>,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
461 pub rev: Option<String>,
462 pub workload_count: u32,
463}
464
465#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
467#[serde(rename_all = "lowercase")]
468pub enum SyncOutcome {
469 Success,
470 Failed,
471 Cancelled,
472}
473
474pub fn new_sync_id() -> String {
476 let mut bytes = [0u8; 8];
477 getrandom::getrandom(&mut bytes).unwrap_or(());
478 hex::encode(bytes)
479}
480
481#[cfg(test)]
484mod tests {
485 use super::*;
486 use crate::config::{ServiceConfig, ServiceWithMirrors};
487
488 fn mirror(src: &str) -> MirrorConfig {
491 toml::from_str(src).expect("mirror toml")
492 }
493
494 fn local_static_mirror() -> MirrorConfig {
495 mirror(
496 "schema_version = 1\nshape = \"local\"\n\n[providers.static]\nkind = \"miniflare-native\"\nport = 4321\n",
497 )
498 }
499
500 fn cloudflare_mirror() -> MirrorConfig {
501 mirror(
502 "schema_version = 1\nshape = \"single-machine\"\n\n[providers.static]\nuse = \"cloudflare\"\nimage = \"caddy:2.8.1\"\n",
503 )
504 }
505
506 fn sim_miniflare_mirror() -> MirrorConfig {
507 mirror(
508 "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",
509 )
510 }
511
512 #[test]
513 fn runtime_process_for_local_static_only() {
514 assert_eq!(
515 Runtime::from_mirror(&local_static_mirror()),
516 Runtime::Process
517 );
518 }
519
520 #[test]
521 fn runtime_containers_for_inline_container_kinds() {
522 assert_eq!(
523 Runtime::from_mirror(&sim_miniflare_mirror()),
524 Runtime::Containers
525 );
526 }
527
528 #[test]
529 fn runtime_containers_for_referenced_provider() {
530 assert_eq!(
531 Runtime::from_mirror(&cloudflare_mirror()),
532 Runtime::Containers
533 );
534 }
535
536 #[test]
537 fn no_observation_is_unknown_missing() {
538 let cell = compute_cell("ha", &cloudflare_mirror(), None);
539 assert_eq!(cell.sync, SyncState::Unknown);
540 assert_eq!(cell.health, HealthState::Missing);
541 assert!(cell.drift.is_empty());
542 assert_eq!(cell.live_revision, None);
543 }
544
545 #[test]
546 fn running_ready_local_is_synced_healthy() {
547 let obs = MirrorObservation {
548 running: true,
549 ready: true,
550 ..Default::default()
551 };
552 let cell = compute_cell("dev", &local_static_mirror(), Some(&obs));
553 assert_eq!(cell.sync, SyncState::Synced);
554 assert_eq!(cell.health, HealthState::Healthy);
555 assert_eq!(cell.runtime, Runtime::Process);
556 }
557
558 #[test]
559 fn declared_but_down_local_is_idle_not_missing() {
560 let obs = MirrorObservation {
563 running: false,
564 ..Default::default()
565 };
566 let cell = compute_cell("sim", &sim_miniflare_mirror(), Some(&obs));
567 assert_eq!(cell.health, HealthState::Idle);
568 assert_eq!(cell.sync, SyncState::Synced);
569 }
570
571 #[test]
572 fn down_continuous_tier_is_missing() {
573 let obs = MirrorObservation {
576 running: false,
577 ..Default::default()
578 };
579 let cell = compute_cell("prod", &cloudflare_mirror(), Some(&obs));
580 assert_eq!(cell.health, HealthState::Missing);
581 }
582
583 #[test]
584 fn running_not_ready_is_progressing() {
585 let obs = MirrorObservation {
586 running: true,
587 ready: false,
588 ..Default::default()
589 };
590 let cell = compute_cell("prod", &cloudflare_mirror(), Some(&obs));
591 assert_eq!(cell.health, HealthState::Progressing);
592 }
593
594 #[test]
595 fn errored_is_degraded() {
596 let obs = MirrorObservation {
597 running: true,
598 ready: true,
599 errored: true,
600 ..Default::default()
601 };
602 let cell = compute_cell("prod", &cloudflare_mirror(), Some(&obs));
603 assert_eq!(cell.health, HealthState::Degraded);
604 }
605
606 #[test]
607 fn diverging_live_revision_is_out_of_sync() {
608 let obs = MirrorObservation {
609 running: true,
610 ready: true,
611 live_revision: Some("caddy:2.7.6".into()),
612 ..Default::default()
613 };
614 let cell = compute_cell("prod", &cloudflare_mirror(), Some(&obs));
615 assert_eq!(cell.declared_revision.as_deref(), Some("caddy:2.8.1"));
616 assert_eq!(cell.live_revision.as_deref(), Some("caddy:2.7.6"));
617 assert_eq!(cell.sync, SyncState::OutOfSync);
618 }
619
620 #[test]
621 fn matching_live_revision_is_synced() {
622 let obs = MirrorObservation {
623 running: true,
624 ready: true,
625 live_revision: Some("caddy:2.8.1".into()),
626 ..Default::default()
627 };
628 let cell = compute_cell("prod", &cloudflare_mirror(), Some(&obs));
629 assert_eq!(cell.sync, SyncState::Synced);
630 }
631
632 #[test]
633 fn unknown_live_revision_does_not_force_out_of_sync() {
634 let obs = MirrorObservation {
637 running: true,
638 ready: true,
639 live_revision: None,
640 ..Default::default()
641 };
642 let cell = compute_cell("prod", &cloudflare_mirror(), Some(&obs));
643 assert_eq!(cell.sync, SyncState::Synced);
644 }
645
646 #[test]
647 fn field_drift_is_detected_and_makes_out_of_sync() {
648 let mut live_fields = BTreeMap::new();
649 let mut static_slot = BTreeMap::new();
650 static_slot.insert("image".to_string(), "caddy:2.7.6".to_string());
651 static_slot.insert("port".to_string(), "8080".to_string()); live_fields.insert("static".to_string(), static_slot);
653
654 let obs = MirrorObservation {
655 running: true,
656 ready: true,
657 live_fields,
658 ..Default::default()
659 };
660 let cell = compute_cell("sim", &sim_miniflare_mirror(), Some(&obs));
661 assert_eq!(cell.sync, SyncState::OutOfSync);
662 assert_eq!(cell.drift_count(), 1, "only the image field drifts");
663 assert_eq!(cell.drift[0].path, "providers.static.image");
664 assert_eq!(cell.drift[0].desired, "caddy:2.8.1");
665 assert_eq!(cell.drift[0].live, "caddy:2.7.6");
666 }
667
668 #[test]
669 fn provider_label_joins_inline_kinds() {
670 assert_eq!(
673 provider_label(&sim_miniflare_mirror()),
674 "minio-container + miniflare-container"
675 );
676 assert_eq!(provider_label(&cloudflare_mirror()), "cloudflare");
677 }
678
679 #[test]
680 fn declared_revision_none_when_no_version_field() {
681 assert_eq!(declared_revision(&local_static_mirror()), None);
682 }
683
684 #[test]
685 fn compute_service_and_summary_roll_up() {
686 let svc = ServiceWithMirrors {
687 service: ServiceConfig {
688 schema_version: 1,
689 name: "yah-dev".into(),
690 domain: "yah.dev".into(),
691 health_path: None,
692 components: vec![],
693 db: crate::DbCatalog::default(),
694 },
695 mirrors: BTreeMap::from([
696 ("dev".to_string(), local_static_mirror()),
697 ("prod".to_string(), cloudflare_mirror()),
698 ]),
699 component_transform_recipes: BTreeMap::new(),
700 passway_machines: BTreeMap::new(),
701 };
702
703 let mut obs = BTreeMap::new();
704 obs.insert(
705 "dev".to_string(),
706 MirrorObservation {
707 running: true,
708 ready: true,
709 ..Default::default()
710 },
711 );
712 let status = compute_service(&svc, &obs);
715 assert_eq!(status.name, "yah-dev");
716 assert_eq!(status.cells.len(), 2);
717 assert_eq!(status.cells["dev"].sync, SyncState::Synced);
718 assert_eq!(status.cells["dev"].health, HealthState::Healthy);
719 assert_eq!(status.cells["prod"].sync, SyncState::Unknown);
720 assert_eq!(status.cells["prod"].health, HealthState::Missing);
721
722 let summary = summarize(&[status]);
723 assert_eq!(summary.synced, 1);
724 assert_eq!(summary.unknown, 1);
725 assert_eq!(summary.healthy, 1);
726 assert_eq!(summary.missing, 1);
727 }
728
729 #[test]
730 fn states_serialize_in_kebab_case_for_the_wire() {
731 assert_eq!(
732 serde_json::to_string(&SyncState::OutOfSync).unwrap(),
733 "\"out-of-sync\""
734 );
735 assert_eq!(
736 serde_json::to_string(&HealthState::Idle).unwrap(),
737 "\"idle\""
738 );
739 assert_eq!(
740 serde_json::to_string(&Runtime::Containers).unwrap(),
741 "\"containers\""
742 );
743 }
744}