Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/Skidbladnir

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);
    }
}