use crate::domain::{GateKind, NodeId, TierId}; 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: TierId, #[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: NodeId, pub ssh_target: String, pub release_root: String, /// systemd unit name to reload-or-restart after the symlink swap. /// Defaults to "makenotwork.service" because that's MNW's prod unit. #[serde(default = "default_service_name")] pub service_name: String, /// Capability grant for this node's executor (see `ops_exec`). Defaults to /// the current behavior of every Sando node — actuate deploy+restart, /// observe health — so an existing `sando.toml` keeps working unedited. #[serde(default = "default_actuate")] pub actuate: Vec, #[serde(default = "default_observe")] pub observe: Vec, } fn default_service_name() -> String { "makenotwork.service".into() } /// The capability set every pre-existing Sando node implicitly had: it deploys /// and restarts, and is health-observed. Keeping these as the defaults is what /// lets `sando.toml` stay unchanged through the executor refactor. pub fn default_actuate() -> Vec { vec!["deploy".into(), "restart".into()] } pub fn default_observe() -> Vec { vec!["health".into()] } #[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 Gate { /// The discriminant — the identifier we use in events, schema columns, /// and the TUI. Gate parameters (e.g. `BurnIn.hours`) stay with `Gate` /// and are not carried into `gate_runs` history. pub fn kind(&self) -> GateKind { match self { Gate::CargoTest => GateKind::CargoTest, Gate::MigrationDryRun => GateKind::MigrationDryRun, Gate::BootSmoke => GateKind::BootSmoke, Gate::BurnIn { .. } => GateKind::BurnIn, Gate::ManualConfirm => GateKind::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.as_str() != "host" { anyhow::bail!("tier {} is provisioned but has no nodes", t.name); } } Ok(()) } }