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

nordisk/Skidbladnir

src/promote/generation.rs

//! **Generations** — a slot is a *name*, not a namespace.
//!
//! `test1` points at one of several generations: `test1-20260721-1432`,
//! `test1-20260715-0900`, … Raising a new generation, switching the host onto it
//! and putting the old one to sleep is the blue/green shape, and it is what makes
//! a rollback take seconds instead of a restore.
//!
//! # What generations change
//!
//! The usual question before a destructive operation is *"is the state we are
//! about to destroy already captured?"* With generations nothing is destroyed —
//! the previous generation stands complete and startable. The answer is yes by
//! construction, and there is exactly **one** point where data actually goes
//! away: when a sleeping generation is deleted. That, and only that, is where a
//! bundle has to be sealed first.
//!
//! # Atomic rollback is a switch, not a restore
//!
//! Point the host back at the previous namespace and scale it up. Seconds, and
//! lossless, because the old generation was never touched. Compare with
//! overwriting a slot in place, where a failure leaves it half-written and the
//! only way back is a restore — the path that is tested least and needed most.
//!
//! # Every generation is complete
//!
//! Its own database, its own bucket, its own credentials, its own secrets. **No
//! cross-links**, between generations or between slots. If two generations shared
//! a bucket, the new one would write into the old one's objects and the rollback
//! would be half a rollback: the database goes back, the documents do not.
//!
//! # The ordering is the contract
//!
//! [`switch_plan`] is a pure function, and its step order is the hard-won part:
//! **wake the target, then point the host at it, then drop the old route.** Any
//! other order has a window in which the host resolves to something that is not
//! serving. The plan is a value so that order can be asserted in a test without a
//! cluster.

use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};

/// One generation of a slot.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Generation {
    /// The slot's name — carries the identity.
    pub slot: String,
    /// The namespace — carries the generation.
    pub namespace: String,
}

/// The suffix a new generation gets: `YYYYMMDD-HHMM`.
///
/// Date **and** time: rebuilding a slot twice in one day is normal, and two
/// generations must not be able to collide on a name. The form also sorts
/// chronologically as a string, which turns "which is newest?" into a comparison
/// rather than a parse.
pub fn suffix(unix: u64) -> String {
    let (days, secs) = ((unix / 86_400) as i64, unix % 86_400);
    let (y, m, d) = civil_from_days(days);
    format!(
        "{y:04}{m:02}{d:02}-{:02}{:02}",
        secs / 3600,
        (secs % 3600) / 60
    )
}

/// Howard Hinnant's `civil_from_days` — days since the Unix epoch to `(y, m, d)`.
/// Inline rather than a date crate: this is the only calendar arithmetic in the
/// module and it must be exact for a name that becomes a namespace.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
    let z = z + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = (z - era * 146_097) as u64;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe as i64 + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
    (if m <= 2 { y + 1 } else { y }, m, d)
}

/// Is `s` a generation suffix — eight digits, a dash, four digits?
pub fn is_generation_suffix(s: &str) -> bool {
    let Some((d, t)) = s.split_once('-') else {
        return false;
    };
    d.len() == 8
        && t.len() == 4
        && d.chars().all(|c| c.is_ascii_digit())
        && t.chars().all(|c| c.is_ascii_digit())
}

impl Generation {
    /// Name a **new** generation of `slot` at `unix`.
    pub fn new(slot: &str, unix: u64) -> Result<Self> {
        let namespace = format!("{slot}-{}", suffix(unix));
        // Namespaces are DNS labels: 63 characters, lowercase, digits, dashes. A
        // slot name that blows the limit must be refused here, not by the API
        // server halfway through a provisioning run.
        if namespace.len() > 63 {
            bail!(
                "namespace '{namespace}' would be {} characters — the limit is 63",
                namespace.len()
            );
        }
        if !namespace
            .chars()
            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
        {
            bail!("namespace '{namespace}' is not a valid DNS label (a-z, 0-9, '-')");
        }
        Ok(Generation {
            slot: slot.to_string(),
            namespace,
        })
    }

    /// Is `namespace` a generation of `base` (a slot name or a slot namespace)?
    ///
    /// A prefix match alone is not enough: `test1-20260721-1432` is a generation
    /// of `test1`, but `test10-…` is not. The suffix must have generation shape,
    /// or the namespace counts as a slot of its own.
    pub fn is_of(base: &str, namespace: &str) -> bool {
        if base.is_empty() {
            return false;
        }
        let ns = namespace.trim().to_ascii_lowercase();
        let Some(rest) = ns.strip_prefix(&format!("{}-", base.to_ascii_lowercase())) else {
            return false;
        };
        is_generation_suffix(rest)
    }
}

/// The generations of `slot` among `namespaces`, **newest first**.
///
/// Names sort chronologically as strings — that is what the suffix format buys —
/// so ordering is a string comparison, not a parse.
pub fn generations(slot: &str, namespaces: &[String]) -> Vec<Generation> {
    let mut v: Vec<Generation> = namespaces
        .iter()
        .filter(|ns| Generation::is_of(slot, ns))
        .map(|ns| Generation {
            slot: slot.to_string(),
            namespace: ns.clone(),
        })
        .collect();
    v.sort_by(|a, b| b.namespace.cmp(&a.namespace));
    v
}

/// One step of a generation operation. A plan is a `Vec` of these; the executor
/// walks it in order.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", tag = "step")]
pub enum GenerationStep {
    /// Scale every workload in `namespace` to `replicas`.
    Scale { namespace: String, replicas: i32 },
    /// Wait until every workload in `namespace` reports `replicas` ready.
    WaitReplicas {
        namespace: String,
        replicas: i32,
        timeout_secs: u64,
    },
    /// Create/replace the route for `host` inside `namespace`.
    PointHost {
        host: String,
        namespace: String,
        service: String,
        port: i32,
        tls_secret: String,
    },
    /// Remove the route for `host` from `namespace` — the *old* one.
    DropHost { host: String, namespace: String },
    /// Nothing to do, and why. A plan that would be empty says so instead of
    /// looking like a plan that ran.
    NoOp { because: String },
}

impl GenerationStep {
    /// A one-line description for a dry run. No colour, no decoration — the
    /// caller decides how this is shown.
    pub fn describe(&self) -> String {
        match self {
            GenerationStep::Scale {
                namespace,
                replicas,
            } => {
                format!("scale every workload in '{namespace}' to {replicas}")
            }
            GenerationStep::WaitReplicas {
                namespace,
                replicas,
                timeout_secs,
            } => {
                format!("wait for '{namespace}' to report {replicas} ready (≤{timeout_secs}s)")
            }
            GenerationStep::PointHost {
                host,
                namespace,
                service,
                port,
                ..
            } => {
                format!("point {host} at {namespace}/{service}:{port}")
            }
            GenerationStep::DropHost { host, namespace } => {
                format!("drop the route for {host} from '{namespace}'")
            }
            GenerationStep::NoOp { because } => format!("nothing to do — {because}"),
        }
    }
}

/// How long a switch waits for the target generation's pods.
pub const WAKE_TIMEOUT_SECS: u64 = 180;

/// Put a generation to sleep: every workload to zero, **everything else kept**.
///
/// Volumes, buckets, secrets and credentials are untouched. That is the whole
/// point — a sleeping generation is not a backup, it is a paused environment, and
/// waking it is the rollback.
pub fn sleep_plan(namespace: &str) -> Vec<GenerationStep> {
    vec![GenerationStep::Scale {
        namespace: namespace.to_string(),
        replicas: 0,
    }]
}

/// Wake a sleeping generation.
pub fn wake_plan(namespace: &str) -> Vec<GenerationStep> {
    vec![
        GenerationStep::Scale {
            namespace: namespace.to_string(),
            replicas: 1,
        },
        GenerationStep::WaitReplicas {
            namespace: namespace.to_string(),
            replicas: 1,
            timeout_secs: WAKE_TIMEOUT_SECS,
        },
    ]
}

/// **Switch a host onto a generation** — the atomic rollback, in the one order
/// that has no gap.
///
/// 1. **Wake the target** if it is asleep. A route pointing at a scaled-down
///    generation serves errors until its pods are up; switching first and waking
///    second opens a window in which the site is down.
/// 2. **Create the route in the target's namespace.** Both generations now answer
///    for a moment; the ingress controller picks one, and both are correct
///    answers for that host. A route object is namespace-local and can only point
///    at services in its own namespace, so this step cannot misdirect traffic.
/// 3. **Remove the route from the old namespace.** Exactly one generation owns the
///    host again.
///
/// The old generation is **not** put to sleep here. Switching back must be as
/// fast as switching over, and a generation that is still running is seconds from
/// being live again. Sleeping is a separate, later decision.
///
/// `from` is the namespace that currently owns the host, if any — the caller
/// reads it off the cluster and passes it in, so this function stays pure.
pub fn switch_plan(
    slot: &str,
    from: Option<&str>,
    to_namespace: &str,
    host: &str,
    service: &str,
    port: i32,
    tls_secret: &str,
) -> Result<Vec<GenerationStep>> {
    if !Generation::is_of(slot, to_namespace) && slot != to_namespace {
        bail!(
            "'{to_namespace}' is not a generation of '{slot}' — refusing to point {host} at a \
             namespace that does not belong to the slot"
        );
    }
    if from == Some(to_namespace) {
        return Ok(vec![GenerationStep::NoOp {
            because: format!("'{to_namespace}' already owns {host}"),
        }]);
    }
    let mut plan = wake_plan(to_namespace);
    plan.push(GenerationStep::PointHost {
        host: host.to_string(),
        namespace: to_namespace.to_string(),
        service: service.to_string(),
        port,
        tls_secret: tls_secret.to_string(),
    });
    if let Some(f) = from {
        plan.push(GenerationStep::DropHost {
            host: host.to_string(),
            namespace: f.to_string(),
        });
    }
    Ok(plan)
}

/// The rungs of a **raise** — bringing a whole new generation into existence.
///
/// The ship names the ladder and its order; the caller climbs it. Each phase is
/// something only the consumer can do (its own object store, its own secret
/// store, its own chart), so encoding *what* each does here would be encoding an
/// application into the ship. What is generic — and what has actually gone wrong
/// in practice — is the **order**, and the reasons are on the variants.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RaisePhase {
    /// Permissions first, before anything is created. A raise that would fail on
    /// authorisation should fail while it has only *read*.
    Rbac,
    /// The namespace. Everything after this is namespaced.
    Namespace,
    /// TLS. As early as it possibly can be — later than the namespace (the
    /// secret is namespaced) but before storage, secrets, the release and the
    /// data. It is a step that **can refuse**, and a step that can refuse belongs
    /// where refusing is cheapest: here it costs an empty namespace, whereas after
    /// the data load it costs the operator every minute of that load. It must
    /// also never come after [`RaisePhase::SwitchHost`] — the route names this
    /// secret, and if it did not exist the site would fail its handshake in the
    /// exact second the old generation let go of the host.
    Tls,
    /// The generation's **own** storage. Never shared with the previous
    /// generation: a shared bucket makes the rollback half a rollback.
    Storage,
    /// The generation's **own** secrets, seeded from the target slot's previous
    /// generation — never from the source of the data.
    Secrets,
    /// Install/upgrade the release into the new namespace.
    Release,
    /// Load the application's data ([`AppData::load`](super::AppData::load)).
    Data,
    /// **The commit point.** Everything before this is invisible to users;
    /// everything after is live. A failure before it costs a namespace nobody saw;
    /// a failure after it is an outage.
    SwitchHost,
}

/// The raise ladder, in order. Iterate it; do not reorder it.
pub const RAISE_LADDER: &[RaisePhase] = &[
    RaisePhase::Rbac,
    RaisePhase::Namespace,
    RaisePhase::Tls,
    RaisePhase::Storage,
    RaisePhase::Secrets,
    RaisePhase::Release,
    RaisePhase::Data,
    RaisePhase::SwitchHost,
];

impl RaisePhase {
    /// Is this phase past the point of no return?
    pub fn is_after_commit(self) -> bool {
        self >= RaisePhase::SwitchHost
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_suffix_is_date_and_time_and_sorts_chronologically() {
        // 2026-07-21T14:32:00Z
        let a = suffix(1_784_644_320);
        assert_eq!(a, "20260721-1432");
        let b = suffix(1_784_644_320 + 3600);
        assert!(b > a, "later must sort later: {a} vs {b}");
        assert!(is_generation_suffix(&a) && is_generation_suffix(&b));
    }

    #[test]
    fn a_generation_namespace_is_a_valid_dns_label_or_it_is_refused() {
        assert_eq!(
            Generation::new("test1", 1_784_644_320).unwrap().namespace,
            "test1-20260721-1432"
        );
        assert!(
            Generation::new("Test1", 1_784_644_320).is_err(),
            "uppercase is not a DNS label"
        );
        assert!(
            Generation::new(&"x".repeat(60), 1_784_644_320).is_err(),
            "63-char limit"
        );
    }

    #[test]
    fn prefix_alone_does_not_make_a_generation() {
        assert!(Generation::is_of("test1", "test1-20260721-1432"));
        assert!(
            !Generation::is_of("test1", "test10-20260721-1432"),
            "test10 is a different slot"
        );
        assert!(
            !Generation::is_of("test1", "test1-something"),
            "the suffix must have the shape"
        );
        assert!(
            !Generation::is_of("test1", "test1"),
            "the slot itself is not a generation of itself"
        );
        assert!(!Generation::is_of("", "-20260721-1432"));
    }

    #[test]
    fn generations_come_back_newest_first() {
        let ns: Vec<String> = [
            "test1-20260715-0900",
            "other",
            "test1-20260721-1432",
            "test10-20260722-0000",
        ]
        .iter()
        .map(|s| s.to_string())
        .collect();
        let g = generations("test1", &ns);
        assert_eq!(
            g.iter().map(|g| g.namespace.as_str()).collect::<Vec<_>>(),
            ["test1-20260721-1432", "test1-20260715-0900"]
        );
    }

    /// The order is the contract: wake, then route, then drop the old route.
    #[test]
    fn a_switch_wakes_before_it_routes_and_drops_the_old_route_last() {
        let p = switch_plan(
            "test1",
            Some("test1-20260715-0900"),
            "test1-20260721-1432",
            "test1.example.test",
            "test1-20260721-1432",
            3000,
            "test1-tls",
        )
        .unwrap();
        let i = |pred: fn(&GenerationStep) -> bool| p.iter().position(pred).expect("step present");
        let wake = i(|s| matches!(s, GenerationStep::Scale { replicas: 1, .. }));
        let ready = i(|s| matches!(s, GenerationStep::WaitReplicas { .. }));
        let point = i(|s| matches!(s, GenerationStep::PointHost { .. }));
        let drop = i(|s| matches!(s, GenerationStep::DropHost { .. }));
        assert!(wake < ready, "scale up before waiting");
        assert!(
            ready < point,
            "the host must not be pointed at pods that are not up — that is the 503 window"
        );
        assert!(
            point < drop,
            "the new route must exist before the old one is removed"
        );
        assert_eq!(drop, p.len() - 1);
    }

    /// The old generation keeps running. Switching back must be as fast as
    /// switching over.
    #[test]
    fn a_switch_does_not_put_the_previous_generation_to_sleep() {
        let p = switch_plan(
            "t",
            Some("t-20260715-0900"),
            "t-20260721-1432",
            "h",
            "svc",
            80,
            "tls",
        )
        .unwrap();
        assert!(
            !p.iter()
                .any(|s| matches!(s, GenerationStep::Scale { replicas: 0, .. })),
            "sleeping is a separate, later decision: {p:?}"
        );
    }

    #[test]
    fn switching_to_a_namespace_that_is_not_the_slots_is_refused() {
        let err = switch_plan("test1", None, "test2-20260721-1432", "h", "svc", 80, "tls")
            .expect_err("cross-slot switch");
        assert!(
            format!("{err:#}").contains("not a generation of"),
            "{err:#}"
        );
    }

    #[test]
    fn switching_to_the_current_owner_is_an_explicit_no_op_not_an_empty_plan() {
        let p = switch_plan(
            "t",
            Some("t-20260721-1432"),
            "t-20260721-1432",
            "h",
            "svc",
            80,
            "tls",
        )
        .unwrap();
        assert!(
            matches!(p.as_slice(), [GenerationStep::NoOp { .. }]),
            "{p:?}"
        );
        assert!(p[0].describe().contains("already owns"));
    }

    #[test]
    fn sleeping_scales_to_zero_and_touches_nothing_else() {
        let p = sleep_plan("t-20260721-1432");
        assert_eq!(
            p,
            vec![GenerationStep::Scale {
                namespace: "t-20260721-1432".into(),
                replicas: 0
            }]
        );
    }

    #[test]
    fn the_raise_ladder_puts_tls_early_and_the_host_switch_last() {
        let pos = |p: RaisePhase| RAISE_LADDER.iter().position(|x| *x == p).unwrap();
        assert!(pos(RaisePhase::Rbac) < pos(RaisePhase::Namespace));
        assert!(pos(RaisePhase::Namespace) < pos(RaisePhase::Tls));
        assert!(
            pos(RaisePhase::Tls) < pos(RaisePhase::Release),
            "a refusal must be cheap"
        );
        assert!(pos(RaisePhase::Tls) < pos(RaisePhase::Data));
        assert_eq!(
            pos(RaisePhase::SwitchHost),
            RAISE_LADDER.len() - 1,
            "the commit point is last"
        );
        assert!(RaisePhase::SwitchHost.is_after_commit());
        assert!(!RaisePhase::Data.is_after_commit());
    }

    #[test]
    fn the_plan_serialises_so_a_viewer_or_an_rpc_can_carry_it() {
        let p = sleep_plan("t-20260721-1432");
        let back: Vec<GenerationStep> =
            serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
        assert_eq!(p, back);
    }
}