src/promote/topology.rs
//! **The ladder as data** — everything a viewer needs, and nothing a viewer must
//! reach into the engine to get.
//!
//! This is the same house pattern as `socket::SocketPlan::topology`: the engine
//! reports facts as a serde-serializable graph; the panel decides how they look.
//! There are **no colours, no human-facing labels, no layout hints** here. There
//! is a computed [`TopologyNode::rank`] because depth is a fact about the DAG, not
//! a rendering decision.
//!
//! The intended viewer is a `facett-pipeline` adapter over this type — a
//! promotion ladder is a DAG with per-node status, which is what that viewer
//! already renders for knut's Spark pipelines and the brokkr build DAG. See
//! `.nornir/promote-design.md` §The viewer contract. **No UI dependency enters
//! this crate**; four headless consumers path-depend on Skíðblaðnir and none of
//! them should link an egui stack to get a slot registry.
//!
//! # The honesty rules, carried over from sockets
//!
//! * **Unknown is a state, not a colour.** [`RungState::Unknown`] is distinct from
//! [`RungState::NeverRun`] and from [`RungState::Failed`], and neither is ever
//! rendered as green because neither can *become* green here.
//! * **Nothing is inferred.** A rung's status comes from an [`Observation`] the
//! engine actually recorded. A rung nobody observed is `NeverRun` — not
//! healthy, not failed, not assumed from a neighbour.
//! * **A non-green rung must say why, machine-readably.** [`RungState::Failed`]
//! and [`RungState::Unknown`] can only be constructed with a `code`
//! ([`Observation::failed`] / [`Observation::unknown`]) — there is no
//! constructor that produces one without a reason, the same way every socket
//! refusal carries its `TO FIX:` clause.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::registry::Demotion;
use super::schema::{Ladder, SlotRole};
/// What the engine knows about a rung right now.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum RungState {
/// No promotion has ever been observed on this rung. The default, and the
/// only state a rung gets for free.
#[default]
NeverRun,
/// The last observed promotion succeeded and was verified.
Green,
/// The last observed promotion failed. Carries a `code`.
Failed,
/// The engine tried to determine the rung's state and could not — the cluster
/// was unreachable, the node it is pinned to was off, the release could not be
/// read. Carries a `code`. **Never** collapsed into `NeverRun` or `Green`:
/// "we could not look" and "we looked and it was fine" are different facts.
Unknown,
}
impl RungState {
pub fn as_str(self) -> &'static str {
match self {
RungState::NeverRun => "never-run",
RungState::Green => "green",
RungState::Failed => "failed",
RungState::Unknown => "unknown",
}
}
/// The one question a gate should ask. Only `Green` is green.
pub fn is_green(self) -> bool {
matches!(self, RungState::Green)
}
/// Does this state require a machine-readable `code`?
pub fn requires_code(self) -> bool {
matches!(self, RungState::Failed | RungState::Unknown)
}
}
/// One recorded observation of a rung.
///
/// Constructed only through the four constructors below, which is what enforces
/// "a failed or unknown rung always carries a machine-readable reason".
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Observation {
pub state: RungState,
/// A stable, machine-readable reason code — `rollout-timeout`,
/// `node-unreachable`, `image-not-in-registry`. Required for
/// [`RungState::Failed`] and [`RungState::Unknown`]; `None` otherwise.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
/// Free text for a human, when there is something worth saying.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
/// When the engine saw this, RFC3339. `None` for [`RungState::NeverRun`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observed_at: Option<String>,
/// What was promoted, if the engine knows — an image ref, a chart version.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revision: Option<String>,
}
impl Default for Observation {
fn default() -> Self {
Observation::never_run()
}
}
impl Observation {
/// Nothing has been observed. No code, because there is no event to explain.
pub fn never_run() -> Self {
Observation {
state: RungState::NeverRun,
code: None,
detail: None,
observed_at: None,
revision: None,
}
}
/// Observed, and it succeeded.
pub fn green(observed_at: &str) -> Self {
Observation {
state: RungState::Green,
code: None,
detail: None,
observed_at: Some(observed_at.to_string()),
revision: None,
}
}
/// Observed, and it failed. `code` is not optional.
pub fn failed(code: &str, detail: &str, observed_at: &str) -> Self {
Observation {
state: RungState::Failed,
code: Some(code.to_string()),
detail: (!detail.is_empty()).then(|| detail.to_string()),
observed_at: Some(observed_at.to_string()),
revision: None,
}
}
/// The engine could not determine the state. `code` is not optional — "we do
/// not know" is only useful with "…because".
pub fn unknown(code: &str, detail: &str, observed_at: &str) -> Self {
Observation {
state: RungState::Unknown,
code: Some(code.to_string()),
detail: (!detail.is_empty()).then(|| detail.to_string()),
observed_at: Some(observed_at.to_string()),
revision: None,
}
}
/// Record what was promoted.
pub fn revision(mut self, rev: &str) -> Self {
self.revision = Some(rev.to_string());
self
}
/// Is this observation self-consistent? A `Failed`/`Unknown` without a code
/// is the exact dishonesty this type exists to prevent; the constructors make
/// it unreachable, and this is the assertion for anything deserialized from
/// the wire.
pub fn is_well_formed(&self) -> bool {
!self.state.requires_code() || self.code.as_deref().is_some_and(|c| !c.trim().is_empty())
}
}
/// Recorded observations, keyed by slot name. Anything absent is
/// [`Observation::never_run`] — absence is never promoted to health.
pub type Observations = BTreeMap<String, Observation>;
/// One rung, as a viewer sees it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TopologyNode {
pub name: String,
pub namespace: String,
pub hosts: Vec<String>,
/// The declared role, canonically spelled.
pub role: String,
/// Whether the prod guard fences this rung. Declared, never derived.
pub guarded: bool,
/// **Computed** topological depth: 0 at an entry point. Not authored.
pub rank: u32,
/// The names this rung promotes to (the same edges as [`Topology::edges`],
/// denormalised so a panel can lay out one node without scanning the list).
pub promotes_to: Vec<String>,
/// The consumer's own facts. Carried; never interpreted by this crate.
pub meta: BTreeMap<String, String>,
/// What the engine actually observed.
pub status: Observation,
}
/// One promotion edge — the direction **code** travels.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TopologyEdge {
pub from: String,
pub to: String,
}
/// The whole ladder, as data.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Topology {
/// The schema version the ladder was written to — a viewer that renders a
/// version it does not know should say so rather than guess.
#[serde(rename = "apiVersion")]
pub api_version: String,
/// Ladder-wide consumer facts, carried untouched.
pub meta: BTreeMap<String, String>,
pub nodes: Vec<TopologyNode>,
pub edges: Vec<TopologyEdge>,
/// Things the panel must be able to show that are not per-node — notably a
/// configuration file that tried to lower the fence and was ignored.
pub notes: Vec<String>,
}
impl Topology {
/// Project a validated ladder + the engine's observations into the view type.
pub fn build(ladder: &Ladder, observations: &Observations, demotions: &[Demotion]) -> Self {
let ranks = ladder.ranks();
let nodes = ladder
.slots
.iter()
.map(|s| TopologyNode {
name: s.name.clone(),
namespace: s.namespace.clone(),
hosts: s.hosts.clone(),
role: s.role.as_str().to_string(),
guarded: s.guarded,
rank: ranks
.get(&s.name.to_ascii_lowercase())
.copied()
.unwrap_or(0),
promotes_to: s.promotes_to.clone(),
meta: s.meta.clone(),
// The lookup that carries the honesty rule: no entry means
// NeverRun, never Green.
status: observations
.get(&s.name)
.cloned()
.unwrap_or_else(Observation::never_run),
})
.collect();
let edges = ladder
.slots
.iter()
.flat_map(|s| {
s.promotes_to.iter().map(|t| TopologyEdge {
from: s.name.clone(),
to: t.clone(),
})
})
.collect();
Topology {
api_version: ladder.api_version.clone(),
meta: ladder.meta.clone(),
nodes,
edges,
notes: demotions.iter().map(Demotion::line).collect(),
}
}
/// The rung by name.
pub fn node(&self, name: &str) -> Option<&TopologyNode> {
self.nodes
.iter()
.find(|n| n.name.eq_ignore_ascii_case(name))
}
/// Rungs that are **not** green — the panel's "what needs attention" list,
/// which deliberately includes the never-run and the unknown.
pub fn not_green(&self) -> Vec<&TopologyNode> {
self.nodes
.iter()
.filter(|n| !n.status.state.is_green())
.collect()
}
/// Every node's observation carries a code where one is required.
pub fn is_well_formed(&self) -> bool {
self.nodes.iter().all(|n| n.status.is_well_formed())
}
}
/// The declared role, for a caller that wants the typed form back out of a
/// [`TopologyNode`].
pub fn role_of(node: &TopologyNode) -> SlotRole {
SlotRole::parse(&node.role)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "promote")]
use crate::promote::registry::{DemotionPolicy, SlotRegistry};
#[cfg(feature = "promote")]
use crate::promote::schema::{EXAMPLE_LADDER, Slot};
/// The whole module's ladder fixture is a YAML parse, which is the ONE thing
/// `promote` actually buys (`schema.rs`'s `from_yaml`). Every test that reaches
/// it is dependency-bound and stays gated; the ones that build their facts
/// directly are not, and run in every default build.
#[cfg(feature = "promote")]
fn ladder() -> Ladder {
Ladder::from_yaml("x", EXAMPLE_LADDER).unwrap()
}
#[test]
#[cfg(feature = "promote")]
fn the_topology_is_the_whole_graph_and_needs_no_engine_internals() {
let t = Topology::build(&ladder(), &Observations::new(), &[]);
assert_eq!(t.nodes.len(), 5);
assert_eq!(
t.edges.len(),
5,
"dev→qa-a, dev→qa-b, qa-a→staging, qa-b→staging, staging→live"
);
let live = t.node("live").unwrap();
assert!(live.guarded);
assert_eq!(live.role, "production");
assert_eq!(live.rank, 3);
assert_eq!(live.hosts, ["example.test", "www.example.test"]);
assert_eq!(
live.meta.get("ticket-queue").map(String::as_str),
Some("OPS")
);
}
#[test]
#[cfg(feature = "promote")]
fn the_topology_round_trips_through_serde() {
let mut obs = Observations::new();
obs.insert(
"staging".into(),
Observation::green("2026-07-27T10:00:00Z").revision("v1.4.2"),
);
obs.insert(
"qa-b".into(),
Observation::failed(
"rollout-timeout",
"3/5 ready after 240s",
"2026-07-27T09:12:00Z",
),
);
let t = Topology::build(&ladder(), &obs, &[]);
let json = serde_json::to_string(&t).unwrap();
let back: Topology = serde_json::from_str(&json).unwrap();
assert_eq!(t, back);
}
/// The rule: absence of an observation is NEVER health.
#[test]
#[cfg(feature = "promote")]
fn an_unobserved_rung_is_never_run_not_green() {
let t = Topology::build(&ladder(), &Observations::new(), &[]);
for n in &t.nodes {
assert_eq!(
n.status.state,
RungState::NeverRun,
"{} must not be green",
n.name
);
assert!(!n.status.state.is_green());
}
assert_eq!(t.not_green().len(), 5);
}
/// "We could not look" and "we looked and it was fine" are different facts.
#[test]
#[cfg(feature = "promote")]
fn unknown_is_its_own_state_and_is_not_green() {
let mut obs = Observations::new();
obs.insert(
"qa-a".into(),
Observation::unknown(
"node-unreachable",
"worker node is powered off",
"2026-07-27T09:00:00Z",
),
);
let t = Topology::build(&ladder(), &obs, &[]);
let n = t.node("qa-a").unwrap();
assert_eq!(n.status.state, RungState::Unknown);
assert!(!n.status.state.is_green());
assert_ne!(n.status.state, RungState::NeverRun);
assert_eq!(n.status.code.as_deref(), Some("node-unreachable"));
}
/// Every non-green state carries a machine-readable code — by construction.
#[test]
fn a_failed_or_unknown_rung_always_carries_a_reason_code() {
assert!(Observation::failed("x", "", "t").is_well_formed());
assert!(Observation::unknown("x", "", "t").is_well_formed());
assert!(Observation::green("t").is_well_formed());
assert!(Observation::never_run().is_well_formed());
// The only way to get a code-less failure is to forge it on the wire —
// and that is exactly what `is_well_formed` is for.
let forged: Observation = serde_json::from_str(r#"{"state":"failed"}"#).unwrap();
assert!(
!forged.is_well_formed(),
"a code-less failure must not pass as well-formed"
);
let t = Topology {
api_version: "x".into(),
meta: Default::default(),
nodes: vec![TopologyNode {
name: "n".into(),
namespace: "n".into(),
hosts: vec![],
role: "test".into(),
guarded: false,
rank: 0,
promotes_to: vec![],
meta: Default::default(),
status: forged,
}],
edges: vec![],
notes: vec![],
};
assert!(!t.is_well_formed());
}
/// A config that tried to lower the fence must be visible in the panel, not
/// only in a terminal that scrolled away.
#[test]
#[cfg(feature = "promote")]
fn a_demotion_attempt_shows_up_in_the_topology_notes() {
use crate::promote::schema::SlotRole;
let baseline = SlotRegistry::from_slots(vec![
Slot::new("prod", "njord", SlotRole::Production, true).host("g11.se"),
])
.unwrap();
let lying = Ladder::from_yaml(
"lying.yaml",
"apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n - {name: prod, namespace: njord, hosts: [g11.se], role: test, guarded: false}\n",
)
.unwrap();
let r =
SlotRegistry::load_over(&baseline, lying, DemotionPolicy::ReportAndKeepFenced).unwrap();
let t = r.topology(&Observations::new());
assert!(t.node("prod").unwrap().guarded, "still fenced in the view");
assert_eq!(t.notes.len(), 1);
assert!(t.notes[0].contains("IGNORED"));
}
#[test]
#[cfg(feature = "promote")]
fn the_role_survives_a_round_trip_as_a_typed_value() {
let t = Topology::build(&ladder(), &Observations::new(), &[]);
assert_eq!(role_of(t.node("live").unwrap()), SlotRole::Production);
assert_eq!(role_of(t.node("staging").unwrap()), SlotRole::PreProduction);
}
}