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);
}
}