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