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 address: String,
223 pub cells: BTreeMap<String, CellStatus>,
227}
228
229#[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
242pub 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
305pub 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
327pub 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
349const REVISION_KEYS: [&str; 3] = ["image", "version", "tag"];
354
355fn 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
369fn 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
385fn 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 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
420fn 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
432fn 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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub rev: Option<String>,
470 pub workload_count: u32,
471}
472
473#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
475#[serde(rename_all = "lowercase")]
476pub enum SyncOutcome {
477 Success,
478 Failed,
479 Cancelled,
480}
481
482pub 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#[cfg(test)]
492mod tests {
493 use super::*;
494 use crate::config::{ServiceConfig, ServiceWithMirrors};
495
496 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 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 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 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()); 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 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 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}