src/promote/registry.rs
//! The **slot registry** — the runtime view of a [`Ladder`]: resolve an
//! identifier to a slot, keep the production fence un-lowerable, and hand a
//! viewer the whole graph as data.
//!
//! The *format* lives in [`schema`](super::schema); this module is what the guard
//! and the promoter ask questions of.
//!
//! # The demotion hole, and why it cannot exist here
//!
//! A guard written as *"if this target is not marked production, allow it"* is
//! not lowered by a config file — it is **deleted** by one. Drop the production
//! entry, or flip its `guarded` to `false`, and every destructive verb silently
//! becomes legal against production. Nothing fails; nothing warns; the fence is
//! simply not there any more.
//!
//! [`SlotRegistry::load_over`] closes that by construction. Config is layered
//! **over** a baseline the caller declared in code, and the merge is
//! one-directional:
//!
//! * config may **raise** a slot to guarded — always allowed;
//! * config may **not lower** a guarded baseline slot, and may not make one
//! disappear by omission. Either the load is refused
//! ([`DemotionPolicy::Refuse`], the default) or it succeeds with the slot
//! **still fenced** and the attempt recorded in [`SlotRegistry::demotions`].
//!
//! There is deliberately **no** third policy. "Let the config win" is the hole;
//! offering it as an option is offering the hole.
//!
//! # One slot has many names
//!
//! A slot name, a namespace, each declared host, and any generation namespace
//! derived from name or namespace are all the *same* slot.
//! [`SlotRegistry::resolve`] matches across every one of them, which is what stops
//! an identifier from slipping past a fence that only knew about one of a slot's
//! names.
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
use super::schema::{Hop, Ladder, Slot, eq};
use super::topology::{Observations, Topology};
/// What the caller is pointing the tool at.
///
/// There is no `&str` overload anywhere in this module, and that is the point:
/// the tool this generalises accepted a bare `--slot` string into a field the
/// fence read as a *namespace*, so `--slot prod` cleared a fence keyed on
/// namespaces (`njord`) simply because no namespace is literally called `prod`.
/// Naming the **kind** of identifier at the call site makes that mistake
/// unspellable, and [`SlotRegistry::resolve`] then matches across every identity a
/// slot has regardless of which kind you handed it — so getting the kind wrong is
/// safe too.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetRef<'a> {
/// A slot **name**: `prod`, `lab`.
Slot(&'a str),
/// A Kubernetes **namespace**: `njord`, `test1-20260721-1432`.
Namespace(&'a str),
/// A bare **host**: `g11.se`, `lab.g11.se`.
Host(&'a str),
/// A **URL or connection string**, possibly carrying credentials:
/// `postgres://user:pw@db.g11.se:5432/app`. Only the host is looked at, and
/// [`redact`] exists so the rest never reaches a log line.
Url(&'a str),
}
impl TargetRef<'_> {
/// The identifier to resolve — for [`TargetRef::Url`], its host.
pub fn needle(&self) -> String {
match self {
TargetRef::Slot(s) | TargetRef::Namespace(s) | TargetRef::Host(s) => {
s.trim().to_ascii_lowercase()
}
TargetRef::Url(u) => host_of(u),
}
}
/// The kind, for refusal messages.
pub fn kind(&self) -> &'static str {
match self {
TargetRef::Slot(_) => "slot",
TargetRef::Namespace(_) => "namespace",
TargetRef::Host(_) => "host",
TargetRef::Url(_) => "url",
}
}
/// The target as it is safe to print (a URL loses its credentials).
pub fn shown(&self) -> String {
match self {
TargetRef::Url(u) => redact(u),
other => other.needle(),
}
}
}
/// The lowercased host out of a URL, a `user:pass@host:port/…`, or a bare host.
pub fn host_of(s: &str) -> String {
let after_scheme = s.trim().rsplit("://").next().unwrap_or(s);
let after_userinfo = after_scheme.rsplit('@').next().unwrap_or(after_scheme);
after_userinfo
.chars()
.take_while(|c| !matches!(c, '/' | ':' | '?' | '#'))
.collect::<String>()
.to_ascii_lowercase()
}
/// Strip `user:pass@` out of a URL so a refusal can name the target without
/// leaking the credential that reached it.
pub fn redact(url: &str) -> String {
match (url.find("://"), url.rfind('@')) {
(Some(s), Some(at)) if at > s + 3 => format!("{}***@{}", &url[..s + 3], &url[at + 1..]),
_ => url.to_string(),
}
}
/// What [`SlotRegistry::resolve`] found.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resolution<'a> {
/// The identifier names this slot (by name, namespace, host or generation).
Slot(&'a Slot),
/// Nothing in the registry claims this identifier. **Not** a licence to
/// proceed — see [`UnknownPolicy`](super::UnknownPolicy).
Unknown,
}
impl Resolution<'_> {
pub fn slot(&self) -> Option<&Slot> {
match self {
Resolution::Slot(s) => Some(s),
Resolution::Unknown => None,
}
}
/// Is the resolved slot behind the fence? An unresolved target is not
/// "not guarded" — see the callers, which fence the unknown separately.
pub fn is_guarded(&self) -> bool {
self.slot().is_some_and(Slot::is_guarded)
}
}
/// A guarded slot the configuration tried to lower or drop, and what the registry
/// did about it (which is: kept the fence up).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Demotion {
/// The baseline slot's name.
pub slot: String,
/// Its namespace, so a message can be precise about what stayed fenced.
pub namespace: String,
/// What the config did: `lowered` (present, `guarded` flipped) or `dropped`
/// (absent entirely).
pub how: String,
/// The name the config used for the colliding entry, when there was one.
pub config_named: Option<String>,
}
impl Demotion {
/// A single line fit for an operator's terminal.
pub fn line(&self) -> String {
format!(
"⚠ config {} the guarded slot '{}' (namespace '{}') — IGNORED, it stays fenced{}",
self.how,
self.slot,
self.namespace,
match &self.config_named {
Some(n) if !eq(n, &self.slot) => format!(" (config entry '{n}')"),
_ => String::new(),
}
)
}
}
/// What to do when configuration tries to lower or drop a guarded slot.
///
/// Note what is **absent**: there is no variant that lets the demotion take
/// effect. Both variants keep the slot fenced; they differ only in whether the
/// load fails or merely shouts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DemotionPolicy {
/// Refuse the load. The default, and the right answer for a deploy tool: a
/// config that disagrees with the baseline about what production is has a bug
/// in it, and running with it is running blind.
#[default]
Refuse,
/// Load anyway, keep every baseline guarded slot fenced, and record the
/// attempt in [`SlotRegistry::demotions`] — which every refusal touching that
/// slot then prints.
ReportAndKeepFenced,
}
/// Every slot the tool knows about, plus what configuration tried and failed to
/// do to the fence.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SlotRegistry {
ladder: Ladder,
demotions: Vec<Demotion>,
}
impl SlotRegistry {
/// A registry over an already-validated ladder — the **baseline** a caller
/// declares in code and layers configuration over.
pub fn new(ladder: Ladder) -> Self {
SlotRegistry {
ladder,
demotions: Vec::new(),
}
}
/// Build from slots declared in code (validated as a ladder).
pub fn from_slots(slots: Vec<Slot>) -> Result<Self> {
Ok(SlotRegistry::new(Ladder::from_slots(slots)?))
}
/// **The load a fence is built on.** Layer `config` over `baseline`, allowing
/// the config to add slots and to *raise* a slot to guarded, but never to
/// lower or drop one the baseline declared guarded.
///
/// A baseline guarded slot is matched against the config by any identity
/// ([`Slot::matches`]), so renaming the entry does not evade the check either.
/// If the config's colliding entry is unguarded, or if no config entry claims
/// the slot at all, that is a [`Demotion`]: under [`DemotionPolicy::Refuse`]
/// the load fails; under [`DemotionPolicy::ReportAndKeepFenced`] the slot is
/// kept — fenced — and the attempt is recorded.
///
/// The merged ladder is re-validated, so a config that raises a slot into an
/// ambiguous state (`role: production`, `guarded: false`) still cannot load.
pub fn load_over(
baseline: &SlotRegistry,
config: Ladder,
policy: DemotionPolicy,
) -> Result<Self> {
let mut slots = config.slots;
let meta = config.meta;
let mut demotions = Vec::new();
for base in baseline.ladder.slots.iter().filter(|s| s.is_guarded()) {
let idx = slots.iter().position(|c| {
c.matches(&base.name)
|| c.matches(&base.namespace)
|| base.hosts.iter().any(|h| c.matches(h))
|| base.matches(&c.name)
|| base.matches(&c.namespace)
});
match idx {
Some(i) if slots[i].is_guarded() => {} // config agrees — nothing to do
Some(i) => {
demotions.push(Demotion {
slot: base.name.clone(),
namespace: base.namespace.clone(),
how: "lowered".into(),
config_named: Some(slots[i].name.clone()),
});
// The fence goes back up on the config's OWN entry, so its
// namespace/hosts/edges (which may be the newer truth) survive.
slots[i].guarded = true;
}
None => {
demotions.push(Demotion {
slot: base.name.clone(),
namespace: base.namespace.clone(),
how: "dropped".into(),
config_named: None,
});
// Re-inserted without its edges: the config owns the ladder's
// shape, and inventing edges into slots it never declared would
// be the registry making up a topology. It is restored as a
// fenced, unlinked rung — visible, guarded, and obviously wrong
// in the topology view, which is exactly what it is.
let mut restored = base.clone();
restored.promotes_to.clear();
slots.push(restored);
}
}
}
if !demotions.is_empty() && policy == DemotionPolicy::Refuse {
let lines: Vec<String> = demotions.iter().map(Demotion::line).collect();
bail!(
"refusing this slot configuration: it tries to lower the production fence.\n{}\n\
A configuration file may RAISE a slot to guarded, never lower one.\n\
Fix the config, or load it with DemotionPolicy::ReportAndKeepFenced — which \
keeps every one of these slots fenced anyway and prints the warning above.",
lines.join("\n")
);
}
let ladder = Ladder {
api_version: config.api_version,
meta,
slots,
};
ladder.validate("<merged ladder>")?;
Ok(SlotRegistry { ladder, demotions })
}
/// The validated ladder behind this registry.
pub fn ladder(&self) -> &Ladder {
&self.ladder
}
/// Every slot, in declaration order.
pub fn slots(&self) -> &[Slot] {
&self.ladder.slots
}
/// Guarded slots the configuration tried to lower or drop. Empty in the
/// healthy case. Never empty *and* honoured: a recorded demotion is one that
/// did **not** take effect.
pub fn demotions(&self) -> &[Demotion] {
&self.demotions
}
/// The guarded slots, as the fence sees them.
pub fn guarded(&self) -> Vec<&Slot> {
self.ladder.guarded()
}
/// Resolve an identifier across **every** identity every slot has.
///
/// Two passes, and the order is the whole safety argument:
///
/// 1. **Exact identities** — name, namespace, generation namespace, declared
/// host. Guarded slots are consulted first, so an ambiguous identifier reads
/// as fenced.
/// 2. **Declared zones** ([`Slot::matches_zone`]) — only once every exact
/// identity has missed, again guarded-first.
///
/// Pass 1 outranking pass 2 is what lets a production apex own `*.example.test`
/// without swallowing a sibling that named `lab.example.test` for itself: an
/// explicit claim always beats someone else's wildcard. And pass 2 existing at
/// all is what stops prod's unlisted data plane (`db.example.test`) from
/// resolving to [`Resolution::Unknown`] — which
/// [`Guard::allow_unknown`](super::guard::Guard::allow_unknown) would wave
/// through.
pub fn resolve(&self, target: &TargetRef<'_>) -> Resolution<'_> {
let needle = target.needle();
if needle.is_empty() {
return Resolution::Unknown;
}
// Guarded first: if an identifier is ambiguous between a fenced slot and a
// free one, the fenced reading wins. Ambiguity must fail safe.
if let Some(s) = self
.ladder
.slots
.iter()
.find(|s| s.is_guarded() && s.matches(&needle))
{
return Resolution::Slot(s);
}
if let Some(s) = self.ladder.slots.iter().find(|s| s.matches(&needle)) {
return Resolution::Slot(s);
}
// Nobody claimed it by name. Now — and only now — honour declared zone
// ownership, still fenced-first.
if let Some(s) = self
.ladder
.slots
.iter()
.find(|s| s.is_guarded() && s.matches_zone(&needle))
{
return Resolution::Slot(s);
}
match self.ladder.slots.iter().find(|s| s.matches_zone(&needle)) {
Some(s) => Resolution::Slot(s),
None => Resolution::Unknown,
}
}
/// Every identifier an operator could legitimately have meant — used to turn a
/// refusal into a next step rather than a dead end.
pub fn known_identifiers(&self) -> Vec<String> {
let mut v: Vec<String> = Vec::new();
for s in &self.ladder.slots {
v.push(s.name.clone());
if !eq(&s.name, &s.namespace) {
v.push(s.namespace.clone());
}
// Exact hosts only — a `*.zone` entry is ownership, not something an
// operator could have typed, so suggesting it back is noise.
v.extend(s.exact_hosts().map(|h| h.to_string()));
}
v.sort();
v.dedup();
v
}
/// Is promoting **code** from `from` to `to` a legal hop? Delegates to the
/// ladder; see [`Hop`].
pub fn check_promotion(&self, from: &str, to: &str) -> Hop {
self.ladder.check_promotion(from, to)
}
/// Is copying **data** from `from` to `to` legal? Data flows down.
pub fn check_data_flow(&self, from: &str, to: &str) -> Hop {
self.ladder.check_data_flow(from, to)
}
/// **The whole ladder as data**, for a viewer, an RPC or a test.
///
/// Nodes, edges, computed ranks, each slot's declared role/guard/meta, and a
/// per-rung status taken from `observations` — never inferred. A rung nobody
/// observed reports [`RungState::NeverRun`](super::topology::RungState::NeverRun),
/// not green. Any configuration that tried to lower the fence rides along in
/// [`Topology::notes`].
pub fn topology(&self, observations: &Observations) -> Topology {
Topology::build(&self.ladder, observations, &self.demotions)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "promote")]
use crate::promote::schema::EXAMPLE_LADDER;
use crate::promote::schema::SlotRole;
fn baseline() -> SlotRegistry {
SlotRegistry::from_slots(vec![
Slot::new("prod", "njord", SlotRole::Production, true)
.host("g11.se")
.host("www.g11.se"),
Slot::new("preprod", "njord-lab", SlotRole::PreProduction, false)
.host("lab.g11.se")
.promotes_to("prod"),
Slot::new("test1", "test1", SlotRole::Test, false)
.host("test1.g11.se")
.promotes_to("preprod"),
])
.unwrap()
}
/// Every overlay fixture in this module is a YAML parse, which is the ONE thing
/// the `promote` feature actually buys (`schema.rs`'s `from_yaml`). The tests
/// that reach it are dependency-bound and stay gated; the resolution tests below
/// build their registry from `from_slots` and are not.
#[cfg(feature = "promote")]
fn cfg(yaml: &str) -> Ladder {
Ladder::from_yaml("test-config.yaml", yaml).unwrap()
}
#[test]
fn a_slot_answers_to_every_one_of_its_names() {
let r = baseline();
for n in [
"prod",
"njord",
"g11.se",
"www.g11.se",
"njord-20260722-1730",
] {
assert!(
r.resolve(&TargetRef::Slot(n)).is_guarded(),
"'{n}' must resolve to prod"
);
}
}
/// The exact hole this registry exists to close: a fence keyed only on the
/// namespace waves `--slot prod` through, because no namespace is called
/// `prod`.
#[test]
fn the_slot_name_of_production_is_not_a_bypass() {
let r = baseline();
assert!(r.resolve(&TargetRef::Slot("prod")).is_guarded());
assert!(
r.resolve(&TargetRef::Namespace("prod")).is_guarded(),
"kind must not matter"
);
}
#[test]
fn exact_tokens_only_no_prefix_bleed() {
let r = SlotRegistry::from_slots(vec![
Slot::new("test1", "test1", SlotRole::Production, true).host("test1.g11.se"),
Slot::new("test10", "test10", SlotRole::Test, false).host("test10.g11.se"),
])
.unwrap();
assert!(!r.resolve(&TargetRef::Namespace("test10")).is_guarded());
assert!(r.resolve(&TargetRef::Namespace("test1")).is_guarded());
}
#[test]
fn subdomains_are_their_own_slots_but_www_folds_toward_the_fence() {
let r = baseline();
assert!(r.resolve(&TargetRef::Host("www.g11.se")).is_guarded());
assert!(
r.resolve(&TargetRef::Host("WWW.G11.SE")).is_guarded(),
"case-insensitive"
);
assert!(!r.resolve(&TargetRef::Host("lab.g11.se")).is_guarded());
assert!(!r.resolve(&TargetRef::Host("test1.g11.se")).is_guarded());
}
#[test]
fn urls_resolve_by_host_and_print_redacted() {
let r = baseline();
assert!(
r.resolve(&TargetRef::Url("postgres://u:pw@g11.se:5432/app"))
.is_guarded()
);
assert!(
!r.resolve(&TargetRef::Url("postgres://u:pw@test1-postgresql:5432/app"))
.is_guarded()
);
assert_eq!(
TargetRef::Url("postgres://u:secret@db.g11.se/app").shown(),
"postgres://***@db.g11.se/app"
);
}
#[test]
fn unrelated_identifiers_resolve_to_unknown() {
assert_eq!(
baseline().resolve(&TargetRef::Namespace("something-else")),
Resolution::Unknown
);
}
// ── the demotion hole ────────────────────────────────────────────────────
#[cfg(feature = "promote")]
const LYING: &str = "apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n\
- {name: prod, namespace: njord, hosts: [g11.se], role: test, guarded: false}\n\
- {name: preprod, namespace: njord-lab, hosts: [lab.g11.se], role: pre-production, guarded: false, promotes_to: [prod]}\n";
#[test]
#[cfg(feature = "promote")]
fn a_config_that_lowers_the_fence_is_refused() {
let err = SlotRegistry::load_over(&baseline(), cfg(LYING), DemotionPolicy::Refuse)
.expect_err("a config that lowers the fence must not load");
let msg = format!("{err:#}");
assert!(
msg.contains("lowered"),
"the refusal must name what happened: {msg}"
);
assert!(msg.contains("prod"), "…and which slot: {msg}");
}
#[test]
#[cfg(feature = "promote")]
fn a_config_that_lowers_the_fence_cannot_disarm_it_even_when_tolerated() {
let r =
SlotRegistry::load_over(&baseline(), cfg(LYING), DemotionPolicy::ReportAndKeepFenced)
.expect("tolerated");
assert!(
r.resolve(&TargetRef::Namespace("njord")).is_guarded(),
"the config said unguarded; the fence must stay up regardless"
);
assert_eq!(r.demotions().len(), 1);
assert!(r.demotions()[0].line().contains("IGNORED"));
}
/// Deleting the entry is the same attack as lowering it — and against a guard
/// that returns early for "not marked production" it is the *more* effective
/// one, because there is nothing left to disagree with.
#[test]
#[cfg(feature = "promote")]
fn a_config_that_drops_the_guarded_slot_cannot_disarm_the_fence() {
let dropped = "apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n\
- {name: preprod, namespace: njord-lab, hosts: [lab.g11.se], role: pre-production, guarded: false}\n";
let err = SlotRegistry::load_over(&baseline(), cfg(dropped), DemotionPolicy::Refuse)
.expect_err("dropping the guarded slot must not load silently");
assert!(format!("{err:#}").contains("dropped"), "{err:#}");
let r = SlotRegistry::load_over(
&baseline(),
cfg(dropped),
DemotionPolicy::ReportAndKeepFenced,
)
.unwrap();
assert!(
r.resolve(&TargetRef::Namespace("njord")).is_guarded(),
"restored, fenced"
);
}
/// Renaming the entry must not evade the collision check either.
#[test]
#[cfg(feature = "promote")]
fn a_renamed_entry_is_still_the_same_guarded_slot() {
let renamed = "apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n\
- {name: produktion, namespace: njord, hosts: [g11.se], role: test, guarded: false}\n";
let r = SlotRegistry::load_over(
&baseline(),
cfg(renamed),
DemotionPolicy::ReportAndKeepFenced,
)
.unwrap();
assert!(r.resolve(&TargetRef::Namespace("njord")).is_guarded());
assert_eq!(r.demotions()[0].config_named.as_deref(), Some("produktion"));
}
#[test]
#[cfg(feature = "promote")]
fn a_config_may_raise_a_slot_to_guarded() {
let raising = "apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n\
- {name: prod, namespace: njord, hosts: [g11.se], role: production, guarded: true}\n\
- {name: preprod, namespace: njord-lab, hosts: [lab.g11.se], role: pre-production, guarded: true, promotes_to: [prod]}\n";
let r = SlotRegistry::load_over(&baseline(), cfg(raising), DemotionPolicy::Refuse).unwrap();
assert!(r.resolve(&TargetRef::Slot("preprod")).is_guarded());
assert!(r.demotions().is_empty());
}
/// The merge re-validates, so a config cannot smuggle in an ambiguous slot.
#[test]
#[cfg(feature = "promote")]
fn a_merged_ladder_is_revalidated() {
// Two config slots claiming one namespace: invalid on its own, and the
// merge must not launder it.
let bad = Ladder::from_yaml(
"x",
"apiVersion: skidbladnir.nordisk/promote.v1\nslots:\n\
- {name: prod, namespace: njord, hosts: [g11.se], role: production, guarded: true}\n",
)
.unwrap();
// Baseline has a guarded slot on namespace `njord`; a config declaring a
// DIFFERENT guarded slot that also claims `njord` would be caught here.
let r = SlotRegistry::load_over(&baseline(), bad, DemotionPolicy::Refuse).unwrap();
assert_eq!(
r.slots().len(),
1,
"config owns the shape when it agrees about the fence"
);
}
#[test]
fn ambiguity_resolves_to_the_fenced_reading() {
let r = SlotRegistry::from_slots(vec![
Slot::new("free", "shared", SlotRole::Test, false).host("free.example"),
Slot::new("prod", "apex", SlotRole::Production, true).host("shared"),
])
.unwrap();
assert!(r.resolve(&TargetRef::Namespace("shared")).is_guarded());
}
#[test]
#[cfg(feature = "promote")]
fn a_registry_answers_hop_questions_from_the_ladder() {
let r = SlotRegistry::new(Ladder::from_yaml("x", EXAMPLE_LADDER).unwrap());
assert!(r.check_promotion("staging", "live").is_legal());
assert!(r.check_data_flow("live", "dev").is_legal());
assert!(!r.check_data_flow("dev", "live").is_legal());
}
}