feat(iso): Create generate-iso command (#192)

## Tasks

- [x] Add ctrl-c handler to kill spawned children
- [x] add more args to support all variables
- [x] Add integration test
This commit is contained in:
Gerald Pinder 2024-09-04 18:17:08 -04:00 committed by GitHub
parent 4634f40840
commit e6cce3d542
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 737 additions and 201 deletions

View file

@ -39,6 +39,9 @@ fn main() {
#[cfg(feature = "login")]
CommandArgs::Login(mut command) => command.run(),
#[cfg(feature = "iso")]
CommandArgs::GenerateIso(mut command) => command.run(),
CommandArgs::BugReport(mut command) => command.run(),
CommandArgs::Completions(mut command) => command.run(),

View file

@ -11,6 +11,8 @@ pub mod bug_report;
pub mod build;
pub mod completions;
pub mod generate;
#[cfg(feature = "iso")]
pub mod generate_iso;
#[cfg(feature = "login")]
pub mod login;
// #[cfg(feature = "init")]
@ -68,6 +70,10 @@ pub enum CommandArgs {
#[clap(visible_alias = "template")]
Generate(generate::GenerateCommand),
/// Generate an ISO for an image or recipe.
#[cfg(feature = "iso")]
GenerateIso(generate_iso::GenerateIsoCommand),
/// Upgrade your current OS with the
/// local image saved at `/etc/bluebuild/`.
///

View file

@ -4,7 +4,7 @@ use std::{
};
use blue_build_process_management::drivers::{
opts::{BuildTagPushOpts, CheckKeyPairOpts, CompressionType},
opts::{BuildTagPushOpts, CheckKeyPairOpts, CompressionType, GenerateTagsOpts, SignVerifyOpts},
BuildDriver, CiDriver, Driver, DriverArgs, SigningDriver,
};
use blue_build_recipe::Recipe;
@ -14,11 +14,13 @@ use blue_build_utils::{
GITIGNORE_PATH, LABELED_ERROR_MESSAGE, NO_LABEL_ERROR_MESSAGE, RECIPE_FILE, RECIPE_PATH,
},
credentials::{Credentials, CredentialsArgs},
string,
};
use clap::Args;
use colored::Colorize;
use log::{debug, info, trace, warn};
use miette::{bail, Context, IntoDiagnostic, Result};
use oci_distribution::Reference;
use typed_builder::TypedBuilder;
use crate::commands::generate::GenerateCommand;
@ -197,7 +199,6 @@ impl BlueBuildCommand for BuildCommand {
impl BuildCommand {
#[cfg(feature = "multi-recipe")]
fn start(&self, recipe_paths: &[PathBuf]) -> Result<()> {
use blue_build_process_management::drivers::opts::SignVerifyOpts;
use rayon::prelude::*;
trace!("BuildCommand::build_image()");
@ -205,54 +206,12 @@ impl BuildCommand {
recipe_paths
.par_iter()
.try_for_each(|recipe_path| -> Result<()> {
let recipe = Recipe::parse(recipe_path)?;
let containerfile = if recipe_paths.len() > 1 {
blue_build_utils::generate_containerfile_path(recipe_path)?
} else {
PathBuf::from(CONTAINER_FILE)
};
let tags = Driver::generate_tags(&recipe)?;
let image_name = self.generate_full_image_name(&recipe)?;
let opts = if let Some(archive_dir) = self.archive.as_ref() {
BuildTagPushOpts::builder()
.containerfile(&containerfile)
.archive_path(format!(
"{}/{}.{ARCHIVE_SUFFIX}",
archive_dir.to_string_lossy().trim_end_matches('/'),
recipe.name.to_lowercase().replace('/', "_"),
))
.squash(self.squash)
.build()
} else {
BuildTagPushOpts::builder()
.image(&image_name)
.containerfile(&containerfile)
.tags(&tags)
.push(self.push)
.retry_push(self.retry_push)
.retry_count(self.retry_count)
.compression(self.compression_format)
.squash(self.squash)
.build()
};
Driver::build_tag_push(&opts)?;
if self.push && !self.no_sign {
let opts = SignVerifyOpts::builder()
.image(&image_name)
.retry_push(self.retry_push)
.retry_count(self.retry_count);
let opts = if let Some(tag) = tags.first() {
opts.tag(tag).build()
} else {
opts.build()
};
Driver::sign_and_verify(&opts)?;
}
Ok(())
self.build(recipe_path, &containerfile)
})?;
info!("Build complete!");
@ -261,18 +220,27 @@ impl BuildCommand {
#[cfg(not(feature = "multi-recipe"))]
fn start(&self, recipe_path: &Path) -> Result<()> {
use blue_build_process_management::drivers::opts::SignVerifyOpts;
trace!("BuildCommand::start()");
self.build(recipe_path, Path::new(CONTAINER_FILE))?;
info!("Build complete!");
Ok(())
}
fn build(&self, recipe_path: &Path, containerfile: &Path) -> Result<()> {
let recipe = Recipe::parse(recipe_path)?;
let containerfile = PathBuf::from(CONTAINER_FILE);
let tags = Driver::generate_tags(&recipe)?;
let image_name = self.generate_full_image_name(&recipe)?;
let tags = Driver::generate_tags(
&GenerateTagsOpts::builder()
.oci_ref(&recipe.base_image_ref()?)
.alt_tags(recipe.alt_tags())
.build(),
)?;
let image_name = self.image_name(&recipe)?;
let opts = if let Some(archive_dir) = self.archive.as_ref() {
BuildTagPushOpts::builder()
.containerfile(&containerfile)
.containerfile(containerfile)
.archive_path(format!(
"{}/{}.{ARCHIVE_SUFFIX}",
archive_dir.to_string_lossy().trim_end_matches('/'),
@ -283,7 +251,7 @@ impl BuildCommand {
} else {
BuildTagPushOpts::builder()
.image(&image_name)
.containerfile(&containerfile)
.containerfile(containerfile)
.tags(&tags)
.push(self.push)
.retry_push(self.retry_push)
@ -308,14 +276,29 @@ impl BuildCommand {
Driver::sign_and_verify(&opts)?;
}
info!("Build complete!");
Ok(())
}
fn image_name(&self, recipe: &Recipe) -> Result<String> {
let image_name = self.generate_full_image_name(recipe)?;
let image_name = if image_name.registry().is_empty() {
string!(image_name.repository())
} else {
format!(
"{}/{}",
image_name.resolve_registry(),
image_name.repository()
)
};
Ok(image_name)
}
/// # Errors
///
/// Will return `Err` if the image name cannot be generated.
fn generate_full_image_name(&self, recipe: &Recipe) -> Result<String> {
fn generate_full_image_name(&self, recipe: &Recipe) -> Result<Reference> {
trace!("BuildCommand::generate_full_image_name({recipe:#?})");
info!("Generating full image name");
@ -324,14 +307,18 @@ impl BuildCommand {
self.registry_namespace.as_ref().map(|s| s.to_lowercase()),
) {
trace!("registry={registry}, registry_path={registry_path}");
format!(
let image = format!(
"{}/{}/{}",
registry.trim().trim_matches('/'),
registry_path.trim().trim_matches('/'),
recipe.name.trim(),
)
);
image
.parse()
.into_diagnostic()
.with_context(|| format!("Unable to parse {image}"))?
} else {
Driver::generate_image_name(recipe)?
Driver::generate_image_name(&recipe.name)?
};
debug!("Using image name '{image_name}'");

View file

@ -93,15 +93,15 @@ impl GenerateCommand {
});
debug!("Deserializing recipe");
let recipe_de = Recipe::parse(&recipe_path)?;
trace!("recipe_de: {recipe_de:#?}");
let recipe = Recipe::parse(&recipe_path)?;
trace!("recipe_de: {recipe:#?}");
if self.display_full_recipe {
if let Some(output) = self.output.as_ref() {
std::fs::write(output, serde_yaml::to_string(&recipe_de).into_diagnostic()?)
std::fs::write(output, serde_yaml::to_string(&recipe).into_diagnostic()?)
.into_diagnostic()?;
} else {
syntax_highlighting::print_ser(&recipe_de, "yml", self.syntax_theme)?;
syntax_highlighting::print_ser(&recipe, "yml", self.syntax_theme)?;
}
return Ok(());
}
@ -109,9 +109,9 @@ impl GenerateCommand {
info!("Templating for recipe at {}", recipe_path.display());
let template = ContainerFileTemplate::builder()
.os_version(Driver::get_os_version(&recipe_de)?)
.os_version(Driver::get_os_version(&recipe.base_image_ref()?)?)
.build_id(Driver::get_build_id())
.recipe(&recipe_de)
.recipe(&recipe)
.recipe_path(recipe_path.as_path())
.registry(Driver::get_registry()?)
.repo(Driver::get_repo_url()?)
@ -142,7 +142,3 @@ impl GenerateCommand {
Ok(())
}
}
// ======================================================== //
// ========================= Helpers ====================== //
// ======================================================== //

View file

@ -0,0 +1,240 @@
use std::{
env, fs,
path::{self, Path, PathBuf},
};
use blue_build_recipe::Recipe;
use blue_build_utils::{constants::ARCHIVE_SUFFIX, string_vec};
use clap::{Args, Subcommand, ValueEnum};
use miette::{bail, Context, IntoDiagnostic, Result};
use oci_distribution::Reference;
use tempdir::TempDir;
use typed_builder::TypedBuilder;
use blue_build_process_management::{
drivers::{opts::RunOpts, Driver, DriverArgs, RunDriver},
run_volumes,
};
use super::{build::BuildCommand, BlueBuildCommand};
#[derive(Clone, Debug, TypedBuilder, Args)]
pub struct GenerateIsoCommand {
#[command(subcommand)]
command: GenIsoSubcommand,
/// The directory to save the resulting ISO file.
#[arg(short, long)]
output_dir: Option<PathBuf>,
/// The variant of the installer to use.
///
/// The Kinoite variant will ask for a user
/// and password before installing the OS.
/// This version is the most stable and is
/// recommended.
///
/// The Silverblue variant will ask for a user
/// and password on first boot after the OS
/// is installed.
///
/// The Server variant is the basic installer
/// and will ask to setup a user at install time.
#[arg(short = 'V', long, default_value = "server")]
variant: GenIsoVariant,
/// The url to the secure boot public key.
///
/// Defaults to one of UBlue's public key.
/// It's recommended to change this if your base
/// image is not from UBlue.
#[arg(
long,
default_value = "https://github.com/ublue-os/bazzite/raw/main/secure_boot.der"
)]
secure_boot_url: String,
/// The enrollment password for the secure boot
/// key.
///
/// Default's to UBlue's enrollment password.
/// It's recommended to change this if your base
/// image is not from UBlue.
#[arg(long, default_value = "universalblue")]
enrollment_password: String,
/// The name of your ISO image file.
#[arg(long)]
iso_name: Option<String>,
#[clap(flatten)]
#[builder(default)]
drivers: DriverArgs,
}
#[derive(Debug, Clone, Subcommand)]
pub enum GenIsoSubcommand {
/// Build an ISO from a remote image.
Image {
/// The image ref to create the iso from.
#[arg()]
image: String,
},
/// Build an ISO from a recipe.
///
/// This will build the image locally first
/// before creating the ISO. This is a long
/// process.
Recipe {
/// The path to the recipe file for your image.
#[arg()]
recipe: PathBuf,
},
}
#[derive(Debug, Default, Clone, Copy, ValueEnum)]
pub enum GenIsoVariant {
#[default]
Kinoite,
Silverblue,
Server,
}
impl std::fmt::Display for GenIsoVariant {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match *self {
Self::Server => "Server",
Self::Silverblue => "Silverblue",
Self::Kinoite => "Kinoite",
}
)
}
}
impl BlueBuildCommand for GenerateIsoCommand {
fn try_run(&mut self) -> Result<()> {
Driver::init(self.drivers);
let image_out_dir = TempDir::new("build_image").into_diagnostic()?;
let output_dir = if let Some(output_dir) = self.output_dir.clone() {
if output_dir.exists() && !output_dir.is_dir() {
bail!("The '--output-dir' arg must be a directory");
}
if !output_dir.exists() {
fs::create_dir(&output_dir).into_diagnostic()?;
}
path::absolute(output_dir).into_diagnostic()?
} else {
env::current_dir().into_diagnostic()?
};
if let GenIsoSubcommand::Recipe { recipe } = &self.command {
#[cfg(feature = "multi-recipe")]
let mut build_command = {
BuildCommand::builder()
.recipe(vec![recipe.clone()])
.archive(image_out_dir.path())
.build()
};
#[cfg(not(feature = "multi-recipe"))]
let mut build_command = {
BuildCommand::builder()
.recipe(recipe.to_path_buf())
.archive(image_out_dir.path())
.build()
};
build_command.try_run()?;
}
let iso_name = self.iso_name.as_ref().map_or("deploy.iso", String::as_str);
let iso_path = output_dir.join(iso_name);
if iso_path.exists() {
fs::remove_file(iso_path).into_diagnostic()?;
}
self.build_iso(iso_name, &output_dir, image_out_dir.path())
}
}
impl GenerateIsoCommand {
fn build_iso(&self, iso_name: &str, output_dir: &Path, image_out_dir: &Path) -> Result<()> {
let mut args = string_vec![
format!("VARIANT={}", self.variant),
format!("ISO_NAME=build/{iso_name}"),
"DNF_CACHE=/cache/dnf",
format!("SECURE_BOOT_KEY_URL={}", self.secure_boot_url),
format!("ENROLLMENT_PASSWORD={}", self.enrollment_password),
];
let mut vols = run_volumes![
output_dir.display().to_string() => "/build-container-installer/build",
"dnf-cache" => "/cache/dnf/",
];
match &self.command {
GenIsoSubcommand::Image { image } => {
let image: Reference = image
.parse()
.into_diagnostic()
.with_context(|| format!("Unable to parse image reference {image}"))?;
let (image_repo, image_name) = {
let registry = image.resolve_registry();
let repo = image.repository();
let image = format!("{registry}/{repo}");
let mut image_parts = image.split('/').collect::<Vec<_>>();
let image_name = image_parts.pop().unwrap(); // Should be at least 2 elements
let image_repo = image_parts.join("/");
(image_repo, image_name.to_string())
};
args.extend([
format!("IMAGE_NAME={image_name}",),
format!("IMAGE_REPO={image_repo}"),
format!("IMAGE_TAG={}", image.tag().unwrap_or("latest")),
format!("VERSION={}", Driver::get_os_version(&image)?),
]);
}
GenIsoSubcommand::Recipe { recipe } => {
let recipe = Recipe::parse(recipe)?;
args.extend([
format!(
"IMAGE_SRC=oci-archive:/img_src/{}.{ARCHIVE_SUFFIX}",
recipe.name.replace('/', "_"),
),
format!(
"VERSION={}",
Driver::get_os_version(&recipe.base_image_ref()?)?,
),
]);
vols.extend(run_volumes![
image_out_dir.display().to_string() => "/img_src/",
]);
}
}
// Currently testing local tarball builds
let opts = RunOpts::builder()
.image("ghcr.io/jasonn3/build-container-installer")
.privileged(true)
.remove(true)
.args(&args)
.volumes(vols)
.build();
let status = Driver::run(&opts).into_diagnostic()?;
if !status.success() {
bail!("Failed to create ISO");
}
Ok(())
}
}