src/service_backend.rs
//! `ServiceBackend` — the unified refresh verb across substrates (the first slice).
//!
//! The [`servicebackend-design`] doc calls for one verb set
//! (install/upgrade/start/stop/status/uninstall) sitting **above** the per-substrate
//! backends, so a product drives systemd and Kubernetes through the same calls. That
//! full unification is sequenced behind the uninstall-law + lifecycle-audit work and
//! is not landed yet.
//!
//! This module lands the **refresh** verb of that seam — the one place both
//! substrates already have a complete, rollback-safe engine, so the parity is real
//! rather than aspirational:
//!
//! | substrate | this trait's `refresh` delegates to | which drives |
//! |---|---|---|
//! | systemd | [`crate::service::Lifecycle::refresh`] | [`crate::install::refresh_product`] |
//! | kubernetes | [`crate::kube_runtime::kube_refresh`] | the same engine [`KubeRuntime::refresh`] wraps |
//!
//! **The payloads differ, and honestly so** — exactly as the design doc notes
//! ("systemd takes a signed bundle path … kubernetes takes an image ref"). systemd's
//! refresh needs a signed bundle + layout + trust root (a
//! [`RefreshRequest`](crate::install::RefreshRequest)); kubernetes needs an image ref
//! + migration chain (a [`RefreshPlan`](crate::kube_runtime::RefreshPlan)). So the
//! seam is a trait with an associated `Request`/`Outcome` per backend, not a single
//! flattened signature. Forcing both into one concrete signature is the "cascade" the
//! design doc warns about and is deferred to the full `ServiceBackend` landing.
//!
//! # Why the kube impl is over the ops seam, not the concrete `KubeRuntime`
//!
//! [`KubeRuntime`](crate::kube_runtime::live::KubeRuntime) can only be constructed
//! against a live apiserver, so a blanket impl over the
//! [`KubeRefreshOps`](crate::kube_runtime::KubeRefreshOps) seam gives two things at
//! once: the production [`KubeRuntime`] gets `ServiceRefresh` for free (its body is
//! the exact `kube_refresh(self, &plan)` dispatch [`KubeRuntime::refresh`] already
//! runs), and the offline scripted mock the `kube_runtime` tests use gets it too — so
//! the dispatch is proven with no cluster, the same way the engine itself is tested.
//!
//! [`servicebackend-design`]: https://example.invalid/servicebackend-design
//! [`KubeRuntime::refresh`]: crate::kube_runtime::live::KubeRuntime::refresh
//! [`KubeRuntime`]: crate::kube_runtime::live::KubeRuntime
use anyhow::Result;
/// One `refresh` verb, two substrates. A product that drives this seam refreshes a
/// systemd service and a Kubernetes service through the SAME trait method, each
/// delegating to its existing rollback-safe engine — never a second, worse
/// reimplementation.
///
/// The inputs and outcome are per-substrate associated types (a signed bundle vs an
/// image ref; a systemd [`RefreshOutcome`](crate::install::RefreshOutcome) vs a
/// [`KubeRefreshOutcome`](crate::kube_runtime::KubeRefreshOutcome), each of which
/// keeps its failure shapes apart). Unifying those payloads into one flattened
/// signature is deferred to the full [`ServiceBackend`](self) landing; this trait is
/// the honest first slice.
pub trait ServiceRefresh {
/// The substrate-specific inputs a refresh needs — borrowed for the call.
type Request<'a>;
/// The substrate-specific outcome (each keeps completed / migration-failed /
/// rolled-back / rollback-failed apart, never collapsed into one bool).
type Outcome;
/// Promote the service to the version described by `req`, migrating forward and
/// rolling back on failure. Delegates to the substrate's engine.
fn refresh(&self, req: Self::Request<'_>) -> Result<Self::Outcome>;
}
// ─────────────────────────────── systemd ───────────────────────────────
/// The systemd backend's refresh: [`crate::install::refresh_product`] — verify-on-receipt → versioned unfold →
/// stamped migration chain → atomic `current` flip → D-Bus restart → health probe →
/// rollback. Unix-only, exactly like the inherent method it forwards to.
#[cfg(unix)]
impl ServiceRefresh for crate::service::Lifecycle {
type Request<'a> = &'a crate::install::RefreshRequest<'a>;
type Outcome = crate::install::RefreshOutcome;
fn refresh(&self, req: Self::Request<'_>) -> Result<Self::Outcome> {
// The engine (lane B4: `Lifecycle` lives in skidbladnir-systemd now, and this
// impl is its refresh).
crate::install::refresh_product(req)
}
}
// ───────────────────────────── kubernetes ─────────────────────────────
/// The Kubernetes backend's refresh = [`kube_refresh`](crate::kube_runtime::kube_refresh),
/// the engine [`KubeRuntime::refresh`](crate::kube_runtime::live::KubeRuntime::refresh)
/// wraps: anchor → pre-upgrade migration Jobs (stamped → head) → SSA rolling update →
/// readiness gate → prior-revision rollback.
///
/// Implemented over the [`KubeRefreshOps`](crate::kube_runtime::KubeRefreshOps) seam
/// so the production `KubeRuntime` (which implements it) is wired automatically and
/// the offline scripted mock is too — the dispatch is testable with no cluster.
impl<T: crate::kube_runtime::KubeRefreshOps> ServiceRefresh for T {
type Request<'a> = &'a crate::kube_runtime::RefreshPlan<'a>;
type Outcome = crate::kube_runtime::KubeRefreshOutcome;
fn refresh(&self, req: Self::Request<'_>) -> Result<Self::Outcome> {
crate::kube_runtime::kube_refresh(self, req)
}
}
#[cfg(test)]
mod tests {
use super::ServiceRefresh;
/// A zero-cost stand-in for a kube backend: the blanket impl gives ANY
/// [`KubeRefreshOps`](crate::kube_runtime::KubeRefreshOps) implementor the unified
/// `refresh` verb, so the production `KubeRuntime` and this mock are wired the
/// same way.
struct MockOps;
impl crate::kube_runtime::KubeRefreshOps for MockOps {
fn read_stamp(&self) -> anyhow::Result<Option<String>> {
Ok(None)
}
fn write_stamp(&self, _schema: &str) -> anyhow::Result<()> {
Ok(())
}
fn current_image(&self) -> anyhow::Result<Option<String>> {
Ok(None)
}
fn run_migration_job(
&self,
_job: &serde_json::Value,
) -> anyhow::Result<crate::kube_runtime::MigrationJobOutcome> {
Ok(crate::kube_runtime::MigrationJobOutcome::Succeeded)
}
fn apply_deployment(&self, _deployment: &serde_json::Value) -> anyhow::Result<()> {
Ok(())
}
fn wait_rollout(&self, _timeout: std::time::Duration) -> anyhow::Result<()> {
Ok(())
}
}
/// **Parity, at the type level.** Both substrates present the SAME refresh verb
/// through the unified seam — the systemd backend
/// ([`Lifecycle`](crate::service::Lifecycle), unix) and the kubernetes backend
/// (any [`KubeRefreshOps`](crate::kube_runtime::KubeRefreshOps), of which the
/// production [`KubeRuntime`](crate::kube_runtime::live::KubeRuntime) is one). A
/// missing impl on either side fails to compile here. The runtime dispatch
/// behaviour is proven in `kube_runtime`'s `service_backend_refresh_*` tests.
#[test]
fn both_backends_implement_the_refresh_seam() {
fn assert_refresh<T: ServiceRefresh>() {}
#[cfg(unix)]
assert_refresh::<crate::service::Lifecycle>();
assert_refresh::<MockOps>();
}
}