use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::path::Path; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Topology { pub repo: RepoConfig, pub backup: BackupConfig, #[serde(rename = "tier")] pub tiers: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RepoConfig { pub bare_path: String, pub branch: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BackupConfig { pub source: String, pub local_path: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Tier { pub name: String, #[serde(default)] pub provisioned: bool, pub gates: Vec, #[serde(default)] pub canary: CanaryPolicy, #[serde(default, rename = "node")] pub nodes: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Node { pub name: String, pub ssh_target: String, pub release_root: String, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum CanaryPolicy { #[default] Sequential, Parallel, } impl CanaryPolicy { pub fn as_str(self) -> &'static str { match self { CanaryPolicy::Sequential => "sequential", CanaryPolicy::Parallel => "parallel", } } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum Gate { CargoTest, MigrationDryRun, BootSmoke, BurnIn { hours: u32 }, ManualConfirm, } impl Topology { pub fn load(path: &Path) -> Result { let raw = std::fs::read_to_string(path) .with_context(|| format!("reading topology at {}", path.display()))?; let topo: Topology = toml::from_str(&raw)?; topo.validate()?; Ok(topo) } fn validate(&self) -> Result<()> { anyhow::ensure!(!self.tiers.is_empty(), "topology must declare at least one tier"); for t in &self.tiers { if t.provisioned && t.nodes.is_empty() && t.name != "mm" { anyhow::bail!("tier {} is provisioned but has no nodes", t.name); } } Ok(()) } }