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

nordisk/Skidbladnir

src/promote/mod.rs

//! **PROMOTE** — moving an application between the slots it already lives in
//! (feature `promote`). Design: `.nornir/promote-design.md`; guide:
//! `.nornir/promote-guide.md`; walkthrough: `.nornir/promote-for-idiots.md`.
//!
//! The rest of Skíðblaðnir is **birth**: `pack → archive → fetch ║airgap║ →
//! unfold → install → start`. This module is **ongoing operations** on something
//! already alive: roll a new image into a test rung, copy production's data down
//! to it, raise a fresh generation and switch the host onto it — and never, ever,
//! do any of that to production by accident.
//!
//! See [the charter note](#is-this-the-ships-job) at the bottom: whether
//! promotion belongs to this ship is an open question, recorded here rather than
//! silently answered by the code shape.
//!
//! # What this module knows, and what it refuses to know
//!
//! It knows **sites, ladders, namespaces, hosts, generations, releases, images,
//! bundles**. It does **not** know what the application's data *is*. Every
//! payload question — what to dump, what to load, what a watermark means, what
//! must be neutralised after a cross-slot copy — goes through the [`AppData`] seam
//! and is answered by the application's own code. The ship orchestrates; the app
//! knows.
//!
//! # The pieces
//!
//! | piece | module | what it owns |
//! |---|---|---|
//! | the ladder **schema** | [`schema`] | the public, versioned config format: sites, roles, guard, promotion edges, `meta` |
//! | slot registry | [`registry`] | resolving an identifier to a site; the un-lowerable fence |
//! | the prod guard | [`guard`] | the `(namespace, host)` fence, the two-key override, the [`Cleared`] token |
//! | generations | [`generation`] | blue/green `raise` · `sleep` · `wake` · `switch` |
//! | release ops | [`release`] | set-image → wait rollout → roll back; kube-rs, never a shell |
//! | the app seam | [`appdata`] | [`AppData`], and report types that make a partial capture impossible to hide |
//! | bundle envelope | [`envelope`] | seal · unseal · verify · manifest, payload-agnostic |
//! | the view | [`topology`] | the whole ladder as serde data, for a panel or an RPC |
//!
//! # Five rules the whole module is built around
//!
//! **1. The ladder is data.** No rung name — no `lab`, no `test1`, no `prod` —
//! appears in any type, variant or match arm here. A consumer authors a
//! [`Ladder`] and the promoter reads it. See [`schema::EXAMPLE_LADDER`].
//!
//! **2. Roles are declared, never inferred.** Nothing concludes "this is
//! production because it is called prod", and nothing concludes "this is safe
//! because it is a subdomain". [`Slot::role`] and [`Slot::guarded`] are required
//! fields; a site that declares neither is a parse error, not a test slot.
//!
//! **3. A config file can raise the fence, never lower it.** The classic hole is
//! a guard that returns "allowed" the moment the target is *not* marked
//! production — because then deleting the production entry from a config file
//! does not lower the guard, it **deletes** it. [`SlotRegistry::load_over`] makes
//! a demotion structurally impossible: a site the caller declared guarded stays
//! guarded whatever the config says, and the attempt is either refused outright
//! or carried in [`SlotRegistry::demotions`] and printed in every refusal — and
//! in the topology a panel renders.
//!
//! **4. There is no raw string that means "somewhere".** The guard never takes a
//! bare namespace; it takes a [`TargetRef`], and the registry resolves it across
//! **every** identity a site has — name, namespace, each host, and any generation
//! namespace derived from either. That is what stops `--slot prod` from sailing
//! past a fence keyed only on namespaces. An identifier that resolves to nothing
//! is **fenced**, not allowed: fail closed.
//!
//! **5. No silent partial success, and never a guess.** [`DumpReport`] starts out
//! [`Completeness::Unknown`] and only becomes [`Completeness::Complete`] when
//! every declared part reports [`CaptureState::Complete`]; an empty report is
//! Unknown, never Complete. And when the app cannot answer [`AppData::watermark`],
//! that is `None` = **unknown**, which [`Watermark::would_discard`] treats as *may
//! discard newer data* — never as "there is nothing newer".
//!
//! # Dry run is a pure function, not a flag
//!
//! Every planning entry point here (`*_plan`, `describe`) is **pure**: no cluster
//! call, no subprocess, no filesystem read. A dry run is "print the plan and
//! stop", so there is no code path on which a preview can touch the world. (The
//! tool this generalises had a `--dry-run` that still spawned `helm version`;
//! that class of bug cannot be expressed in this shape.)
//!
//! # Async without a runtime dependency
//!
//! [`AppData`] and [`ReleaseOps`] are async — the real implementations talk to a
//! database, an object store and the Kubernetes API — but the `promote` core
//! pulls **no** async runtime. The traits return [`Fut`], a plain
//! `Pin<Box<dyn Future>>` from `std`, so they are object-safe, runtime-agnostic
//! and compile in the lean build. Only `promote-k8s` (which implies `kubernetes`)
//! links `kube`/`tokio`.
//!
//! # The viewer contract
//!
//! [`SlotRegistry::topology`] returns the whole ladder as serde-serializable data
//! — nodes, edges, computed ranks, declared roles, guard state, consumer `meta`,
//! and a per-rung [`Observation`](topology::Observation). A panel needs nothing
//! else, and never parses a config file itself. **No UI dependency enters this
//! crate**: the intended viewer is a `facett-pipeline` adapter living in the
//! consumer's own UI crate. See `.nornir/promote-design.md` §The viewer contract.
//!
//! # Is this the ship's job?
//!
//! **Open question, deliberately not answered by this code.** Skíðblaðnir's
//! charter to date is *bringing things into existence*; promotion is *keeping
//! things in sync*. They share the bundle, the zero-shell law and the airgap
//! posture, but they are not the same verb. `promote` is built as a strictly
//! additive, default-off tenant: nothing outside `#[cfg(feature = "promote")]`
//! changes, so if the answer is "this belongs elsewhere", the module lifts out
//! whole. See `.nornir/promote-design.md` §Charter.

/// A boxed, object-safe future — the async seam without an async runtime.
///
/// `async fn` in a trait is not object-safe, and the ship needs `&dyn AppData`
/// (the application supplies the implementation; the ship holds it behind a
/// reference). Boxing the future by hand is the standard way out, and it costs
/// **no dependency**: `Pin`, `Box` and `Future` are all `std`. A synchronous
/// implementation returns `Box::pin(async move { … })` and never awaits anything.
pub type Fut<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;

pub mod appdata;
pub mod envelope;
pub mod generation;
pub mod guard;
pub mod registry;
pub mod release;
pub mod schema;
pub mod topology;

pub use appdata::{
    AppData, CaptureState, CapturedPart, Completeness, Discard, DumpReport, LoadReport, Quiesced,
    Watermark,
};
pub use envelope::{
    LoadPolicy, MANIFEST_SUFFIX, MANIFEST_VERSION, PromotionManifest, SealSpec, Sealed, Unsealed,
    VerifyReport, load_manifest, manifest_path, seal, unseal, verify,
};
pub use generation::{
    Generation, GenerationStep, RAISE_LADDER, RaisePhase, generations, is_generation_suffix,
    sleep_plan, switch_plan, wake_plan,
};
pub use guard::{
    Cleared, DEFAULT_OVERRIDE_ENV, EnvSource, Guard, Override, Reason, Refusal, UnknownPolicy,
    Verdict,
};
pub use registry::{
    Demotion, DemotionPolicy, Resolution, SlotRegistry, TargetRef, host_of, redact,
};
pub use release::{
    ChartRef, ReleaseOps, ReleaseRef, ReleaseSource, RolloutOutcome, RolloutPlan, rollout,
    run_generation_plan,
};
pub use schema::{API_VERSION, EXAMPLE_LADDER, Hop, Ladder, Slot, SlotRole};
pub use topology::{Observation, Observations, RungState, Topology, TopologyEdge, TopologyNode};

#[cfg(feature = "promote-k8s")]
pub use release::kube_ops::{KubeReleaseOps, stored_release_values};

/// An RFC3339 UTC timestamp for [`SealSpec::taken_at`] and friends.
///
/// Offered as a helper, never called implicitly: every function that stamps a
/// bundle takes the stamp as an argument, so the whole envelope path stays pure
/// and reproducible in a test.
pub fn now_rfc3339() -> String {
    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}