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

nordisk/Skidbladnir

.nornir/promote-design.md

PROMOTE — the design, and the public contract

skidbladnir::promote, feature promote (+ promote-k8s for the live half). Guide: promote-guide.md. Walkthrough: promote-for-idiots.md.

Everything below marked CONTRACT is what a consumer builds against. It is written precisely because the first consumer (Njord's crates/deployer) is being refactored onto it by a different pair of hands than wrote it.


1. What this module is

Skíðblaðnir's other verbs are birth: pack → archive → fetch ║airgap║ → unfold → install → start. promote is ongoing operations on something already alive — roll a new image into a test rung, copy production's data down into it, raise a fresh generation and switch the host onto it, and never do any of that to production by accident.

It was extracted from a working tool. The split is:

generic — lives here application-specific — stays with the consumer
the environment ladder (sites, roles, order) what a table is, which column is the change clock
the production fence and its override which rows must not survive a cross-slot copy
generations: raise · sleep · wake · switch which object-store prefixes exist
set-image → wait rollout → roll back the mail policy, the schema version, the 40 tables
the bundle envelope everything the payload is

The seam is AppData. Nothing on the left ever learns anything from the right.


2. Charter — the open question, stated not answered

Does this ship's charter stretch to "keeps running things in sync"?

The honest answer from building it: the code fits, and the charter is a judgement call that the code shape does not make. Everything promote needs already lives here — the content-addressed bundle, the zero-shell law, the kube-rs applier, the pure-Rust posture, the "a refusal names its remedy" habit. Nothing had to be bent. The bundle envelope is a thin layer over archive::Archive and is the strongest argument that this is the same ship: promotion moves a sealed payload between two places, which is what the ship does.

The argument against is equally simple and is not a code argument. "Skíðblaðnir" is the ship that carries the gods' war-gear across a gap and folds into a pouch. That is a transport and birth metaphor. Blue/green generations, ingress switching and rollout watching are operations, and a reader who knows the crate as "the airgap framework" will not look here for them.

So the module is built to be liftable: one directory, two features, no changes anywhere outside #[cfg(feature = "promote")], and no new heavyweight dependency. If the answer is "this belongs in its own crate", the move is a directory rename and a Cargo.toml — not an untangling.

Recommendation, for Rickard and Henrik to accept or overrule: keep it here while it is changing weekly and the ladder schema is settling; revisit once a second consumer authors a ladder. A versioned cross-repo contract for something this young costs more than it buys.


3. CONTRACT — the ladder schema (promote::schema)

This is a public, versioned config format. Any consumer authors one file in it. Njord's Njord-deploys/env/<customer>/<slot>/slot.yaml is one instance of this schema, not a format Njord invented.

apiVersion: skidbladnir.nordisk/promote.v1   # REQUIRED. Unknown → loud refusal.
meta:                                        # ladder-wide consumer facts
  customer: Example Corp
  owner: platform-team@example.com
slots:
  - name: dev                # REQUIRED, unique
    namespace: example-dev   # REQUIRED, unique
    hosts: [dev.example.test]# authoritative; a host not listed belongs to no slot
    role: development        # REQUIRED — see the vocabulary below
    guarded: false           # REQUIRED — never derived from the name
    promotes_to: [qa-a, qa-b]# the rungs CODE may move to next
    meta: {note: anything}   # carried, never interpreted

The shipped reference instance is promote::EXAMPLE_LADDER (a fictional consumer, deliberately not anyone's real hosts). It is parsed by a conformance test, so it cannot drift from the parser.

3.1 Roles are declared, never inferred

role vocabulary, with tolerant spellings folded onto the canonical value:

canonical also accepted
production prod, prd, produktion, live
pre-production preprod, pre-prod, preproduction, uat
staging stage, stg
test qa, qual, testing
development dev, sandbox
(anything else) kept verbatim as SlotRole::Custom

Folding spellings of the role field is reading a declared value tolerantly. It is not the same as inferring a role from a slot's name, which never happens anywhere in this crate. A slot called prod whose declared role is test is a test slot; a slot called harmless whose declared role is production is production.

guarded is a separate required boolean, because "what is this for" and "does the fence apply" are different questions. Any role may be guarded. The one tie between them: role: production with guarded: false is a validation error — that combination is ambiguous, and ambiguity about what production is, is the failure mode the whole module exists to prevent.

3.2 Order is a DAG, not a rank — and why

promotes_to names the rungs code may move to next. Edges, not a rank integer, because:

A rank is still available where a viewer wants a linear layout: Ladder::rank computes topological depth from the edges. Computed, never authored.

3.3 The two directions

Edges point the way code travels — up, toward production. Data travels the other way: copying production's data into a test rung is normal; the reverse replaces production with somebody's test rows. Both are answered from the same edges:

ladder.check_promotion(from, to) -> Hop   // code, upward
ladder.check_data_flow(from, to) -> Hop   // data, downward

Hop is Legal | SkipsRungs(Vec<String>) | WrongDirection | NotOnLadder | SameSlot | UnknownSlot(String), and Hop::require(from, to, allow_skip) turns it into a Result. A skip is the only overridable case, and even when overridden the skipped rungs come back in the Ok value so the caller reports them. A wrong-direction move is not overridable at all.

3.4 Validation refuses ambiguity

Every check names the file and the field:


4. CONTRACT — the registry and the un-lowerable fence

SlotRegistry::from_slots(Vec<Slot>) -> Result<SlotRegistry>   // the baseline, in code
SlotRegistry::new(Ladder) -> SlotRegistry
SlotRegistry::load_over(&baseline, config: Ladder, DemotionPolicy) -> Result<SlotRegistry>

4.1 The hole being designed out

The tool this generalises had:

pub fn assert_lab_only(t: &Target) -> Result<()> {
    if !is_prod_namespace(&t.namespace) { return Ok(()); }   // ← here
    …
}

A config file that marks the production namespace non-prod does not lower that guard, it deletes it: the early return fires, and every destructive verb is legal against production with no banner and no audit line. Deleting the entry entirely is the same attack and works even better.

4.2 What replaces it

Configuration is layered over a baseline the caller declares in code, and the merge is one-directional:

DemotionPolicy::Refuse (the default) fails the load. ReportAndKeepFenced loads, keeps the slot fenced, and records a Demotion — which then appears in every guard refusal touching that slot and in Topology::notes, so it reaches a panel and not only a terminal that scrolled away.

There is deliberately no third policy. "Let the config win" is the hole; offering it as an option is offering the hole.

The merged ladder is re-validated, so a config cannot launder an ambiguous slot in through the merge.

4.3 One slot has many names

enum TargetRef<'a> { Slot(&str), Namespace(&str), Host(&str), Url(&str) }
registry.resolve(&TargetRef) -> Resolution<'_>   // Slot(&Slot) | Unknown

There is no &str overload. resolve matches across every identity a slot has — name, namespace, each declared host, and any generation namespace derived from the name or the namespace — so which variant you pick cannot make the answer wrong, only the message clearer.

This is the fix for the second bug: a raw --slot prod cleared a fence keyed on namespaces, because no namespace is literally called prod. Here prod is one of the production slot's identities, so it hits the fence.

Two deliberate asymmetries, both in the fail-safe direction:


5. CONTRACT — the guard

Guard::new(&registry)
    .with_override(Override::flagged())          // key one: the CLI flag
    .with_env(EnvSource::Process)                // key two: PROMOTE_ALLOW_PROD=1
    .allow_unknown()                             // read-only verbs ONLY
    .assert(&TargetRef::Slot("lab"))  -> Result<Cleared>
    .assert_all(&[…])                 -> Result<Cleared>   // all or nothing
    .assert_promotion(from, to, allow_skip) -> Result<(Cleared, Vec<String>)>
    .assert_data_flow(from, to)             -> Result<Cleared>

5.1 Cleared is a capability, not a promise

Cleared has private fields and is constructible only by the guard. Every mutating operation in promote::release takes &Cleared. "Did anyone check the fence?" becomes a signature instead of a convention each op has to remember.

Cleared::covers(namespace) is true for the cleared slot's namespace and for any generation of it, and false otherwise; require_covers is the Result form. A clearance for lab cannot be spent on njord.


6. CONTRACT — AppData, the seam

pub type Fut<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;

pub trait AppData: Send + Sync {
    fn describe(&self) -> &str;
    fn quiesce<'a>(&'a self, slot: &'a Slot) -> Fut<'a, Result<Quiesced>>;
    fn resume<'a>(&'a self, slot: &'a Slot, q: &'a Quiesced) -> Fut<'a, Result<()>>;
    fn dump<'a>(&'a self, slot: &'a Slot, staging: &'a Path) -> Fut<'a, Result<DumpReport>>;
    fn load<'a>(&'a self, slot: &'a Slot, staging: &'a Path, q: &'a Quiesced)
        -> Fut<'a, Result<LoadReport>>;
    fn watermark<'a>(&'a self, slot: &'a Slot) -> Fut<'a, Result<Option<Watermark>>>;
    fn neutralise_after_load<'a>(&'a self, slot: &'a Slot, q: &'a Quiesced)
        -> Fut<'a, Result<Vec<String>>>;
}

Refinements against the original sketch, each from the real code:

6.1 Async without an async dependency

The traits return Fut — a plain Pin<Box<dyn Future>> from std. Object-safe (the ship holds &dyn AppData), runtime-agnostic, and zero dependencies: no async-trait, no tokio in the promote core. A synchronous implementation returns Box::pin(async move { … }) and awaits nothing.

6.2 No silent partial success

DumpReport::new()
    .part(CapturedPart::complete("database", Some(41)))
    .part(CapturedPart::skipped("objects", "S3 credentials were not configured"))
    .watermark(Some(w))
    .app_meta(json!({ … }));           // opaque to the ship

report.completeness() -> Completeness  // Complete | Incomplete{parts} | Unknown
report.require_complete() -> Result<()>

The specific bug: the original backup degraded to database-only when S3_* was missing and still exited 0, producing a bundle indistinguishable from a good one. Here that becomes CapturedPart::skipped("objects", …) → Incomplete → the verdict is stamped into the manifest → unseal refuses it.

6.3 The ship never guesses the payload

Watermark { mark: Option<String>, magnitude: Option<BTreeMap<String, i64>> }
bundle_wm.would_discard(target_wm: Option<&Watermark>) -> Discard

Discard is Nothing | NewerWrites{..} | RemovedRows{buckets} | Unknown{because}, and requires_confirmation() is true for everything except Nothing — including Unknown.

Two clocks, and the second is not decoration. A change timestamp only moves forward when rows are written; a deletion does not move it, it removes it. So a slot where somebody deleted two hundred rows since the bundle was sealed reads as "nothing happened" on the mark alone, and a switch destroys those deletions silently. magnitude is the cheap quantity that actually goes down.

Every path that cannot prove safety returns Unknown: no target watermark, a missing mark on either side, no comparable counts, or a bucket the target did not report. None from AppData::watermark means unknown, never "nothing newer".


7. CONTRACT — the bundle envelope

seal(&dyn Archive, &SealSpec, &DumpReport) -> Result<Sealed>
verify(&dyn Archive, bundle) -> Result<VerifyReport>
unseal(&dyn Archive, bundle, dest, LoadPolicy) -> Result<Unsealed>
load_manifest(bundle) -> Result<PromotionManifest>

Payload-agnostic, and a thin layer over the existing archive::Archive (so TarZstd and the znippy engine both work unchanged).

The PromotionManifest is a sidecar at <bundle>.promote.json, mirroring archive::manifest_path. It is beside the bundle rather than inside it because a manifest cannot describe an archive it is part of — including it would change the content and therefore its own integrity root.

It carries version (refused if unknown, never best-effort parsed), source_slot, taken_at, app, completeness, parts, watermark, app_meta (opaque), and the archive::BundleManifest with its root_sha256.

VerifyReport keeps two questions apart on purpose:

Conflating them is exactly how a partial bundle passes a checksum and gets called good. LoadPolicy::strict() requires both; accept_incomplete() and accept_corrupt() are explicit opt-ins, and the verdict still comes back in Unsealed::completeness().


8. CONTRACT — generations

Generation::new(slot, unix) -> Result<Generation>     // "<slot>-YYYYMMDD-HHMM"
Generation::is_of(base, namespace) -> bool
generations(slot, &[String]) -> Vec<Generation>       // newest first
sleep_plan(ns) / wake_plan(ns) -> Vec<GenerationStep>
switch_plan(slot, from, to_ns, host, service, port, tls) -> Result<Vec<GenerationStep>>
RAISE_LADDER: &[RaisePhase]

Plans are pure functions returning values. That is what lets the ordering — the part that has actually caused outages — be asserted in a test with no cluster in sight, and it is what makes a dry run structurally incapable of touching the world (see §10).

switch_plan's order is the contract: wake the target → point the host at it → drop the old route. Waking first closes the window where the host resolves to a scaled-down generation. Both routes existing for a moment is safe: a route object is namespace-local and can only point at services in its own namespace. The old generation is not put to sleep — switching back must be as fast as switching over, and a running generation is seconds from live.

RaisePhase is Rbac → Namespace → Tls → Storage → Secrets → Release → Data → SwitchHost. The ship names the ladder and its order; the caller climbs it. TLS sits early because it is a step that can refuse, and a refusal belongs where it costs least — an empty namespace, not a completed data load. SwitchHost is the commit point: before it a failure costs a namespace nobody saw; after it, an outage.


9. CONTRACT — release operations, and the honest word on Helm

trait ReleaseOps {                       // async via Fut, object-safe
    fn current_image(&Cleared-free)      // read
    fn set_image(&Cleared, …)            // write
    fn wait_rollout / scale / wait_replicas / workloads
    fn apply(&Cleared, ns, &ReleaseSource)
    fn host_owner / route_host(&Cleared, …) / drop_route(&Cleared, …)
}

rollout(&dyn ReleaseOps, &Cleared, &RolloutPlan) -> Result<RolloutOutcome>
run_generation_plan(&dyn ReleaseOps, &Cleared, &[GenerationStep]) -> Result<Vec<String>>

KubeReleaseOps (feature promote-k8s) implements it over kube-rs. No shell, anywhere.

RolloutOutcome keeps four cases apart, because they mean different things to the person on call:

outcome what happened
Rolled{from, to} the new image is live — the only success
RolledBack{to_previous, failure} it failed; the previous image is back and serving
RollbackFailed{failure, rollback_failure} it failed and the rollback failed — may be DOWN
NoAnchor{failure} it failed and nothing was restored, because no previous image was readable

The anchor is read before anything is patched; after the patch it is too late to ask. NoAnchor exists so a rollback that never happened is never reported as one.

9.1 Helm

The original shells helm upgrade, and its --dry-run still spawned helm version — a preview that touched the world. Neither is acceptable here.

But promote does not reimplement Helm, and says so rather than designing around it. A chart is Go templates plus Sprig plus .Capabilities plus lookup against a live cluster. There is no production-grade pure-Rust implementation of that stack, and writing one is a project, not a module.

So ReleaseSource has two sides:

What is generic and worth owning is the read side. stored_release_values(client, namespace, chart_name) pulls a Helm release's stored user-supplied values straight out of its sh.helm.release.v1.* Secret (base64 → gzip → JSON). That is exactly what helm get values answers, obtained with no Helm binary on PATH, no subprocess whose stdout carries the slot's secrets, and therefore runnable as a plain Kubernetes Job. Only the deployed revision is read, and only releases whose chart matches chart_name — a namespace routinely carries more than one release and the others are different applications.


10. Dry run is a pure function, not a flag

Every planning entry point (*_plan, RolloutPlan::describe, Hop, the whole schema layer) is pure: no cluster call, no subprocess, no filesystem read. A dry run is "produce the plan and print it". There is no code path on which a preview can touch the world, so the --dry-run-that-spawned-helm-version class of bug cannot be expressed in this shape.


11. CONTRACT — the viewer contract

registry.topology(&Observations) -> Topology     // serde, both ways

Topology { apiVersion, meta, nodes, edges, notes }, where a TopologyNode carries name, namespace, hosts, role, guarded, computed rank, promotes_to, consumer meta, and an Observation.

A panel needs nothing else and never parses a config file itself.

Observation { state, code, detail, observed_at, revision } with RungState:

state meaning
NeverRun nothing observed. The default, and the only state a rung gets for free
Green the last observed promotion succeeded
Failed it failed. Carries a code
Unknown the engine could not determine the state. Carries a code

The honesty rules, carried over from sockets:

11.1 Which viewer

No UI dependency enters Skíðblaðnir. This crate is path-depended by nornir, dwarves, korp and holger; adding an egui stack would force it onto four headless consumers. The panel lives in the consumer's own UI crate.

facett-pipeline should render this as-is, through a new adapter, and a dedicated panel is not warranted. The precedent is exact: facett_pipeline::RunaPipelineAdapter already renders both knut's Spark pipelines and the brokkr build DAG through one viewer. A promotion ladder is the same object — a DAG with per-node status — and Topology hands the adapter everything the viewer wants: nodes with stable ids, edges, a computed rank for layout, and a per-node state with a reason code.

Two things the adapter must decide, because the engine deliberately does not:

  1. How the four RungStates look. NeverRun and Unknown must not be the same colour as Green, and ideally not the same as each other — the whole point of keeping them apart is that an operator can see the difference.
  2. What to do with guarded. A fenced rung should be visually distinct; the engine only says guarded: true.

Topology::not_green() is the panel's "what needs attention" list, and it includes the never-run and the unknown by design.


12. Feature gating

promote     = ["dep:serde_yaml"]                              # pure core
promote-k8s = ["promote", "kubernetes", "dep:flate2", "dep:base64"]

13. Tests

build lib promote_contract public_api testmatrix_rows
default (before) 183 — 6 16
default (after) 183 0 (cfg'd out) 6 16
--features promote 282 15 6 16

tests/promote_contract.rs exercises the module through public exports only — an external consumer's view. If a name stops being public or changes shape it fails to compile, which is the honest early warning for a downstream break.

Red-when-reverted, each verified by actually breaking the code and counting:

protection removed tests that go red
load_over stops defending the fence 6 lib + 1 contract
Slot::matches goes back to namespace-only (--slot prod) 9 lib
empty report → Complete; Skipped stops spoiling the verdict; watermark(None) → Nothing 7 lib + 2 contract
unknown target allowed; one override key suffices 2 lib + 2 contract

14. What a consumer must do to build against this

  1. Declare a baseline in code — at minimum every guarded slot, with its namespace and hosts. This is the floor a config file cannot go under.
  2. Author a ladder file in the schema of §3, and load it with SlotRegistry::load_over(&baseline, ladder, DemotionPolicy::Refuse).
  3. Implement AppData in the application's own repository, next to the code that knows the schema. Declare every part in DumpReport; return None from watermark when you cannot answer.
  4. Route every destructive verb through Guard::assert* and pass the resulting Cleared down. The signatures will not let you skip it.
  5. Keep every application-specific fact behind AppData — the outbox table, the change column, the table list, the object prefixes, the release-named ConfigMap/Secret, the mail policy. DumpReport::app_meta is the opaque channel for anything that must ride in the manifest.
  6. Render charts where they are owned; hand promote ReleaseSource::Manifests.