feat: Add bootc support (#448)

Adds support for using `bootc` as the preferred method for booting from
a locally created image. This new method gets rid of the need to create
a tarball and move it to the correct place and instead it will make use
of `podman scp` which copies the image to the root `containers-storage`
and then has `rpm-ostree` and `bootc` boot from that store.

Closes #418 
Closes #200
This commit is contained in:
Gerald Pinder 2025-08-09 14:05:59 -04:00 committed by GitHub
parent 2c525854c9
commit 3a0be4099a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 2991 additions and 1857 deletions

View file

@ -7,20 +7,18 @@ use blue_build_process_management::{
BuildTagPushOpts, CheckKeyPairOpts, CompressionType, GenerateImageNameOpts,
GenerateTagsOpts, SignVerifyOpts,
},
types::Platform,
types::{ImageRef, Platform},
},
logging::{color_str, gen_random_ansi_color},
};
use blue_build_recipe::Recipe;
use blue_build_utils::{
constants::{
ARCHIVE_SUFFIX, BB_REGISTRY_NAMESPACE, BB_SKIP_VALIDATION, CONFIG_PATH, CONTAINER_FILE,
RECIPE_FILE, RECIPE_PATH,
ARCHIVE_SUFFIX, BB_REGISTRY_NAMESPACE, BB_SKIP_VALIDATION, CONFIG_PATH, RECIPE_FILE,
RECIPE_PATH,
},
cowstr,
credentials::{Credentials, CredentialsArgs},
string,
traits::CowCollecter,
};
use bon::Builder;
use clap::Args;
@ -163,7 +161,7 @@ impl BlueBuildCommand for BuildCommand {
if self.push {
blue_build_utils::check_command_exists("cosign")?;
Driver::check_signing_files(&CheckKeyPairOpts::builder().dir(Path::new(".")).build())?;
Driver::check_signing_files(CheckKeyPairOpts::builder().dir(Path::new(".")).build())?;
Driver::login()?;
Driver::signing_login()?;
}
@ -191,11 +189,11 @@ impl BlueBuildCommand for BuildCommand {
recipe_paths.par_iter().try_for_each(|recipe| {
GenerateCommand::builder()
.output(tempdir.path().join(if recipe_paths.len() > 1 {
blue_build_utils::generate_containerfile_path(recipe)?
} else {
PathBuf::from(CONTAINER_FILE)
}))
.output(
tempdir
.path()
.join(blue_build_utils::generate_containerfile_path(recipe)?),
)
.skip_validation(self.skip_validation)
.maybe_platform(self.platform)
.recipe(recipe)
@ -217,12 +215,10 @@ impl BuildCommand {
let images = recipe_paths
.par_iter()
.try_fold(Vec::new, |mut images, recipe_path| -> Result<Vec<String>> {
let containerfile = temp_dir.join(if recipe_paths.len() > 1 {
blue_build_utils::generate_containerfile_path(recipe_path)?
} else {
PathBuf::from(CONTAINER_FILE)
});
images.extend(self.build(recipe_path, &containerfile)?);
images.extend(self.build(
recipe_path,
&temp_dir.join(blue_build_utils::generate_containerfile_path(recipe_path)?),
)?);
Ok(images)
})
.try_reduce(Vec::new, |mut init, image_names| {
@ -245,9 +241,9 @@ impl BuildCommand {
fn build(&self, recipe_path: &Path, containerfile: &Path) -> Result<Vec<String>> {
let recipe = Recipe::parse(recipe_path)?;
let tags = Driver::generate_tags(
&GenerateTagsOpts::builder()
GenerateTagsOpts::builder()
.oci_ref(&recipe.base_image_ref()?)
.maybe_alt_tags(recipe.alt_tags.as_ref().map(CowCollecter::collect_cow_vec))
.maybe_alt_tags(recipe.alt_tags.as_deref())
.maybe_platform(self.platform)
.build(),
)?;
@ -276,45 +272,44 @@ impl BuildCommand {
&image_name,
cache_image.as_ref(),
)?
} else if let Some(archive_dir) = self.archive.as_ref() {
Driver::build_tag_push(
BuildTagPushOpts::builder()
.containerfile(containerfile)
.maybe_platform(self.platform)
.image(&ImageRef::from(PathBuf::from(format!(
"{}/{}.{ARCHIVE_SUFFIX}",
archive_dir.to_string_lossy().trim_end_matches('/'),
recipe.name.to_lowercase().replace('/', "_"),
))))
.squash(self.squash)
.maybe_cache_from(cache_image.as_ref())
.maybe_cache_to(cache_image.as_ref())
.secrets(&recipe.get_secrets())
.build(),
)?
} else {
Driver::build_tag_push(&self.archive.as_ref().map_or_else(
|| {
BuildTagPushOpts::builder()
.image(&image)
.containerfile(containerfile)
.maybe_platform(self.platform)
.tags(tags.collect_cow_vec())
.push(self.push)
.retry_push(self.retry_push)
.retry_count(self.retry_count)
.compression(self.compression_format)
.squash(self.squash)
.maybe_cache_from(cache_image.as_ref())
.maybe_cache_to(cache_image.as_ref())
.secrets(recipe.get_secrets())
.build()
},
|archive_dir| {
BuildTagPushOpts::builder()
.containerfile(containerfile)
.maybe_platform(self.platform)
.image(PathBuf::from(format!(
"{}/{}.{ARCHIVE_SUFFIX}",
archive_dir.to_string_lossy().trim_end_matches('/'),
recipe.name.to_lowercase().replace('/', "_"),
)))
.squash(self.squash)
.maybe_cache_from(cache_image.as_ref())
.maybe_cache_to(cache_image.as_ref())
.secrets(recipe.get_secrets())
.build()
},
))?
Driver::build_tag_push(
BuildTagPushOpts::builder()
.image(&ImageRef::from(&image))
.containerfile(containerfile)
.maybe_platform(self.platform)
.tags(&tags)
.push(self.push)
.retry_push(self.retry_push)
.retry_count(self.retry_count)
.compression(self.compression_format)
.squash(self.squash)
.maybe_cache_from(cache_image.as_ref())
.maybe_cache_to(cache_image.as_ref())
.secrets(&recipe.get_secrets())
.build(),
)?
};
if self.push && !self.no_sign {
Driver::sign_and_verify(
&SignVerifyOpts::builder()
SignVerifyOpts::builder()
.image(&image)
.retry_push(self.retry_push)
.retry_count(self.retry_count)
@ -342,13 +337,13 @@ impl BuildCommand {
.parse()
.into_diagnostic()?;
Driver::rechunk(
&RechunkOpts::builder()
RechunkOpts::builder()
.image(image_name)
.containerfile(containerfile)
.maybe_platform(self.platform)
.tags(tags.collect_cow_vec())
.tags(tags)
.push(self.push)
.version(format!(
.version(&format!(
"{version}.<date>",
version = Driver::get_os_version()
.oci_ref(&recipe.base_image_ref()?)
@ -359,23 +354,23 @@ impl BuildCommand {
.retry_count(self.retry_count)
.compression(self.compression_format)
.base_digest(
Driver::get_metadata(
&GetMetadataOpts::builder()
&Driver::get_metadata(
GetMetadataOpts::builder()
.image(&base_image)
.maybe_platform(self.platform)
.build(),
)?
.digest,
)
.repo(Driver::get_repo_url()?)
.name(&*recipe.name)
.description(&*recipe.description)
.base_image(format!("{}:{}", &recipe.base_image, &recipe.image_version))
.repo(&Driver::get_repo_url()?)
.name(&recipe.name)
.description(&recipe.description)
.base_image(&format!("{}:{}", &recipe.base_image, &recipe.image_version))
.maybe_tempdir(self.tempdir.as_deref())
.clear_plan(self.rechunk_clear_plan)
.maybe_cache_from(cache_image)
.maybe_cache_to(cache_image)
.secrets(recipe.get_secrets())
.secrets(&recipe.get_secrets())
.build(),
)
}
@ -384,8 +379,8 @@ impl BuildCommand {
let image_name = Driver::generate_image_name(
GenerateImageNameOpts::builder()
.name(recipe.name.trim())
.maybe_registry(self.credentials.registry.as_ref().map(|r| cowstr!(r)))
.maybe_registry_namespace(self.registry_namespace.as_ref().map(|r| cowstr!(r)))
.maybe_registry(self.credentials.registry.as_deref())
.maybe_registry_namespace(self.registry_namespace.as_deref())
.build(),
)?;

View file

@ -142,6 +142,19 @@ impl GenerateCommand {
let base_image: Reference = format!("{}:{}", &recipe.base_image, &recipe.image_version)
.parse()
.into_diagnostic()?;
let base_digest = &Driver::get_metadata(
GetMetadataOpts::builder()
.image(&base_image)
.maybe_platform(self.platform)
.build(),
)?
.digest;
let build_scripts_image = &determine_scripts_tag(self.platform)?;
let repo = &Driver::get_repo_url()?;
let build_features = &[
#[cfg(feature = "bootc")]
"bootc".into(),
];
let template = ContainerFileTemplate::builder()
.os_version(
@ -153,19 +166,12 @@ impl GenerateCommand {
.build_id(Driver::get_build_id())
.recipe(&recipe)
.recipe_path(recipe_path.as_path())
.registry(registry)
.repo(Driver::get_repo_url()?)
.build_scripts_image(determine_scripts_tag(self.platform)?.to_string())
.base_digest(
Driver::get_metadata(
&GetMetadataOpts::builder()
.image(&base_image)
.maybe_platform(self.platform)
.build(),
)?
.digest,
)
.registry(&registry)
.repo(repo)
.build_scripts_image(build_scripts_image)
.base_digest(base_digest)
.maybe_nushell_version(recipe.nushell_version.as_ref())
.build_features(build_features)
.build();
let output_str = template.render().into_diagnostic()?;
@ -197,7 +203,7 @@ fn determine_scripts_tag(platform: Option<Platform>) -> Result<Reference> {
.parse()
.into_diagnostic()
.and_then(|image| {
Driver::get_metadata(&opts.clone().image(&image).build())
Driver::get_metadata(opts.clone().image(&image).build())
.inspect_err(|e| trace!("{e:?}"))
.map(|_| image)
})
@ -205,7 +211,7 @@ fn determine_scripts_tag(platform: Option<Platform>) -> Result<Reference> {
let image: Reference = format!("{BUILD_SCRIPTS_IMAGE_REF}:{}", shadow::BRANCH)
.parse()
.into_diagnostic()?;
Driver::get_metadata(&opts.clone().image(&image).build())
Driver::get_metadata(opts.clone().image(&image).build())
.inspect_err(|e| trace!("{e:?}"))
.map(|_| image)
})
@ -213,7 +219,7 @@ fn determine_scripts_tag(platform: Option<Platform>) -> Result<Reference> {
let image: Reference = format!("{BUILD_SCRIPTS_IMAGE_REF}:v{}", crate_version!())
.parse()
.into_diagnostic()?;
Driver::get_metadata(&opts.image(&image).build())
Driver::get_metadata(opts.image(&image).build())
.inspect_err(|e| trace!("{e:?}"))
.map(|_| image)
})

View file

@ -7,7 +7,6 @@ use blue_build_recipe::Recipe;
use blue_build_utils::{
constants::{ARCHIVE_SUFFIX, BB_SKIP_VALIDATION},
string_vec,
traits::CowCollecter,
};
use bon::Builder;
use clap::{Args, Subcommand, ValueEnum};
@ -189,8 +188,10 @@ impl GenerateIsoCommand {
format!("SECURE_BOOT_KEY_URL={}", self.secure_boot_url),
format!("ENROLLMENT_PASSWORD={}", self.enrollment_password),
];
let image_out_dir = &image_out_dir.display().to_string();
let output_dir = &output_dir.display().to_string();
let mut vols = run_volumes![
output_dir.display().to_string() => "/build-container-installer/build",
output_dir => "/build-container-installer/build",
"dnf-cache" => "/cache/dnf/",
];
@ -239,8 +240,8 @@ impl GenerateIsoCommand {
.call()?,
),
]);
vols.extend(run_volumes![
image_out_dir.display().to_string() => "/img_src/",
vols.extend(&run_volumes![
image_out_dir => "/img_src/",
]);
}
}
@ -250,11 +251,11 @@ impl GenerateIsoCommand {
.image("ghcr.io/jasonn3/build-container-installer")
.privileged(true)
.remove(true)
.args(args.collect_cow_vec())
.volumes(vols)
.args(&args)
.volumes(&vols)
.build();
let status = Driver::run(&opts)?;
let status = Driver::run(opts)?;
if !status.success() {
bail!("Failed to create ISO");

View file

@ -518,8 +518,8 @@ impl InitCommand {
.with_context(|| format!("Failed to delete old public file {COSIGN_PUB_PATH}"))?;
Driver::generate_key_pair(
&GenerateKeyPairOpts::builder()
.maybe_dir(self.dir.as_ref())
GenerateKeyPairOpts::builder()
.maybe_dir(self.dir.as_deref())
.build(),
)
}

View file

@ -73,7 +73,7 @@ impl BlueBuildCommand for PruneCommand {
}
Driver::prune(
&PruneOpts::builder()
PruneOpts::builder()
.all(self.all)
.volumes(self.volumes)
.build(),

View file

@ -1,29 +1,19 @@
use std::{
path::{Path, PathBuf},
time::Duration,
};
use std::path::PathBuf;
use blue_build_process_management::{
drivers::{Driver, DriverArgs},
logging::CommandLogging,
use blue_build_process_management::drivers::{
BootDriver, BuildDriver, CiDriver, Driver, DriverArgs, PodmanDriver, RunDriver,
opts::{BuildOpts, GenerateImageNameOpts, RemoveImageOpts, SwitchOpts},
types::ImageRef,
};
use blue_build_recipe::Recipe;
use blue_build_utils::{
constants::{
ARCHIVE_SUFFIX, BB_SKIP_VALIDATION, LOCAL_BUILD, OCI_ARCHIVE, OSTREE_UNVERIFIED_IMAGE,
SUDO_ASKPASS,
},
has_env_var, running_as_root,
};
use blue_build_utils::constants::BB_SKIP_VALIDATION;
use bon::Builder;
use clap::Args;
use comlexr::cmd;
use indicatif::ProgressBar;
use log::{debug, trace};
use log::trace;
use miette::{IntoDiagnostic, Result, bail};
use tempfile::TempDir;
use crate::{commands::build::BuildCommand, rpm_ostree_status::RpmOstreeStatus};
use crate::commands::generate::GenerateCommand;
use super::BlueBuildCommand;
@ -60,238 +50,59 @@ impl BlueBuildCommand for SwitchCommand {
Driver::init(self.drivers);
let status = RpmOstreeStatus::try_new()?;
trace!("{status:?}");
let status = Driver::status()?;
if status.transaction_in_progress() {
bail!("There is a transaction in progress. Please cancel it using `rpm-ostree cancel`");
}
let recipe = Recipe::parse(&self.recipe)?;
let image_name = Driver::generate_image_name(
GenerateImageNameOpts::builder()
.name(recipe.name.trim())
.build(),
)?;
let tempdir = if let Some(ref dir) = self.tempdir {
TempDir::new_in(dir).into_diagnostic()?
} else {
TempDir::new().into_diagnostic()?
};
trace!("{tempdir:?}");
let containerfile = tempdir
.path()
.join(blue_build_utils::generate_containerfile_path(&self.recipe)?);
BuildCommand::builder()
.recipe([self.recipe.clone()])
.archive(tempdir.path())
.maybe_tempdir(self.tempdir.clone())
.skip_validation(self.skip_validation)
GenerateCommand::builder()
.output(&containerfile)
.recipe(&self.recipe)
.build()
.try_run()?;
PodmanDriver::build(
BuildOpts::builder()
.image(&ImageRef::from(&image_name))
.containerfile(&containerfile)
.secrets(&recipe.get_secrets())
.build(),
)?;
PodmanDriver::copy_image_to_root_store(&image_name)?;
PodmanDriver::remove_image(RemoveImageOpts::builder().image(&image_name).build())?;
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);
Self::clean_local_build_dir()?;
Self::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)
if status
.booted_image()
.is_some_and(|booted| booted == image_name)
{
let command = cmd!("rpm-ostree", "upgrade", if self.reboot => "--reboot");
trace!("{command:?}");
command
Driver::upgrade(
SwitchOpts::builder()
.image(&image_name)
.reboot(self.reboot)
.build(),
)
} else {
let image_ref = format!(
"{OSTREE_UNVERIFIED_IMAGE}:{OCI_ARCHIVE}:{path}",
path = archive_path.display()
);
let command = cmd!(
"rpm-ostree",
"rebase",
&image_ref,
if self.reboot => "--reboot",
);
trace!("{command:?}");
command
Driver::switch(
SwitchOpts::builder()
.image(&image_name)
.reboot(self.reboot)
.build(),
)
}
.build_status(
format!("{}", archive_path.display()),
"Switching to new image",
)
.into_diagnostic()?;
if !status.success() {
bail!("Failed to switch to new image!");
}
Ok(())
}
fn move_archive(from: &Path, to: &Path) -> Result<()> {
trace!(
"SwitchCommand::move_archive({}, {})",
from.display(),
to.display()
);
let progress = ProgressBar::new_spinner();
progress.enable_steady_tick(Duration::from_millis(100));
progress.set_message(format!("Moving image archive to {}...", to.display()));
let status = {
let c = cmd!(
if running_as_root() {
"mv"
} else {
"sudo"
},
if !running_as_root() && has_env_var(SUDO_ASKPASS) => [
"-A",
"-p",
format!("Password needed to move {from:?} to {to:?}"),
],
if !running_as_root() => "mv",
from,
to,
);
trace!("{c:?}");
c
}
.status()
.into_diagnostic()?;
progress.finish_and_clear();
if !status.success() {
bail!(
"Failed to move archive from {from} to {to}",
from = from.display(),
to = to.display()
);
}
Ok(())
}
fn 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}");
let mut command = {
let c = cmd!(
if running_as_root() {
"ls"
} else {
"sudo"
},
if !running_as_root() && has_env_var(SUDO_ASKPASS) => [
"-A",
"-p",
format!("Password required to list files in {LOCAL_BUILD}"),
],
if !running_as_root() => "ls",
LOCAL_BUILD
);
trace!("{c:?}");
c
};
let output =
String::from_utf8(command.output().into_diagnostic()?.stdout).into_diagnostic()?;
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 progress = ProgressBar::new_spinner();
progress.enable_steady_tick(Duration::from_millis(100));
progress.set_message("Removing old image archive files...");
let status = {
let c = cmd!(
if running_as_root() {
"rm"
} else {
"sudo"
},
if !running_as_root() && has_env_var(SUDO_ASKPASS) => [
"-A",
"-p",
format!("Password required to remove files: {files:?}"),
],
if !running_as_root() => "rm",
"-f",
for files,
);
trace!("{c:?}");
c
}
.status()
.into_diagnostic()?;
progress.finish_and_clear();
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 = {
let c = cmd!(
if running_as_root() {
"mkdir"
} else {
"sudo"
},
if !running_as_root() && has_env_var(SUDO_ASKPASS) => [
"-A",
"-p",
format!("Password needed to create directory {local_build_path:?}"),
],
if !running_as_root() => "mkdir",
"-p",
local_build_path,
);
trace!("{c:?}");
c
}
.status()
.into_diagnostic()?;
if !status.success() {
bail!("Failed to create directory {LOCAL_BUILD}");
}
}
Ok(())
}
}