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::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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
459 pub rev: Option<String>,
460 pub workload_count: u32,
461}
462
463#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
465#[serde(rename_all = "lowercase")]
466pub enum SyncOutcome {
467 Success,
468 Failed,
469 Cancelled,
470}
471
472pub 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#[cfg(test)]
482mod tests {
483 use super::*;
484 use crate::config::{ServiceConfig, ServiceWithMirrors};
485
486 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 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 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 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()); 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 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 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}