use crate::config::AppConfig; use crate::domain::{AppId, NodeId}; use crate::events::EventTx; use crate::topology::{Node, Topology}; use ops_exec::{CapabilitySet, Executor, LocalExec, SshExec}; use sqlx::SqlitePool; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; use tokio::task::AbortHandle; /// Per-node executors keyed by node id, built once from the topology at /// startup. The deploy path looks a node's executor up here instead of /// constructing ssh/rsync invocations inline. pub type ExecutorMap = HashMap>; /// The in-flight build pipeline: its abort handle plus the `build_runs` row it /// drives. A newer `/rebuild` aborts the handle and settles the row `aborted`. pub struct ActiveBuild { pub handle: AbortHandle, pub run_id: crate::domain::RunId, } /// One product, fully resolved: its pipeline config, its tiers and nodes, and /// the executors for those nodes. /// /// Everything a handler needs to act on a product, reachable from its /// [`AppId`] alone. Bundled rather than left as three parallel maps because the /// three are only ever correct together — an executor map built from another /// product's topology would address the wrong machines. pub struct App { pub cfg: Arc, pub topo: Arc, pub executors: Arc, } /// Every product this daemon ships, by id. pub type AppMap = std::collections::BTreeMap>; #[derive(Clone)] pub struct AppState { pub pool: SqlitePool, /// Every configured product. The authority; the three fields below are one /// product's view of it. pub apps: Arc, /// The product a request that names none is about, and the only one a /// pre-multi-app config declares. pub default_app: AppId, pub topo: Arc, pub cfg: Arc, /// Single-slot guard for the build pipeline. A new /rebuild aborts any /// in-flight build (cargo + gates) so the latest push always wins. Carries /// the run id alongside the handle so the aborting `/rebuild` can settle /// the superseded `build_runs` row as `aborted`. pub active_build: Arc>>, /// Serializes the deploy mutators (`/promote`, `/rollback`) so their /// check -> deploy -> advance sequences never interleave. Without it two /// concurrent promotes (or a promote racing a rollback) could deploy mixed /// versions to a node and clobber each other's `tier_state` advance /// (CF3). Held for the whole handler. pub deploy_lock: Arc>, /// Broadcast bus for live operator events. WS /events subscribes; all /// build/gate/deploy code sites emit on this. pub events: EventTx, /// One capability-scoped [`Executor`] per node, built from the topology. pub executors: Arc, /// Bearer token required on the deploy-mutating routes (promote, rollback, /// rebuild, confirm, backup/fetch). Sourced from `SANDO_API_TOKEN` (the /// systemd EnvironmentFile). `None` = unauthenticated, which main() permits /// only on a loopback bind (CF2). pub api_token: Option>, } /// The `apps` map and default id for a daemon shipping exactly one product. /// /// The common case, and the only one a config written before 2026-08-06 /// describes. Kept as a constructor so the single-product path builds the same /// map the multi-product path does, rather than a special case that can drift /// from it. pub fn one_app( cfg: Arc, topo: Arc, executors: Arc, ) -> (Arc, AppId) { let id = cfg.id.clone(); let mut apps = AppMap::new(); apps.insert( id.clone(), Arc::new(App { cfg, topo, executors, }), ); (Arc::new(apps), id) } impl AppState { /// The product `id` names, or `None` if this daemon does not ship it. pub fn app(&self, id: &AppId) -> Option<&Arc> { self.apps.get(id) } /// Every product id, in declared order. Stable, because it keys the /// operator-facing surface. pub fn app_ids(&self) -> Vec { self.apps.keys().cloned().collect() } } /// Build the executor for one node: a `LocalExec` for the `local` fast-path, an /// `SshExec` otherwise, each granted the node's declared capabilities (which /// default to deploy+restart / observe health — the historical behavior). pub fn build_executor(node: &Node) -> Arc { let caps = CapabilitySet::from_tokens(&node.actuate, &node.observe); if node.ssh_target == "local" || node.ssh_target.is_empty() { Arc::new(LocalExec::new(caps)) } else { Arc::new(SshExec::new(node.ssh_target.clone(), caps)) } } /// Build the full node → executor map from every tier's nodes. pub fn build_executors(topo: &Topology) -> ExecutorMap { topo.tiers .iter() .flat_map(|t| t.nodes.iter()) .map(|node| (node.name.clone(), build_executor(node))) .collect() }