diff --git a/lib/src/cli.rs b/lib/src/cli.rs index 42bfbcca..623796a4 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 444ad8e0..3eb31a8a 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 6de96390..5f6df932 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 dba4889f..e8f1fa58 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 a801656d..e193ff50 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