src/promote/schema.rs
//! **The environment-ladder schema** — Skíðblaðnir's public, versioned format for
//! describing *someone's* sites and the order code is promoted through them.
//!
//! This is a **contract, not an internal type**. Any consumer authors one file in
//! this schema and the promoter reads it; Njord's
//! `Njord-deploys/env/<customer>/<slot>/slot.yaml` is one *instance* of it, not a
//! format Njord invented. A third party writes their own — `dev → qa → staging →
//! prod`, or two parallel test rungs that fan in before production — without
//! reading a line of this crate's source. The schema is documented in
//! `.nornir/promote-design.md` §The ladder schema, and a reference instance ships
//! as [`EXAMPLE_LADDER`].
//!
//! # Four rules the format is built around
//!
//! **1. The ladder is data.** No rung name appears in any type, variant or match
//! arm in this crate. `lab`, `test1`, `prod` are strings in somebody's file.
//!
//! **2. Roles are declared, never inferred.** Nothing here concludes "this is
//! production because it is called `prod`", and nothing concludes "this is safe
//! because it is a subdomain". Both are real holes in the tool this generalises.
//! [`Slot::role`] and [`Slot::guarded`] are **required fields** — a slot that does
//! not declare them is a parse error, not a test slot. Fail closed.
//!
//! **3. The version is stated and checked.** [`API_VERSION`] is required, and an
//! unrecognised value is a loud refusal, never a best-effort parse. A silently
//! misparsed environment descriptor is precisely the failure this module exists
//! to prevent.
//!
//! **4. Order is expressed as a DAG, not a number.** [`Slot::promotes_to`] names
//! the rungs code may move to next. Edges were chosen over a `rank` integer
//! because a rank cannot express two parallel test rungs that both feed one
//! pre-production, nor a fan-in that requires several rungs before production —
//! and both exist in the wild. A rank is still *available*: [`Ladder::rank`]
//! **computes** topological depth for a viewer that wants a linear layout, so the
//! file never carries redundant authored state that can drift from the edges.
//!
//! # The two directions
//!
//! Edges point the way **code** travels: up the ladder, toward production. **Data
//! travels the other way** — a copy of production's data into a test rung is
//! normal; the reverse replaces production with somebody's test rows. Both
//! questions are answered from the same edges:
//! [`Ladder::check_promotion`] and [`Ladder::check_data_flow`].
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
/// The one schema version this build understands. Present in every file; an
/// unknown value is refused rather than guessed at.
pub const API_VERSION: &str = "skidbladnir.nordisk/promote.v1";
/// A complete, valid reference instance — a fictional consumer, deliberately not
/// anyone's real hosts. Parsed by the conformance test, and quotable in a guide.
pub const EXAMPLE_LADDER: &str = r#"apiVersion: skidbladnir.nordisk/promote.v1
# One consumer's environment ladder. Code is promoted along `promotes_to`;
# data may only be copied the other way.
meta:
customer: Example Corp
owner: platform-team@example.com
slots:
- name: dev
namespace: example-dev
hosts: [dev.example.test]
role: development
guarded: false
promotes_to: [qa-a, qa-b]
meta:
note: two parallel QA rungs fan in before staging
- name: qa-a
namespace: example-qa-a
hosts: [qa-a.example.test]
role: test
guarded: false
promotes_to: [staging]
- name: qa-b
namespace: example-qa-b
hosts: [qa-b.example.test]
role: test
guarded: false
promotes_to: [staging]
- name: staging
namespace: example-staging
hosts: [staging.example.test]
role: pre-production
guarded: false
promotes_to: [live]
- name: live
namespace: example-live
hosts: [example.test, www.example.test]
role: production
guarded: true
promotes_to: []
meta:
ticket-queue: OPS
"#;
/// What a slot is *for*, as its owner declares it.
///
/// A closed vocabulary with an open escape hatch. Common spellings are folded to
/// the canonical value ([`SlotRole::parse`]) so `prod`, `production` and `live`
/// all mean the same thing in the **role field** — that is reading a declared
/// field tolerantly, which is not the same as inferring a role from a slot's
/// *name*. The latter never happens anywhere in this crate.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum SlotRole {
/// The real thing. Must be [`Slot::guarded`]; see [`Ladder::validate`].
Production,
/// The rung immediately below production — often the one that actually
/// carries live traffic for some function.
PreProduction,
Staging,
Test,
Development,
/// A role this vocabulary does not name. Perfectly legal; it simply carries no
/// extra meaning to the ship, and — like every other role — must still declare
/// [`Slot::guarded`] explicitly.
Custom(String),
}
impl SlotRole {
/// The canonical spelling.
pub fn as_str(&self) -> &str {
match self {
SlotRole::Production => "production",
SlotRole::PreProduction => "pre-production",
SlotRole::Staging => "staging",
SlotRole::Test => "test",
SlotRole::Development => "development",
SlotRole::Custom(s) => s,
}
}
/// Fold a declared spelling onto the vocabulary. Unknown strings become
/// [`SlotRole::Custom`] verbatim.
pub fn parse(s: &str) -> SlotRole {
match s
.trim()
.to_ascii_lowercase()
.replace(['_', ' '], "-")
.as_str()
{
"production" | "prod" | "prd" | "produktion" | "live" | "prd-live" => {
SlotRole::Production
}
"pre-production" | "preproduction" | "preprod" | "pre-prod" | "uat" => {
SlotRole::PreProduction
}
"staging" | "stage" | "stg" => SlotRole::Staging,
"test" | "qa" | "qual" | "testing" => SlotRole::Test,
"development" | "dev" | "sandbox" => SlotRole::Development,
other => SlotRole::Custom(other.to_string()),
}
}
/// Is this the role that must always be fenced?
pub fn is_production(&self) -> bool {
matches!(self, SlotRole::Production)
}
}
impl Serialize for SlotRole {
fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for SlotRole {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
let s = String::deserialize(d)?;
if s.trim().is_empty() {
return Err(serde::de::Error::custom("`role` must not be empty"));
}
Ok(SlotRole::parse(&s))
}
}
/// One slot on the ladder — one site, one environment.
///
/// Note what has **no `#[serde(default)]`**: `role` and `guarded`. Omitting either
/// is a parse error naming the field, because "unspecified" must never quietly
/// become "safe".
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Slot {
/// The name humans, CLIs and edges use. Unique within a ladder.
pub name: String,
/// The Kubernetes namespace this slot's workloads live in. Unique within a
/// ladder.
pub namespace: String,
/// Every host this slot answers on. **Authoritative**: a host that is not
/// listed belongs to no slot, and no host is derived from another by prefix
/// or suffix rules.
///
/// An entry may be a **declared zone** — `*.example.test` — meaning "this slot
/// owns every subdomain of `example.test` that no other slot has claimed by
/// name". That is still a declaration, not an inference: the operator states
/// the ownership and [`Slot::matches_zone`] honours the statement. It exists so
/// a production apex cannot have its own data plane (`db.example.test`) read as
/// *not* production merely because nobody remembered to list it. An exact entry
/// on any slot always outranks another slot's zone — see
/// [`SlotRegistry::resolve`](super::registry::SlotRegistry::resolve).
///
/// A zone entry never matches the apex itself; declare the apex too if the slot
/// answers on it.
#[serde(default)]
pub hosts: Vec<String>,
/// What this slot is for. **Required.**
pub role: SlotRole,
/// Whether the prod guard fences this slot. **Required, and never derived
/// from the name.** [`SlotRole::Production`] must set it `true`.
pub guarded: bool,
/// The rungs code may be promoted to from here. Edges point *up* the ladder.
#[serde(default)]
pub promotes_to: Vec<String>,
/// Whatever the consumer needs carried alongside — customer, owner, ticket
/// queue, node pool. **The ship stores and round-trips this and never branches
/// on it.** Nothing in this crate reads a key out of `meta`.
#[serde(default)]
pub meta: BTreeMap<String, String>,
}
impl Slot {
/// A slot built in code — the **baseline** a caller declares before layering
/// configuration over it. `guarded` is explicit here for the same reason it is
/// required in the file.
pub fn new(name: &str, namespace: &str, role: SlotRole, guarded: bool) -> Self {
Slot {
name: name.to_string(),
namespace: namespace.to_string(),
hosts: Vec::new(),
role,
guarded,
promotes_to: Vec::new(),
meta: BTreeMap::new(),
}
}
/// Add a host this slot answers on.
pub fn host(mut self, host: &str) -> Self {
self.hosts.push(host.to_string());
self
}
/// Add an outgoing promotion edge.
pub fn promotes_to(mut self, next: &str) -> Self {
self.promotes_to.push(next.to_string());
self
}
/// Attach a consumer fact the ship will carry and never interpret.
pub fn meta(mut self, k: &str, v: &str) -> Self {
self.meta.insert(k.to_string(), v.to_string());
self
}
/// Is this slot behind the fence?
///
/// Reads the declared field. There is no code path in this crate that answers
/// this question from a name, a host suffix, or anything else.
pub fn is_guarded(&self) -> bool {
self.guarded
}
/// Does `needle` name this slot, by **any** of its identities?
///
/// Name, namespace, any declared host, or a generation namespace derived from
/// the name or the namespace (`test1-20260721-1432`). Matching is exact per
/// identity — never a substring — so `test10` does not match `test1`.
///
/// One deliberate asymmetry: for a **guarded** slot a leading `www.` is folded
/// away, so `www.example.test` matches a slot that only listed
/// `example.test`. This is not an inference about safety — it can only ever
/// cause the fence to refuse *more*, never less, and a fence that
/// over-matches costs an operator one extra flag while one that under-matches
/// costs a production database. Hosts remain authoritative in the other
/// direction: nothing is ever concluded to be *safe* from its shape.
pub fn matches(&self, needle: &str) -> bool {
let n = needle.trim().to_ascii_lowercase();
if n.is_empty() {
return false;
}
if eq(&self.name, &n) || eq(&self.namespace, &n) {
return true;
}
if super::generation::Generation::is_of(&self.name, &n)
|| super::generation::Generation::is_of(&self.namespace, &n)
{
return true;
}
let bare = n.strip_prefix("www.").unwrap_or(&n);
self.exact_hosts()
.any(|h| eq(h, &n) || (self.guarded && eq(h, bare)))
}
/// The declared zones this slot owns — the `*.example.test` entries in
/// [`Slot::hosts`], with the `*.` stripped (so `example.test`).
pub fn zones(&self) -> impl Iterator<Item = &str> {
self.hosts.iter().filter_map(|h| zone_suffix(h))
}
/// The plain, exact host entries — [`Slot::hosts`] minus the zone wildcards.
/// A wildcard is never treated as a literal hostname.
pub fn exact_hosts(&self) -> impl Iterator<Item = &str> {
self.hosts
.iter()
.filter(|h| zone_suffix(h).is_none())
.map(|h| h.as_str())
}
/// Does `needle` fall inside a zone this slot **declared** it owns?
///
/// True for a strict subdomain only: a slot declaring `*.example.test` matches
/// `db.example.test` and `a.b.example.test`, but **not** `example.test` itself
/// (the apex is a separate declaration) and not `notexample.test`.
///
/// This is deliberately *not* consulted by [`Slot::matches`]. Zone ownership is
/// the weaker claim, and [`SlotRegistry::resolve`](super::registry::SlotRegistry::resolve)
/// only reaches for it after every slot's exact identities have missed — so a
/// sibling that named `lab.example.test` outright keeps it.
pub fn matches_zone(&self, needle: &str) -> bool {
let n = needle.trim().to_ascii_lowercase();
if n.is_empty() {
return false;
}
self.zones().any(|z| {
// Strict subdomain: `.{zone}` must be a real suffix, and something must
// precede it. `eq` on the remainder is not enough — compare case-folded.
n.len() > z.len() + 1
&& n.as_bytes()[n.len() - z.len() - 1] == b'.'
&& eq(&n[n.len() - z.len()..], z)
})
}
}
/// `*.example.test` → `Some("example.test")`; anything else → `None`.
///
/// The ONE reader of the wildcard form — [`Slot::zones`], [`Slot::exact_hosts`] and
/// [`Ladder::validate`] all route through it, so what counts as a zone entry is
/// decided in exactly one place.
pub(crate) fn zone_suffix(host: &str) -> Option<&str> {
let z = host.trim().strip_prefix("*.")?;
(!z.is_empty() && !z.contains('*')).then_some(z)
}
pub(crate) fn eq(a: &str, b: &str) -> bool {
a.eq_ignore_ascii_case(b)
}
/// A whole ladder: the schema version, the slots, and the consumer's own facts.
///
/// Construct it from a file ([`Ladder::from_file`]) or in code
/// ([`Ladder::from_slots`]); either way it is **validated** before it exists, so
/// holding a `Ladder` means the DAG is acyclic, every edge resolves, and no
/// slot's guarded status is ambiguous.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Ladder {
/// Must equal [`API_VERSION`].
#[serde(rename = "apiVersion")]
pub api_version: String,
/// Ladder-wide consumer facts. Carried, never interpreted.
#[serde(default)]
pub meta: BTreeMap<String, String>,
pub slots: Vec<Slot>,
}
impl Ladder {
/// Build and validate a ladder in code.
pub fn from_slots(slots: Vec<Slot>) -> Result<Self> {
let l = Ladder {
api_version: API_VERSION.to_string(),
meta: BTreeMap::new(),
slots,
};
l.validate("<code>")?;
Ok(l)
}
/// Parse + validate YAML. `source` names the file in every error message.
#[cfg(feature = "promote")]
pub fn from_yaml(source: &str, text: &str) -> Result<Self> {
let l: Ladder = serde_yaml::from_str(text)
.map_err(|e| anyhow::anyhow!("{source}: not a valid ladder document: {e}"))?;
l.validate(source)?;
Ok(l)
}
/// Parse + validate JSON.
pub fn from_json(source: &str, text: &str) -> Result<Self> {
let l: Ladder = serde_json::from_str(text)
.map_err(|e| anyhow::anyhow!("{source}: not a valid ladder document: {e}"))?;
l.validate(source)?;
Ok(l)
}
/// Read a ladder file. `.json` parses as JSON, anything else as YAML (which
/// also accepts JSON). The path is the `source` in every error.
#[cfg(feature = "promote")]
pub fn from_file(path: &std::path::Path) -> Result<Self> {
let source = path.display().to_string();
let text = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("{source}: cannot read ladder file: {e}"))?;
if path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("json"))
{
Self::from_json(&source, &text)
} else {
Self::from_yaml(&source, &text)
}
}
/// Every check the format promises, each naming `source` and the offending
/// field. Called by every constructor; there is no way to hold an invalid
/// `Ladder`.
pub fn validate(&self, source: &str) -> Result<()> {
if self.api_version.trim() != API_VERSION {
bail!(
"{source}: apiVersion '{}' is not supported by this build.\n \
Supported: '{API_VERSION}'.\n \
A ladder written for another version is NOT parsed best-effort — a \
misread environment descriptor is how the wrong namespace gets wiped. \
Upgrade the tool, or write the file to the supported version.",
self.api_version
);
}
if self.slots.is_empty() {
bail!("{source}: `slots` is empty — a ladder with no rungs describes nothing");
}
let mut names: BTreeSet<String> = BTreeSet::new();
let mut namespaces: BTreeMap<String, String> = BTreeMap::new();
let mut hosts: BTreeMap<String, String> = BTreeMap::new();
for s in &self.slots {
if s.name.trim().is_empty() {
bail!("{source}: a slot has an empty `name`");
}
if s.namespace.trim().is_empty() {
bail!("{source}: slot '{}' has an empty `namespace`", s.name);
}
if !names.insert(s.name.to_ascii_lowercase()) {
bail!("{source}: slot '{}' is declared twice", s.name);
}
if let Some(other) = namespaces.insert(s.namespace.to_ascii_lowercase(), s.name.clone())
{
bail!(
"{source}: slots '{other}' and '{}' both claim namespace '{}' — \
a namespace must identify exactly one slot",
s.name,
s.namespace
);
}
for h in &s.hosts {
if h.trim().is_empty() {
bail!("{source}: slot '{}' has an empty entry in `hosts`", s.name);
}
// A `*` in a host entry is only meaningful as the zone form
// `*.example.test`. Anything else (`*`, `*.`, `*g11.se`,
// `a.*.test`) is a typo that would otherwise silently become a
// literal hostname no request can ever match — i.e. a fence the
// author believes exists and does not.
if h.contains('*') && zone_suffix(h).is_none() {
bail!(
"{source}: slot '{}' has a malformed host entry '{h}' — a wildcard \
must be written `*.<zone>` (e.g. `*.example.test`) and the zone \
may not itself contain `*`",
s.name
);
}
if let Some(other) = hosts.insert(h.to_ascii_lowercase(), s.name.clone()) {
bail!(
"{source}: slots '{other}' and '{}' both claim host '{h}' — \
a host must identify exactly one slot",
s.name
);
}
}
// The one rule that ties the two required fields together: a slot may
// be guarded under any role, but the production role is never open.
if s.role.is_production() && !s.guarded {
bail!(
"{source}: slot '{}' declares `role: production` with `guarded: false`.\n \
That is ambiguous, and the ambiguity is the whole failure mode this \
schema exists to prevent. Either it is production — then set \
`guarded: true` — or its role is something else.",
s.name
);
}
}
// Edges: every reference must resolve, and no slot may promote to itself.
for s in &self.slots {
for next in &s.promotes_to {
if eq(next, &s.name) {
bail!(
"{source}: slot '{}' has `promotes_to` pointing at itself",
s.name
);
}
if !names.contains(&next.to_ascii_lowercase()) {
bail!(
"{source}: slot '{}' promotes to '{next}', which is not a declared slot.\n \
Declared slots: {}",
s.name,
self.slots
.iter()
.map(|x| x.name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
}
}
if let Some(cycle) = self.find_cycle() {
bail!(
"{source}: the promotion ladder has a cycle: {}.\n \
Code promotes in one direction; a cycle means there is no 'next rung' \
and no 'last rung', so no hop can be checked.",
cycle.join(" → ")
);
}
Ok(())
}
/// The slot named `name`, if any (case-insensitive).
pub fn slot(&self, name: &str) -> Option<&Slot> {
self.slots.iter().find(|s| eq(&s.name, name))
}
/// Every guarded slot.
pub fn guarded(&self) -> Vec<&Slot> {
self.slots.iter().filter(|s| s.guarded).collect()
}
/// Rungs with no incoming edge — where a change enters the ladder.
pub fn entry_points(&self) -> Vec<&Slot> {
let targets: BTreeSet<String> = self
.slots
.iter()
.flat_map(|s| s.promotes_to.iter())
.map(|n| n.to_ascii_lowercase())
.collect();
self.slots
.iter()
.filter(|s| !targets.contains(&s.name.to_ascii_lowercase()))
.collect()
}
/// Rungs with no outgoing edge — where the ladder ends.
pub fn terminals(&self) -> Vec<&Slot> {
self.slots
.iter()
.filter(|s| s.promotes_to.is_empty())
.collect()
}
/// **Computed** topological depth: 0 for an entry point, otherwise one more
/// than the deepest predecessor. Never authored — a rank in the file could
/// drift from the edges, and then two things would disagree about the order.
pub fn rank(&self, name: &str) -> Option<u32> {
self.ranks().get(&name.to_ascii_lowercase()).copied()
}
/// Every rank, keyed by lowercased slot name.
pub fn ranks(&self) -> BTreeMap<String, u32> {
let mut rank: BTreeMap<String, u32> = BTreeMap::new();
for s in &self.slots {
rank.insert(s.name.to_ascii_lowercase(), 0);
}
// The ladder is acyclic (validated), so relaxing |V| times settles the
// longest path. |V| is a handful of slots; clarity beats Kahn here.
for _ in 0..self.slots.len() {
let mut changed = false;
for s in &self.slots {
let here = rank[&s.name.to_ascii_lowercase()];
for next in &s.promotes_to {
let k = next.to_ascii_lowercase();
if let Some(r) = rank.get_mut(&k) {
if *r < here + 1 {
*r = here + 1;
changed = true;
}
}
}
}
if !changed {
break;
}
}
rank
}
fn find_cycle(&self) -> Option<Vec<String>> {
// Kahn: whatever survives with a non-zero in-degree sits on a cycle.
let mut indeg: BTreeMap<String, usize> = self
.slots
.iter()
.map(|s| (s.name.to_ascii_lowercase(), 0))
.collect();
for s in &self.slots {
for n in &s.promotes_to {
if let Some(d) = indeg.get_mut(&n.to_ascii_lowercase()) {
*d += 1;
}
}
}
let mut q: VecDeque<String> = indeg
.iter()
.filter(|(_, d)| **d == 0)
.map(|(k, _)| k.clone())
.collect();
let mut removed = 0usize;
while let Some(k) = q.pop_front() {
removed += 1;
if let Some(s) = self.slot(&k) {
for n in &s.promotes_to {
let nk = n.to_ascii_lowercase();
if let Some(d) = indeg.get_mut(&nk) {
*d -= 1;
if *d == 0 {
q.push_back(nk);
}
}
}
}
}
if removed == self.slots.len() {
return None;
}
let mut stuck: Vec<String> = indeg
.iter()
.filter(|(_, d)| **d > 0)
.map(|(k, _)| k.clone())
.collect();
stuck.sort();
Some(stuck)
}
/// The shortest promotion path from `from` to `to`, inclusive of both.
/// `None` when code cannot travel that way at all.
pub fn path(&self, from: &str, to: &str) -> Option<Vec<String>> {
let (from, to) = (self.slot(from)?, self.slot(to)?);
if eq(&from.name, &to.name) {
return Some(vec![from.name.clone()]);
}
let mut prev: BTreeMap<String, String> = BTreeMap::new();
let mut seen: BTreeSet<String> = BTreeSet::from([from.name.to_ascii_lowercase()]);
let mut q = VecDeque::from([from.name.clone()]);
while let Some(cur) = q.pop_front() {
let Some(s) = self.slot(&cur) else { continue };
for next in &s.promotes_to {
let k = next.to_ascii_lowercase();
if !seen.insert(k.clone()) {
continue;
}
prev.insert(k.clone(), cur.clone());
if eq(next, &to.name) {
let mut path = vec![to.name.clone()];
let mut at = k;
while let Some(p) = prev.get(&at) {
path.push(p.clone());
at = p.to_ascii_lowercase();
}
path.reverse();
return Some(path);
}
q.push_back(next.clone());
}
}
None
}
/// **Is this a legal next hop for code?** The question the schema exists to
/// answer — see [`Hop`].
pub fn check_promotion(&self, from: &str, to: &str) -> Hop {
if self.slot(from).is_none() {
return Hop::UnknownSlot(from.to_string());
}
if self.slot(to).is_none() {
return Hop::UnknownSlot(to.to_string());
}
if eq(from, to) {
return Hop::SameSlot;
}
match self.path(from, to) {
Some(p) if p.len() == 2 => Hop::Legal,
Some(p) => Hop::SkipsRungs(p[1..p.len() - 1].to_vec()),
None => match self.path(to, from) {
Some(_) => Hop::WrongDirection,
None => Hop::NotOnLadder,
},
}
}
/// **Is this a legal data copy?** Data travels *down* the ladder: a copy is
/// legal exactly when `to` promotes (transitively) to `from`. The reverse is
/// how production gets replaced by somebody's test rows.
pub fn check_data_flow(&self, from: &str, to: &str) -> Hop {
if self.slot(from).is_none() {
return Hop::UnknownSlot(from.to_string());
}
if self.slot(to).is_none() {
return Hop::UnknownSlot(to.to_string());
}
if eq(from, to) {
return Hop::SameSlot;
}
match self.path(to, from) {
Some(p) if p.len() == 2 => Hop::Legal,
// Copying prod → test2 skips no rung in any meaningful sense; data is
// not staged rung by rung. Any downward path is simply legal.
Some(_) => Hop::Legal,
None => match self.path(from, to) {
Some(_) => Hop::WrongDirection,
None => Hop::NotOnLadder,
},
}
}
}
/// The verdict on one hop, in either direction.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Hop {
/// Exactly one edge. Go.
Legal,
/// There is a path, but it jumps over these rungs. Refusable, or allowed with
/// an explicit override that says which rungs were skipped —
/// see [`Hop::require`].
SkipsRungs(Vec<String>),
/// The edge exists the other way round. For code that means promoting
/// downward; for data it means copying upward.
WrongDirection,
/// The two rungs are not connected at all.
NotOnLadder,
/// Source and destination are the same rung.
SameSlot,
/// One of the two names is not on this ladder.
UnknownSlot(String),
}
impl Hop {
pub fn is_legal(&self) -> bool {
matches!(self, Hop::Legal)
}
/// Turn the verdict into a `Result`. `allow_skip` is the explicit override for
/// [`Hop::SkipsRungs`] — and even when it is granted the skipped rungs come
/// back in `Ok(Vec<String>)` so the caller reports them rather than swallowing
/// them. Nothing else is overridable here.
pub fn require(&self, from: &str, to: &str, allow_skip: bool) -> Result<Vec<String>> {
match self {
Hop::Legal => Ok(Vec::new()),
Hop::SkipsRungs(rungs) if allow_skip => Ok(rungs.clone()),
Hop::SkipsRungs(rungs) => bail!(
"refusing to go '{from}' → '{to}': it skips {}.\n \
The ladder exists so a change is seen on every rung before the next. \
Promote through {} first, or say explicitly that you are skipping them.",
rungs.join(", "),
rungs.join(" then ")
),
Hop::WrongDirection => bail!(
"refusing '{from}' → '{to}': that is the wrong way along the ladder.\n \
Code is promoted upward and data is copied downward; this move is neither."
),
Hop::NotOnLadder => bail!(
"refusing '{from}' → '{to}': the ladder has no path between them.\n \
Add the edge to the ladder file if the move is meant to be possible."
),
Hop::SameSlot => bail!("'{from}' and '{to}' are the same rung — nothing to do"),
Hop::UnknownSlot(n) => {
bail!("refusing '{from}' → '{to}': '{n}' is not a slot on this ladder")
}
}
}
}
#[cfg(feature = "promote")]
#[cfg(test)]
mod tests {
use super::*;
// ── the reference instance is the conformance proof ──────────────────────
#[test]
fn the_shipped_example_parses_and_describes_what_it_claims() {
let l = Ladder::from_yaml("EXAMPLE_LADDER", EXAMPLE_LADDER).expect("example must parse");
assert_eq!(l.slots.len(), 5);
assert_eq!(
l.meta.get("customer").map(String::as_str),
Some("Example Corp")
);
let live = l.slot("live").unwrap();
assert!(live.is_guarded());
assert_eq!(live.role, SlotRole::Production);
assert_eq!(
live.meta.get("ticket-queue").map(String::as_str),
Some("OPS")
);
// Two parallel QA rungs fan in — the shape a `rank` integer cannot express.
assert_eq!(l.rank("dev"), Some(0));
assert_eq!(l.rank("qa-a"), Some(1));
assert_eq!(l.rank("qa-b"), Some(1));
assert_eq!(l.rank("staging"), Some(2));
assert_eq!(l.rank("live"), Some(3));
assert_eq!(
l.entry_points()
.iter()
.map(|s| s.name.as_str())
.collect::<Vec<_>>(),
["dev"]
);
assert_eq!(
l.terminals()
.iter()
.map(|s| s.name.as_str())
.collect::<Vec<_>>(),
["live"]
);
}
#[test]
fn the_example_round_trips_through_serde() {
let l = Ladder::from_yaml("x", EXAMPLE_LADDER).unwrap();
let json = serde_json::to_string(&l).unwrap();
let back = Ladder::from_json("round-trip", &json).unwrap();
assert_eq!(l, back);
}
// ── the ladder is data: nothing here knows a rung's name ─────────────────
#[test]
fn a_completely_different_ladder_works_just_as_well() {
let l = Ladder::from_yaml(
"other",
"apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n\
- {name: sandbox, namespace: sb, role: development, guarded: false, promotes_to: [main]}\n\
- {name: main, namespace: main, role: production, guarded: true}\n",
)
.unwrap();
assert!(l.check_promotion("sandbox", "main").is_legal());
assert!(l.slot("main").unwrap().is_guarded());
}
// ── fail closed on an undeclared role / guard ────────────────────────────
#[test]
fn a_slot_that_does_not_declare_its_role_is_an_error_not_a_test_slot() {
let err = Ladder::from_yaml(
"bad.yaml",
"apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n - {name: x, namespace: x, guarded: false}\n",
)
.expect_err("missing role must not default");
assert!(format!("{err:#}").contains("role"), "{err:#}");
assert!(
format!("{err:#}").contains("bad.yaml"),
"the error must name the file: {err:#}"
);
}
#[test]
fn a_slot_that_does_not_declare_guarded_is_an_error() {
let err = Ladder::from_yaml(
"bad.yaml",
"apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n - {name: x, namespace: x, role: test}\n",
)
.expect_err("missing guarded must not default to open");
assert!(format!("{err:#}").contains("guarded"), "{err:#}");
}
#[test]
fn production_with_guarded_false_is_ambiguous_and_refused() {
let err = Ladder::from_yaml(
"bad.yaml",
"apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n - {name: p, namespace: p, role: production, guarded: false}\n",
)
.expect_err("ambiguous prod status must be refused");
let m = format!("{err:#}");
assert!(m.contains("ambiguous"), "{m}");
assert!(
m.contains("guarded: true"),
"the error must name the fix: {m}"
);
}
/// The role field is read tolerantly; the slot's NAME is never read at all.
#[test]
fn role_spellings_fold_but_names_never_imply_a_role() {
assert_eq!(SlotRole::parse("prod"), SlotRole::Production);
assert_eq!(SlotRole::parse("PRODUCTION"), SlotRole::Production);
assert_eq!(SlotRole::parse("live"), SlotRole::Production);
assert_eq!(SlotRole::parse("preprod"), SlotRole::PreProduction);
assert_eq!(SlotRole::parse("qa"), SlotRole::Test);
assert_eq!(
SlotRole::parse("bananas"),
SlotRole::Custom("bananas".into())
);
// A slot CALLED prod whose declared role is test is not production.
let l = Ladder::from_yaml(
"x",
"apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n - {name: prod, namespace: prod, role: test, guarded: false}\n",
)
.unwrap();
assert!(
!l.slot("prod").unwrap().is_guarded(),
"the name must imply nothing"
);
// …and a slot called `harmless` whose declared role is production IS.
let l = Ladder::from_yaml(
"x",
"apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n - {name: harmless, namespace: h, role: production, guarded: true}\n",
)
.unwrap();
assert!(l.slot("harmless").unwrap().is_guarded());
}
// ── versioning ───────────────────────────────────────────────────────────
#[test]
fn an_unknown_api_version_is_refused_loudly_not_parsed_best_effort() {
let err = Ladder::from_yaml(
"future.yaml",
"apiVersion: skidbladnir.nordisk/promote.v9\nslots:\n - {name: x, namespace: x, role: test, guarded: false}\n",
)
.expect_err("unknown schema version must be refused");
let m = format!("{err:#}");
assert!(m.contains("promote.v9") && m.contains(API_VERSION), "{m}");
assert!(m.contains("future.yaml"), "{m}");
}
#[test]
fn a_missing_api_version_is_refused() {
assert!(
Ladder::from_yaml(
"x",
"slots:\n - {name: x, namespace: x, role: test, guarded: false}\n"
)
.is_err()
);
}
// ── validation refuses ambiguity ─────────────────────────────────────────
#[test]
fn duplicate_names_namespaces_and_hosts_are_all_refused() {
let base = "apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n";
for (doc, needle) in [
(
" - {name: a, namespace: n1, role: test, guarded: false}\n - {name: A, namespace: n2, role: test, guarded: false}\n",
"declared twice",
),
(
" - {name: a, namespace: n, role: test, guarded: false}\n - {name: b, namespace: N, role: test, guarded: false}\n",
"both claim namespace",
),
(
" - {name: a, namespace: n1, hosts: [h.test], role: test, guarded: false}\n - {name: b, namespace: n2, hosts: [H.TEST], role: test, guarded: false}\n",
"both claim host",
),
] {
let err = Ladder::from_yaml("dup.yaml", &format!("{base}{doc}")).expect_err(needle);
assert!(format!("{err:#}").contains(needle), "{err:#}");
}
}
#[test]
fn a_dangling_edge_is_refused_and_lists_what_exists() {
let err = Ladder::from_yaml(
"e.yaml",
"apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n - {name: a, namespace: a, role: test, guarded: false, promotes_to: [ghost]}\n",
)
.expect_err("dangling edge");
let m = format!("{err:#}");
assert!(
m.contains("ghost") && m.contains("Declared slots: a"),
"{m}"
);
}
#[test]
fn a_cycle_is_refused_and_named() {
let err = Ladder::from_yaml(
"c.yaml",
"apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n\
- {name: a, namespace: a, role: test, guarded: false, promotes_to: [b]}\n\
- {name: b, namespace: b, role: test, guarded: false, promotes_to: [a]}\n",
)
.expect_err("cycle");
let m = format!("{err:#}");
assert!(
m.contains("cycle") && m.contains('a') && m.contains('b'),
"{m}"
);
}
#[test]
fn a_self_edge_is_refused() {
assert!(Ladder::from_yaml(
"s.yaml",
"apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n - {name: a, namespace: a, role: test, guarded: false, promotes_to: [a]}\n",
)
.is_err());
}
#[test]
fn an_empty_ladder_is_refused() {
assert!(
Ladder::from_yaml(
"x",
"apiVersion: skidbladnir.nordisk/promote.v1\nslots: []\n"
)
.is_err()
);
}
// ── promotion order is enforceable ───────────────────────────────────────
fn example() -> Ladder {
Ladder::from_yaml("x", EXAMPLE_LADDER).unwrap()
}
#[test]
fn one_edge_is_legal_and_a_jump_names_the_rungs_it_skipped() {
let l = example();
assert_eq!(l.check_promotion("staging", "live"), Hop::Legal);
assert_eq!(
l.check_promotion("dev", "live"),
Hop::SkipsRungs(vec!["qa-a".into(), "staging".into()])
);
let err = l
.check_promotion("dev", "live")
.require("dev", "live", false)
.unwrap_err();
assert!(
format!("{err:#}").contains("skips qa-a, staging"),
"{err:#}"
);
// The override does not silence the fact — it hands it back to be reported.
assert_eq!(
l.check_promotion("dev", "live")
.require("dev", "live", true)
.unwrap(),
vec!["qa-a".to_string(), "staging".to_string()]
);
}
#[test]
fn promoting_downward_is_the_wrong_direction() {
let l = example();
assert_eq!(l.check_promotion("live", "staging"), Hop::WrongDirection);
assert!(
l.check_promotion("live", "staging")
.require("live", "staging", true)
.is_err()
);
}
#[test]
fn parallel_rungs_are_not_on_each_others_ladder() {
assert_eq!(example().check_promotion("qa-a", "qa-b"), Hop::NotOnLadder);
}
/// Data goes down. Copying production's data into a test rung is the normal
/// case; the reverse replaces production with somebody's test rows.
#[test]
fn data_flows_down_the_ladder_and_never_up() {
let l = example();
assert!(l.check_data_flow("live", "staging").is_legal());
assert!(
l.check_data_flow("live", "dev").is_legal(),
"prod → dev is a normal copy"
);
assert_eq!(l.check_data_flow("dev", "live"), Hop::WrongDirection);
let err = l
.check_data_flow("dev", "live")
.require("dev", "live", true)
.unwrap_err();
assert!(format!("{err:#}").contains("wrong way"), "{err:#}");
}
#[test]
fn an_unknown_rung_in_a_hop_is_named() {
assert_eq!(
example().check_promotion("dev", "nowhere"),
Hop::UnknownSlot("nowhere".into())
);
}
// ── meta is carried, never interpreted ───────────────────────────────────
#[test]
fn meta_survives_a_round_trip_untouched() {
let l = example();
let back = Ladder::from_json("rt", &serde_json::to_string(&l).unwrap()).unwrap();
assert_eq!(back.slot("dev").unwrap().meta, l.slot("dev").unwrap().meta);
assert_eq!(back.meta, l.meta);
}
}