|
|
|
@ -0,0 +1,321 @@
|
|
|
|
|
From 04baad3080246b1ce26df7a783874b2b1b3ddaa4 Mon Sep 17 00:00:00 2001
|
|
|
|
|
From: Colin Walters <walters@verbum.org>
|
|
|
|
|
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 <walters@verbum.org>
|
|
|
|
|
---
|
|
|
|
|
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::<Vec<_>>();
|
|
|
|
|
+ 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<String> {
|
|
|
|
|
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<ImageReference>,
|
|
|
|
|
+ /// 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<BootEntry>,
|
|
|
|
|
/// The previously booted image
|
|
|
|
|
pub rollback: Option<BootEntry>,
|
|
|
|
|
+ /// 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
|
|
|
|
|
|