refactor!: Rename template to generate and move rebase/upgrade under switch (#116)

This updates the `template` subcommand to be `generate`. The `template`
usage will continue to work as an alias to `generate`. A new `switch`
command is added that will manage both `rpm-ostree rebase` and
`rpm-ostree upgrade` and is fully replacing the respective subcommands
as a breaking change.

The new `switch` command is under the feature flag `switch` and will
currently only build for the `main` branch builds until it is moved as a
default feature (`v0.9.0`).

Closes #159
This commit is contained in:
Gerald Pinder 2024-05-26 22:47:34 -04:00 committed by GitHub
parent 968cf3db97
commit 02b2fe5434
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 672 additions and 62 deletions

View file

@ -19,13 +19,25 @@ fn main() {
match args.command {
#[cfg(feature = "init")]
CommandArgs::Init(mut command) => command.run(),
#[cfg(feature = "init")]
CommandArgs::New(mut command) => command.run(),
CommandArgs::Build(mut command) => command.run(),
CommandArgs::Generate(mut command) => command.run(),
#[cfg(feature = "switch")]
CommandArgs::Switch(mut command) => command.run(),
#[cfg(not(feature = "switch"))]
CommandArgs::Rebase(mut command) => command.run(),
#[cfg(not(feature = "switch"))]
CommandArgs::Upgrade(mut command) => command.run(),
CommandArgs::Template(mut command) => command.run(),
CommandArgs::BugReport(mut command) => command.run(),
CommandArgs::Completions(mut command) => command.run(),
}
}

View file

@ -12,10 +12,13 @@ use crate::{
pub mod bug_report;
pub mod build;
pub mod completions;
pub mod generate;
#[cfg(feature = "init")]
pub mod init;
#[cfg(not(feature = "switch"))]
pub mod local;
pub mod template;
#[cfg(feature = "switch")]
pub mod switch;
pub trait BlueBuildCommand {
/// Runs the command and returns a result
@ -57,7 +60,8 @@ pub enum CommandArgs {
Build(build::BuildCommand),
/// Generate a Containerfile from a recipe
Template(template::TemplateCommand),
#[clap(visible_alias = "template")]
Generate(generate::GenerateCommand),
/// Upgrade your current OS with the
/// local image saved at `/etc/bluebuild/`.
@ -69,6 +73,7 @@ pub enum CommandArgs {
/// NOTE: This can only be used if you have `rpm-ostree`
/// installed. This image will not be signed.
#[command(visible_alias("update"))]
#[cfg(not(feature = "switch"))]
Upgrade(local::UpgradeCommand),
/// Rebase your current OS onto the image
@ -80,8 +85,21 @@ pub enum CommandArgs {
///
/// NOTE: This can only be used if you have `rpm-ostree`
/// installed. This image will not be signed.
#[cfg(not(feature = "switch"))]
Rebase(local::RebaseCommand),
/// Switch your current OS onto the image
/// being built.
///
/// This will create a tarball of your image at
/// `/etc/bluebuild/` and invoke `rpm-ostree` to
/// rebase/upgrade onto the image using `oci-archive`.
///
/// NOTE: This can only be used if you have `rpm-ostree`
/// installed. This image will not be signed.
#[cfg(feature = "switch")]
Switch(switch::SwitchCommand),
/// Initialize a new Ublue Starting Point repo
#[cfg(feature = "init")]
Init(init::InitCommand),

View file

@ -19,7 +19,7 @@ use log::{debug, info, trace, warn};
use typed_builder::TypedBuilder;
use crate::{
commands::template::TemplateCommand,
commands::generate::GenerateCommand,
credentials,
drivers::{
opts::{BuildTagPushOpts, CompressionType, GetMetadataOpts},
@ -120,6 +120,15 @@ impl BlueBuildCommand for BuildCommand {
.build()
.init()?;
if self.push && self.archive.is_some() {
bail!("You cannot use '--archive' and '--push' at the same time");
}
if self.push {
blue_build_utils::check_command_exists("cosign")?;
check_cosign_files()?;
}
// Check if the Containerfile exists
// - If doesn't => *Build*
// - If it does:
@ -172,10 +181,6 @@ impl BlueBuildCommand for BuildCommand {
}
}
if self.push && self.archive.is_some() {
bail!("You cannot use '--archive' and '--push' at the same time");
}
let recipe_path = self.recipe.clone().unwrap_or_else(|| {
let legacy_path = Path::new(CONFIG_PATH);
let recipe_path = Path::new(RECIPE_PATH);
@ -187,18 +192,12 @@ impl BlueBuildCommand for BuildCommand {
}
});
TemplateCommand::builder()
GenerateCommand::builder()
.recipe(&recipe_path)
.output(PathBuf::from("Containerfile"))
.drivers(DriverArgs::builder().squash(self.drivers.squash).build())
.build()
.try_run()?;
if self.push {
blue_build_utils::check_command_exists("cosign")?;
check_cosign_files()?;
}
info!("Building image for recipe at {}", recipe_path.display());
self.start(&recipe_path)

View file

@ -22,7 +22,7 @@ use crate::{drivers::Driver, shadow};
use super::{BlueBuildCommand, DriverArgs};
#[derive(Debug, Clone, Args, TypedBuilder)]
pub struct TemplateCommand {
pub struct GenerateCommand {
/// The recipe file to create a template from
#[arg()]
#[builder(default, setter(into, strip_option))]
@ -71,7 +71,7 @@ pub struct TemplateCommand {
drivers: DriverArgs,
}
impl BlueBuildCommand for TemplateCommand {
impl BlueBuildCommand for GenerateCommand {
fn try_run(&mut self) -> Result<()> {
Driver::builder()
.build_driver(self.drivers.build_driver)
@ -83,7 +83,7 @@ impl BlueBuildCommand for TemplateCommand {
}
}
impl TemplateCommand {
impl GenerateCommand {
fn template_file(&self) -> Result<()> {
trace!("TemplateCommand::template_file()");

223
src/commands/switch.rs Normal file
View file

@ -0,0 +1,223 @@
use std::{
path::{Path, PathBuf},
process::Command,
};
use anyhow::{bail, Result};
use blue_build_recipe::Recipe;
use blue_build_utils::constants::{
ARCHIVE_SUFFIX, LOCAL_BUILD, OCI_ARCHIVE, OSTREE_UNVERIFIED_IMAGE,
};
use clap::Args;
use colored::Colorize;
use log::{debug, trace, warn};
use tempdir::TempDir;
use typed_builder::TypedBuilder;
use crate::{commands::build::BuildCommand, drivers::Driver, rpm_ostree_status::RpmOstreeStatus};
use super::{BlueBuildCommand, DriverArgs};
#[derive(Default, Clone, Debug, TypedBuilder, Args)]
pub struct SwitchCommand {
/// The recipe file to build an image.
#[arg()]
recipe: PathBuf,
/// Reboot your system after
/// the update is complete.
#[arg(short, long)]
#[builder(default)]
reboot: bool,
/// Allow `bluebuild` to overwrite an existing
/// Containerfile without confirmation.
///
/// This is not needed if the Containerfile is in
/// .gitignore or has already been built by `bluebuild`.
#[arg(short, long)]
#[builder(default)]
force: bool,
#[clap(flatten)]
#[builder(default)]
drivers: DriverArgs,
}
impl BlueBuildCommand for SwitchCommand {
fn try_run(&mut self) -> Result<()> {
trace!("SwitchCommand::try_run()");
Driver::builder()
.build_driver(self.drivers.build_driver)
.inspect_driver(self.drivers.inspect_driver)
.build()
.init()?;
let status = RpmOstreeStatus::try_new()?;
trace!("{status:?}");
if status.transaction_in_progress() {
bail!("There is a transaction in progress. Please cancel it using `rpm-ostree cancel`");
}
let tempdir = TempDir::new("oci-archive")?;
trace!("{tempdir:?}");
BuildCommand::builder()
.recipe(self.recipe.clone())
.archive(tempdir.path())
.force(self.force)
.build()
.try_run()?;
let recipe = Recipe::parse(&self.recipe)?;
let image_file_name = format!(
"{}.{ARCHIVE_SUFFIX}",
recipe.name.to_lowercase().replace('/', "_")
);
let temp_file_path = tempdir.path().join(&image_file_name);
let archive_path = Path::new(LOCAL_BUILD).join(&image_file_name);
warn!(
"{notice}: {} {sudo} {}",
"The next few steps will require".yellow(),
"You may have to supply your password".yellow(),
notice = "NOTICE".bright_red().bold(),
sudo = "`sudo`.".italic().bright_red().bold(),
);
Self::sudo_clean_local_build_dir()?;
Self::sudo_move_archive(&temp_file_path, &archive_path)?;
// We drop the tempdir ahead of time so that the directory
// can be cleaned out.
drop(tempdir);
self.switch(&archive_path, &status)
}
}
impl SwitchCommand {
fn switch(&self, archive_path: &Path, status: &RpmOstreeStatus<'_>) -> Result<()> {
trace!(
"SwitchCommand::switch({}, {status:#?})",
archive_path.display()
);
let status = if status.is_booted_on_archive(archive_path)
|| status.is_staged_on_archive(archive_path)
{
let mut command = Command::new("rpm-ostree");
command.arg("upgrade");
if self.reboot {
command.arg("--reboot");
}
trace!(
"rpm-ostree upgrade {}",
self.reboot.then_some("--reboot").unwrap_or_default()
);
command
} else {
let image_ref = format!(
"{OSTREE_UNVERIFIED_IMAGE}:{OCI_ARCHIVE}:{path}",
path = archive_path.display()
);
let mut command = Command::new("rpm-ostree");
command.arg("rebase").arg(&image_ref);
if self.reboot {
command.arg("--reboot");
}
trace!(
"rpm-ostree rebase{} {image_ref}",
self.reboot.then_some(" --reboot").unwrap_or_default()
);
command
}
.status()?;
if !status.success() {
bail!("Failed to switch to new image!");
}
Ok(())
}
fn sudo_move_archive(from: &Path, to: &Path) -> Result<()> {
trace!(
"SwitchCommand::sudo_move_archive({}, {})",
from.display(),
to.display()
);
trace!("sudo mv {} {}", from.display(), to.display());
let status = Command::new("sudo").arg("mv").args([from, to]).status()?;
if !status.success() {
bail!(
"Failed to move archive from {from} to {to}",
from = from.display(),
to = to.display()
);
}
Ok(())
}
fn sudo_clean_local_build_dir() -> Result<()> {
trace!("SwitchCommand::clean_local_build_dir()");
let local_build_path = Path::new(LOCAL_BUILD);
if local_build_path.exists() {
debug!("Cleaning out build dir {LOCAL_BUILD}");
trace!("sudo ls {LOCAL_BUILD}");
let output = String::from_utf8(
Command::new("sudo")
.args(["ls", LOCAL_BUILD])
.output()?
.stdout,
)?;
trace!("{output}");
let files = output
.lines()
.filter(|line| line.ends_with(ARCHIVE_SUFFIX))
.map(|file| local_build_path.join(file).display().to_string())
.collect::<Vec<_>>();
if !files.is_empty() {
let files = files.join(" ");
trace!("sudo rm -f {files}");
let status = Command::new("sudo")
.args(["rm", "-f"])
.arg(files)
.status()?;
if !status.success() {
bail!("Failed to clean out archives in {LOCAL_BUILD}");
}
}
} else {
debug!(
"Creating build output dir at {}",
local_build_path.display()
);
let status = Command::new("sudo")
.args(["mkdir", "-p", LOCAL_BUILD])
.status()?;
if !status.success() {
bail!("Failed to create directory {LOCAL_BUILD}");
}
}
Ok(())
}
}

View file

@ -1,7 +1,7 @@
use std::process::Command;
use anyhow::{bail, Result};
use log::{info, trace};
use log::{error, info, trace};
use semver::Version;
use serde::Deserialize;
@ -34,7 +34,8 @@ impl DriverVersion for BuildahDriver {
.arg("--json")
.output()?;
let version_json: BuildahVersionJson = serde_json::from_slice(&output.stdout)?;
let version_json: BuildahVersionJson = serde_json::from_slice(&output.stdout)
.inspect_err(|e| error!("{e}: {}", String::from_utf8_lossy(&output.stdout)))?;
trace!("{version_json:#?}");
Ok(version_json.version)

View file

@ -210,7 +210,6 @@ impl BuildDriver for DockerDriver {
trace!("build --progress=plain --pull -f {CONTAINER_FILE}",);
command
.arg("build")
.arg("--progress=plain")
.arg("--pull")
.arg("-f")
.arg(CONTAINER_FILE);

View file

@ -2,7 +2,7 @@ use std::process::{Command, Stdio};
use anyhow::{bail, Result};
use blue_build_utils::constants::SKOPEO_IMAGE;
use log::{debug, info, trace};
use log::{debug, error, info, trace};
use semver::Version;
use serde::Deserialize;
@ -44,7 +44,8 @@ impl DriverVersion for PodmanDriver {
.arg("json")
.output()?;
let version_json: PodmanVersionJson = serde_json::from_slice(&output.stdout)?;
let version_json: PodmanVersionJson = serde_json::from_slice(&output.stdout)
.inspect_err(|e| error!("{e}: {}", String::from_utf8_lossy(&output.stdout)))?;
trace!("{version_json:#?}");
Ok(version_json.client.version)

View file

@ -8,3 +8,4 @@ pub mod commands;
pub mod credentials;
pub mod drivers;
pub mod image_metadata;
pub mod rpm_ostree_status;

247
src/rpm_ostree_status.rs Normal file
View file

@ -0,0 +1,247 @@
use std::{borrow::Cow, path::Path, process::Command};
use anyhow::{bail, Result};
use log::trace;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct RpmOstreeStatus<'a> {
deployments: Cow<'a, [RpmOstreeDeployments<'a>]>,
transactions: Option<Cow<'a, [Cow<'a, str>]>>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "kebab-case")]
struct RpmOstreeDeployments<'a> {
container_image_reference: Cow<'a, str>,
booted: bool,
staged: bool,
}
impl<'a> RpmOstreeStatus<'a> {
/// Creates a status struct for `rpm-ostree`.
///
/// # Errors
/// Errors if the command fails or deserialization fails.
pub fn try_new() -> Result<Self> {
blue_build_utils::check_command_exists("rpm-ostree")?;
trace!("rpm-ostree status --json");
let output = Command::new("rpm-ostree")
.args(["status", "--json"])
.output()?;
if !output.status.success() {
bail!("Failed to get `rpm-ostree` status!");
}
trace!("{}", String::from_utf8_lossy(&output.stdout));
Ok(serde_json::from_slice(&output.stdout)?)
}
/// Checks if there is a transaction in progress.
#[must_use]
pub fn transaction_in_progress(&self) -> bool {
self.transactions.as_ref().is_some_and(|tr| !tr.is_empty())
}
/// Get the booted image's reference.
#[must_use]
pub fn booted_image(&self) -> Option<String> {
Some(
self.deployments
.iter()
.find(|deployment| deployment.booted)?
.container_image_reference
.to_string(),
)
}
/// Get the booted image's reference.
#[must_use]
pub fn staged_image(&self) -> Option<String> {
Some(
self.deployments
.iter()
.find(|deployment| deployment.staged)?
.container_image_reference
.to_string(),
)
}
#[must_use]
pub fn is_booted_on_archive<P>(&self, archive_path: P) -> bool
where
P: AsRef<Path>,
{
self.booted_image().is_some_and(|deployment| {
deployment
.split(':')
.last()
.is_some_and(|boot_ref| Path::new(boot_ref) == archive_path.as_ref())
})
}
#[must_use]
pub fn is_staged_on_archive<P>(&self, archive_path: P) -> bool
where
P: AsRef<Path>,
{
self.staged_image().is_some_and(|deployment| {
deployment
.split(':')
.last()
.is_some_and(|boot_ref| Path::new(boot_ref) == archive_path.as_ref())
})
}
}
#[cfg(test)]
mod test {
use std::path::Path;
use blue_build_utils::constants::{
ARCHIVE_SUFFIX, LOCAL_BUILD, OCI_ARCHIVE, OSTREE_IMAGE_SIGNED, OSTREE_UNVERIFIED_IMAGE,
};
use super::{RpmOstreeDeployments, RpmOstreeStatus};
fn create_image_status<'a>() -> RpmOstreeStatus<'a> {
RpmOstreeStatus {
deployments: vec![
RpmOstreeDeployments {
container_image_reference: format!(
"{OSTREE_IMAGE_SIGNED}:docker://ghcr.io/blue-build/cli/test"
)
.into(),
booted: true,
staged: false,
},
RpmOstreeDeployments {
container_image_reference: format!(
"{OSTREE_IMAGE_SIGNED}:docker://ghcr.io/blue-build/cli/test:last"
)
.into(),
booted: false,
staged: false,
},
]
.into(),
transactions: None,
}
}
fn create_transaction_status<'a>() -> RpmOstreeStatus<'a> {
RpmOstreeStatus {
deployments: vec![
RpmOstreeDeployments {
container_image_reference: format!(
"{OSTREE_IMAGE_SIGNED}:docker://ghcr.io/blue-build/cli/test"
)
.into(),
booted: true,
staged: false,
},
RpmOstreeDeployments {
container_image_reference: format!(
"{OSTREE_IMAGE_SIGNED}:docker://ghcr.io/blue-build/cli/test:last"
)
.into(),
booted: false,
staged: false,
},
]
.into(),
transactions: Some(vec!["Upgrade".into(), "/".into()].into()),
}
}
fn create_archive_status<'a>() -> RpmOstreeStatus<'a> {
RpmOstreeStatus {
deployments: vec![
RpmOstreeDeployments {
container_image_reference:
format!("{OSTREE_UNVERIFIED_IMAGE}:{OCI_ARCHIVE}:{LOCAL_BUILD}/cli_test.{ARCHIVE_SUFFIX}").into(),
booted: true,
staged: false,
},
RpmOstreeDeployments {
container_image_reference:
format!("{OSTREE_IMAGE_SIGNED}:docker://ghcr.io/blue-build/cli/test:last").into(),
booted: false,
staged: false,
},
]
.into(),
transactions: None,
}
}
fn create_archive_staged_status<'a>() -> RpmOstreeStatus<'a> {
RpmOstreeStatus {
deployments: vec![
RpmOstreeDeployments {
container_image_reference:
format!("{OSTREE_UNVERIFIED_IMAGE}:{OCI_ARCHIVE}:{LOCAL_BUILD}/cli_test.{ARCHIVE_SUFFIX}").into(),
booted: false,
staged: true,
},
RpmOstreeDeployments {
container_image_reference:
format!("{OSTREE_UNVERIFIED_IMAGE}:{OCI_ARCHIVE}:{LOCAL_BUILD}/cli_test.{ARCHIVE_SUFFIX}").into(),
booted: true,
staged: false,
},
RpmOstreeDeployments {
container_image_reference:
format!("{OSTREE_IMAGE_SIGNED}:docker://ghcr.io/blue-build/cli/test:last").into(),
booted: false,
staged: false,
},
]
.into(),
transactions: None,
}
}
#[test]
fn test_booted_image() {
assert!(create_image_status()
.booted_image()
.expect("Contains image")
.ends_with("cli/test"));
}
#[test]
fn test_staged_image() {
assert!(create_archive_staged_status()
.staged_image()
.expect("Contains image")
.ends_with(&format!("cli_test.{ARCHIVE_SUFFIX}")));
}
#[test]
fn test_transaction_in_progress() {
assert!(create_transaction_status().transaction_in_progress());
assert!(!create_image_status().transaction_in_progress());
}
#[test]
fn test_is_booted_archive() {
assert!(!create_archive_status()
.is_booted_on_archive(Path::new(LOCAL_BUILD).join(format!("cli.{ARCHIVE_SUFFIX}"))));
assert!(create_archive_status().is_booted_on_archive(
Path::new(LOCAL_BUILD).join(format!("cli_test.{ARCHIVE_SUFFIX}"))
));
}
#[test]
fn test_is_staged_archive() {
assert!(!create_archive_staged_status()
.is_staged_on_archive(Path::new(LOCAL_BUILD).join(format!("cli.{ARCHIVE_SUFFIX}"))));
assert!(create_archive_staged_status().is_staged_on_archive(
Path::new(LOCAL_BUILD).join(format!("cli_test.{ARCHIVE_SUFFIX}"))
));
}
}