From 04baad3080246b1ce26df7a783874b2b1b3ddaa4 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Sat, 9 Mar 2024 15:33:27 -0500 Subject: [PATCH] Add a `rollback` verb and `rollbackQueued` status I'd really hoped to do something more declarative here, and really flesh out the intersections with automated upgrades and automated rollbacks. But, this just exposes the simple primitive, equivalent to `rpm-ostree rollback`. Signed-off-by: Colin Walters --- lib/src/cli.rs | 33 ++++++++++++ lib/src/deploy.rs | 61 ++++++++++++++++++++++- lib/src/spec.rs | 40 +++++++++++++++ lib/src/status.rs | 15 +++++- tests/integration/playbooks/rollback.yaml | 4 +- 5 files changed, 148 insertions(+), 5 deletions(-) diff --git a/lib/src/cli.rs b/lib/src/cli.rs index 42bfbcc..623796a 100644 --- a/lib/src/cli.rs +++ b/lib/src/cli.rs @@ -89,6 +89,10 @@ pub(crate) struct SwitchOpts { pub(crate) target: String, } +/// Options controlling rollback +#[derive(Debug, Parser, PartialEq, Eq)] +pub(crate) struct RollbackOpts {} + /// Perform an edit operation #[derive(Debug, Parser, PartialEq, Eq)] pub(crate) struct EditOpts { @@ -214,6 +218,18 @@ pub(crate) enum Opt { /// This operates in a very similar fashion to `upgrade`, but changes the container image reference /// instead. Switch(SwitchOpts), + /// Change the bootloader entry ordering; the deployment under `rollback` will be queued for the next boot, + /// and the current will become rollback. If there is a `staged` entry (an unapplied, queued upgrade) + /// then it will be discarded. + /// + /// Note that absent any additional control logic, if there is an active agent doing automated upgrades + /// (such as the default `bootc-fetch-apply-updates.timer` and associated `.service`) the + /// change here may be reverted. It's recommended to only use this in concert with an agent that + /// is in active control. + /// + /// A systemd journal message will be logged with `MESSAGE_ID=26f3b1eb24464d12aa5e7b544a6b5468` in + /// order to detect a rollback invocation. + Rollback(RollbackOpts), /// Apply full changes to the host specification. /// /// This command operates very similarly to `kubectl apply`; if invoked interactively, @@ -500,6 +516,14 @@ async fn switch(opts: SwitchOpts) -> Result<()> { Ok(()) } +/// Implementation of the `bootc rollback` CLI command. +#[context("Rollback")] +async fn rollback(_opts: RollbackOpts) -> Result<()> { + prepare_for_write().await?; + let sysroot = &get_locked_sysroot().await?; + crate::deploy::rollback(sysroot).await +} + /// Implementation of the `bootc edit` CLI command. #[context("Editing spec")] async fn edit(opts: EditOpts) -> Result<()> { @@ -522,7 +546,15 @@ async fn edit(opts: EditOpts) -> Result<()> { println!("Edit cancelled, no changes made."); return Ok(()); } + host.spec.verify_transition(&new_host.spec)?; let new_spec = RequiredHostSpec::from_spec(&new_host.spec)?; + + // We only support two state transitions right now; switching the image, + // or flipping the bootloader ordering. + if host.spec.boot_order != new_host.spec.boot_order { + return crate::deploy::rollback(sysroot).await; + } + let fetched = crate::deploy::pull(sysroot, new_spec.image, opts.quiet).await?; // TODO gc old layers here @@ -586,6 +618,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> { match opt { Opt::Upgrade(opts) => upgrade(opts).await, Opt::Switch(opts) => switch(opts).await, + Opt::Rollback(opts) => rollback(opts).await, Opt::Edit(opts) => edit(opts).await, Opt::UsrOverlay => usroverlay().await, #[cfg(feature = "install")] diff --git a/lib/src/deploy.rs b/lib/src/deploy.rs index 444ad8e..3eb31a8 100644 --- a/lib/src/deploy.rs +++ b/lib/src/deploy.rs @@ -5,7 +5,7 @@ use std::io::{BufRead, Write}; use anyhow::Ok; -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use cap_std::fs::{Dir, MetadataExt}; use cap_std_ext::cap_std; @@ -19,8 +19,8 @@ use ostree_ext::ostree; use ostree_ext::ostree::Deployment; use ostree_ext::sysroot::SysrootLock; -use crate::spec::HostSpec; use crate::spec::ImageReference; +use crate::spec::{BootOrder, HostSpec}; use crate::status::labels_of_config; // TODO use https://github.com/ostreedev/ostree-rs-ext/pull/493/commits/afc1837ff383681b947de30c0cefc70080a4f87a @@ -276,6 +276,63 @@ pub(crate) async fn stage( Ok(()) } +/// Implementation of rollback functionality +pub(crate) async fn rollback(sysroot: &SysrootLock) -> Result<()> { + const ROLLBACK_JOURNAL_ID: &str = "26f3b1eb24464d12aa5e7b544a6b5468"; + let repo = &sysroot.repo(); + let (booted_deployment, deployments, host) = crate::status::get_status_require_booted(sysroot)?; + + let new_spec = { + let mut new_spec = host.spec.clone(); + new_spec.boot_order = new_spec.boot_order.swap(); + new_spec + }; + + // Just to be sure + host.spec.verify_transition(&new_spec)?; + + let reverting = new_spec.boot_order == BootOrder::Default; + if reverting { + println!("notice: Reverting queued rollback state"); + } + let rollback_status = host + .status + .rollback + .ok_or_else(|| anyhow!("No rollback available"))?; + let rollback_image = rollback_status + .query_image(repo)? + .ok_or_else(|| anyhow!("Rollback is not container image based"))?; + let msg = format!("Rolling back to image: {}", rollback_image.manifest_digest); + libsystemd::logging::journal_send( + libsystemd::logging::Priority::Info, + &msg, + [ + ("MESSAGE_ID", ROLLBACK_JOURNAL_ID), + ("BOOTC_MANIFEST_DIGEST", &rollback_image.manifest_digest), + ] + .into_iter(), + )?; + // SAFETY: If there's a rollback status, then there's a deployment + let rollback_deployment = deployments.rollback.expect("rollback deployment"); + let new_deployments = if reverting { + [booted_deployment, rollback_deployment] + } else { + [rollback_deployment, booted_deployment] + }; + let new_deployments = new_deployments + .into_iter() + .chain(deployments.other) + .collect::>(); + tracing::debug!("Writing new deployments: {new_deployments:?}"); + sysroot.write_deployments(&new_deployments, gio::Cancellable::NONE)?; + if reverting { + println!("Next boot: current deployment"); + } else { + println!("Next boot: rollback deployment"); + } + Ok(()) +} + fn find_newest_deployment_name(deploysdir: &Dir) -> Result { let mut dirs = Vec::new(); for ent in deploysdir.entries()? { diff --git a/lib/src/spec.rs b/lib/src/spec.rs index 6de9639..5f6df93 100644 --- a/lib/src/spec.rs +++ b/lib/src/spec.rs @@ -28,12 +28,27 @@ pub struct Host { pub status: HostStatus, } +/// Configuration for system boot ordering. + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum BootOrder { + /// The staged or booted deployment will be booted next + #[default] + Default, + /// The rollback deployment will be booted next + Rollback, +} + #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] /// The host specification pub struct HostSpec { /// The host image pub image: Option, + /// If set, and there is a rollback deployment, it will be set for the next boot. + #[serde(default)] + pub boot_order: BootOrder, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -121,6 +136,9 @@ pub struct HostStatus { pub booted: Option, /// The previously booted image pub rollback: Option, + /// Set to true if the rollback entry is queued for the next boot. + #[serde(default)] + pub rollback_queued: bool, /// The detected type of system #[serde(rename = "type")] @@ -152,6 +170,28 @@ impl Default for Host { } } +impl HostSpec { + /// Validate a spec state transition; some changes cannot be made simultaneously, + /// such as fetching a new image and doing a rollback. + pub(crate) fn verify_transition(&self, new: &Self) -> anyhow::Result<()> { + let rollback = self.boot_order != new.boot_order; + let image_change = self.image != new.image; + if rollback && image_change { + anyhow::bail!("Invalid state transition: rollback and image change"); + } + Ok(()) + } +} + +impl BootOrder { + pub(crate) fn swap(&self) -> Self { + match self { + BootOrder::Default => BootOrder::Rollback, + BootOrder::Rollback => BootOrder::Default, + } + } +} + impl Display for ImageReference { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { // For the default of fetching from a remote registry, just output the image name diff --git a/lib/src/status.rs b/lib/src/status.rs index dba4889..e8f1fa5 100644 --- a/lib/src/status.rs +++ b/lib/src/status.rs @@ -1,6 +1,6 @@ use std::collections::VecDeque; -use crate::spec::{BootEntry, Host, HostSpec, HostStatus, HostType, ImageStatus}; +use crate::spec::{BootEntry, BootOrder, Host, HostSpec, HostStatus, HostType, ImageStatus}; use crate::spec::{ImageReference, ImageSignature}; use anyhow::{Context, Result}; use camino::Utf8Path; @@ -224,11 +224,22 @@ pub(crate) fn get_status( .iter() .position(|d| d.is_staged()) .map(|i| related_deployments.remove(i).unwrap()); + tracing::debug!("Staged: {staged:?}"); // Filter out the booted, the caller already found that if let Some(booted) = booted_deployment.as_ref() { related_deployments.retain(|f| !f.equal(booted)); } let rollback = related_deployments.pop_front(); + let rollback_queued = match (booted_deployment.as_ref(), rollback.as_ref()) { + (Some(booted), Some(rollback)) => rollback.index() < booted.index(), + _ => false, + }; + let boot_order = if rollback_queued { + BootOrder::Rollback + } else { + BootOrder::Default + }; + tracing::debug!("Rollback queued={rollback_queued:?}"); let other = { related_deployments.extend(other_deployments); related_deployments @@ -262,6 +273,7 @@ pub(crate) fn get_status( .and_then(|entry| entry.image.as_ref()) .map(|img| HostSpec { image: Some(img.image.clone()), + boot_order, }) .unwrap_or_default(); @@ -281,6 +293,7 @@ pub(crate) fn get_status( staged, booted, rollback, + rollback_queued, ty, }; Ok((deployments, host)) diff --git a/tests/integration/playbooks/rollback.yaml b/tests/integration/playbooks/rollback.yaml index a801656..e193ff5 100644 --- a/tests/integration/playbooks/rollback.yaml +++ b/tests/integration/playbooks/rollback.yaml @@ -6,8 +6,8 @@ failed_counter: "0" tasks: - - name: rpm-ostree rollback - command: rpm-ostree rollback + - name: bootc rollback + command: bootc rollback become: true - name: Reboot to deploy new system -- 2.43.0