diff --git a/Cargo.lock b/Cargo.lock index aba4f1e92..265f011ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,6 +153,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "bootc-imagectl" +version = "0.1.0" +dependencies = [ + "anyhow", + "bootc-internal-utils", + "camino", + "clap", + "serde", + "serde_json", + "serde_yaml", + "shlex", + "similar-asserts", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "bootc-initramfs-setup" version = "0.1.0" @@ -225,6 +243,7 @@ dependencies = [ "anstream", "anstyle", "anyhow", + "bootc-imagectl", "bootc-initramfs-setup", "bootc-internal-blockdev", "bootc-internal-utils", diff --git a/crates/imagectl/Cargo.toml b/crates/imagectl/Cargo.toml new file mode 100644 index 000000000..55443970b --- /dev/null +++ b/crates/imagectl/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "bootc-imagectl" +description = "Container image build tooling for bootc" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +repository = "https://github.com/bootc-dev/bootc" + +[dependencies] +# Internal crates +bootc-utils = { package = "bootc-internal-utils", path = "../utils", version = "0.0.0" } + +# Workspace dependencies +anyhow = { workspace = true } +camino = { workspace = true } +clap = { workspace = true, features = ["derive"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +shlex = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["process"] } +tracing = { workspace = true } + +# Crate-specific dependencies +serde_yaml = "0.9.34" + +[dev-dependencies] +similar-asserts = { workspace = true } + +[lints] +workspace = true diff --git a/crates/imagectl/src/build_rootfs.rs b/crates/imagectl/src/build_rootfs.rs new file mode 100644 index 000000000..daaacd48e --- /dev/null +++ b/crates/imagectl/src/build_rootfs.rs @@ -0,0 +1,339 @@ +//! Build container root filesystem using rpm-ostree + +use anyhow::{Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::process::Command; +use tempfile::TempDir; + +use crate::cli::BuildRootfsOpts; +use crate::constants::MANIFESTDIR; +use crate::lockfile::Lockfile; +use crate::manifest::ManifestOverride; + +/// Build a container root filesystem from a manifest +pub fn build_rootfs(opts: &BuildRootfsOpts) -> Result<()> { + tracing::info!("Building rootfs at: {}", opts.target); + + // Find the manifest + let manifest_path = crate::manifest::find_manifest(&opts.manifest)?; + tracing::info!("Using manifest: {}", manifest_path); + + // Workaround for https://issues.redhat.com/browse/RHEL-108989 + tracing::debug!("Running dnf repolist as workaround for RHEL-108989"); + let _ = Command::new("dnf") + .arg("repolist") + .output() + .context("Failed to execute dnf repolist")?; + + // Build manifest override if needed + let (final_manifest_path, _manifest_tmpfile) = + build_manifest_override(opts, manifest_path.as_str())?; + + // Build lockfile if needed + let (_lockfile_tmpfile, lockfile_arg) = build_lockfile(opts)?; + + // Handle ostree overlays if needed + let (_tmp_ostree_repo, ostree_repo_arg, _overlay_commits) = setup_ostree_overlays(opts)?; + + // Build rpm-ostree command + let mut argv = vec![ + "rpm-ostree".to_string(), + "compose".to_string(), + "rootfs".to_string(), + ]; + + // Add lockfile if present + if let Some(lockfile_path) = lockfile_arg { + argv.push(format!("--lockfile={}", lockfile_path)); + } + + // Add cachedir if specified + if !opts.cachedir.is_empty() { + argv.push(format!("--cachedir={}", opts.cachedir)); + } + + // Add ostree repo if present + if let Some(repo_path) = ostree_repo_arg { + argv.push(format!("--ostree-repo={}", repo_path)); + } + + // Add source root + let source_root = opts.source_root.as_ref().map(|p| p.as_str()).unwrap_or("/"); + if source_root != "/" { + argv.push(format!("--source-root-rw={}", source_root)); + } else { + argv.push("--source-root=/".to_string()); + } + + // Add manifest and target + argv.push(final_manifest_path); + argv.push(opts.target.to_string()); + + // Execute rpm-ostree + tracing::info!("Executing: {}", argv.join(" ")); + let status = Command::new(&argv[0]) + .args(&argv[1..]) + .status() + .context("Failed to execute rpm-ostree")?; + + if !status.success() { + anyhow::bail!( + "rpm-ostree command failed with exit code: {}", + status.code().unwrap_or(-1) + ); + } + + // Apply permission fix workaround + fix_rootfs_permissions(&opts.target)?; + + // Run bootc container lint + run_bootc_lint(&opts.target)?; + + // Handle reinject if requested + if opts.reinject { + reinject_build_configs(&opts.target)?; + } + + tracing::info!("Successfully built rootfs at: {}", opts.target); + Ok(()) +} + +/// Build manifest override if any options require it +fn build_manifest_override( + opts: &BuildRootfsOpts, + base_manifest: &str, +) -> Result<(String, Option)> { + let mut override_manifest = ManifestOverride::new(base_manifest.to_string()); + let mut needs_override = false; + + // Add packages if specified + if !opts.install.is_empty() { + let packages: Vec = opts.install.iter().map(|p| p.to_string()).collect(); + override_manifest.packages = Some(packages); + needs_override = true; + } + + // Add ostree overlay layers (will be populated by setup_ostree_overlays) + if !opts.add_dir.is_empty() { + let layers: Vec = opts + .add_dir + .iter() + .map(|d| { + let base = d.file_name().unwrap_or("unknown"); + format!("overlay/{}", base) + }) + .collect(); + override_manifest.ostree_override_layers = Some(layers); + needs_override = true; + } + + // Add documentation setting + if opts.no_docs { + override_manifest.documentation = Some(false); + needs_override = true; + } + + // Add sysusers setting + if opts.sysusers { + override_manifest.sysusers = Some("compose-forced".to_string()); + let passwd_mode = if opts.nobody_99 { "nobody" } else { "none" }; + let mut variables = std::collections::HashMap::new(); + variables.insert("passwd_mode".to_string(), passwd_mode.to_string()); + override_manifest.variables = Some(variables); + needs_override = true; + } + + // Add repo overrides + if !opts.repo.is_empty() { + override_manifest.repos = Some(opts.repo.clone()); + needs_override = true; + } + + if needs_override { + tracing::debug!("Creating manifest override"); + let tmpfile = override_manifest.write_to_tempfile()?; + let path = tmpfile.path().to_str().unwrap().to_string(); + Ok((path, Some(tmpfile))) + } else { + Ok((base_manifest.to_string(), None)) + } +} + +/// Build lockfile from NEVRA/NEVR specifications +fn build_lockfile( + opts: &BuildRootfsOpts, +) -> Result<(Option, Option)> { + if opts.lock.is_empty() { + return Ok((None, None)); + } + + let mut lockfile = Lockfile::new(); + for nevra in &opts.lock { + lockfile.add_package(nevra)?; + } + + tracing::debug!("Creating lockfile with {} packages", opts.lock.len()); + let tmpfile = lockfile.write_to_tempfile()?; + let path = tmpfile.path().to_str().unwrap().to_string(); + Ok((Some(tmpfile), Some(path))) +} + +/// Setup ostree overlays for --add-dir +fn setup_ostree_overlays( + opts: &BuildRootfsOpts, +) -> Result<(Option, Option, Vec)> { + if opts.add_dir.is_empty() { + return Ok((None, None, Vec::new())); + } + + // Create temporary ostree repo + let tmp_repo = tempfile::Builder::new() + .prefix("ostree-repo-") + .tempdir_in("/var/tmp") + .context("Failed to create temporary ostree repository")?; + + let repo_path = tmp_repo.path().to_str().unwrap().to_string(); + tracing::info!("Created temporary ostree repo at: {}", repo_path); + + // Initialize the repo + let status = Command::new("ostree") + .args(["init", "--repo", &repo_path, "--mode=bare"]) + .status() + .context("Failed to initialize ostree repository")?; + + if !status.success() { + anyhow::bail!("ostree init failed"); + } + + // Commit each directory as an overlay + let mut commits = Vec::new(); + for dir in &opts.add_dir { + let base = dir.file_name().unwrap_or("unknown"); + let branch = format!("overlay/{}", base); + let abs_path = dir + .canonicalize_utf8() + .with_context(|| format!("Failed to canonicalize path: {}", dir))?; + + tracing::info!("Committing {} as {}", dir, branch); + + let output = Command::new("ostree") + .args([ + "commit", + "--repo", + &repo_path, + "-b", + &branch, + abs_path.as_str(), + "--owner-uid=0", + "--owner-gid=0", + "--no-xattrs", + "--mode-ro-executables", + ]) + .output() + .context("Failed to commit ostree overlay")?; + + if !output.status.success() { + anyhow::bail!( + "ostree commit failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + commits.push(branch); + } + + Ok((Some(tmp_repo), Some(repo_path), commits)) +} + +/// Fix rootfs permissions (workaround for rpm-ostree issue) +fn fix_rootfs_permissions(target: &Utf8Path) -> Result<()> { + // Work around https://github.com/coreos/rpm-ostree/pull/5322 + let metadata = + fs::metadata(target).with_context(|| format!("Failed to get metadata for {}", target))?; + + let mode = metadata.permissions().mode(); + + // Check if "other execute" bit is not set + if (mode & 0o001) == 0 { + tracing::info!("Updating rootfs mode to add execute permissions"); + let new_mode = mode | 0o555; + let new_perms = fs::Permissions::from_mode(new_mode); + fs::set_permissions(target, new_perms) + .with_context(|| format!("Failed to set permissions on {}", target))?; + } + + Ok(()) +} + +/// Run bootc container lint on the generated rootfs +fn run_bootc_lint(target: &Utf8Path) -> Result<()> { + tracing::info!("Running bootc container lint"); + + let status = Command::new("bootc") + .args(["container", "lint", &format!("--rootfs={}", target)]) + .status() + .context("Failed to execute bootc container lint")?; + + if !status.success() { + anyhow::bail!("bootc container lint failed"); + } + + Ok(()) +} + +/// Reinject build configurations into the target +fn reinject_build_configs(target: &Utf8Path) -> Result<()> { + tracing::info!("Reinjecting build configurations"); + + // Copy manifest directory + let manifest_src = Utf8Path::new("/").join(MANIFESTDIR); + let manifest_dst = target.join(MANIFESTDIR); + + if manifest_src.exists() { + tracing::info!("Copying {} to {}", manifest_src, manifest_dst); + copy_dir_all(&manifest_src, &manifest_dst)?; + } else { + tracing::warn!("Manifest directory not found: {}", manifest_src); + } + + // Copy the imagectl binary itself + // In the Python version, this was bootc-base-imagectl + // In our Rust version, this will be part of bootc binary + let imagectl_src = Utf8Path::new("/usr/libexec/bootc-base-imagectl"); + if imagectl_src.exists() { + let imagectl_dst = target.join("usr/libexec/bootc-base-imagectl"); + if let Some(parent) = imagectl_dst.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory: {}", parent))?; + } + tracing::info!("Copying {} to {}", imagectl_src, imagectl_dst); + fs::copy(imagectl_src, imagectl_dst).context("Failed to copy bootc-base-imagectl")?; + } else { + tracing::debug!("Legacy bootc-base-imagectl not found, skipping"); + } + + Ok(()) +} + +/// Recursively copy a directory +fn copy_dir_all(src: &Utf8Path, dst: &Utf8Path) -> Result<()> { + fs::create_dir_all(dst).with_context(|| format!("Failed to create directory: {}", dst))?; + + for entry in fs::read_dir(src).with_context(|| format!("Failed to read directory: {}", src))? { + let entry = entry.context("Failed to read directory entry")?; + let ty = entry.file_type().context("Failed to get file type")?; + let src_path = Utf8PathBuf::try_from(entry.path()).context("Invalid UTF-8 in path")?; + let dst_path = dst.join(src_path.file_name().unwrap()); + + if ty.is_dir() { + copy_dir_all(&src_path, &dst_path)?; + } else { + fs::copy(&src_path, &dst_path) + .with_context(|| format!("Failed to copy {} to {}", src_path, dst_path))?; + } + } + + Ok(()) +} diff --git a/crates/imagectl/src/cli.rs b/crates/imagectl/src/cli.rs new file mode 100644 index 000000000..75828be3b --- /dev/null +++ b/crates/imagectl/src/cli.rs @@ -0,0 +1,90 @@ +//! CLI argument definitions for imagectl commands + +use camino::Utf8PathBuf; +use clap::Parser; + +/// Image build and manipulation commands +#[derive(Debug, clap::Subcommand, PartialEq, Eq)] +pub enum ImageCtlCmd { + /// Generate a container root filesystem from a manifest + /// + /// Uses rpm-ostree to compose a root filesystem from package manifests, + /// with support for additional packages, overlays, and customizations. + #[clap(name = "build-rootfs")] + BuildRootfs(BuildRootfsOpts), + + /// Generate a new container image with split, reproducible, chunked layers + /// + /// Uses rpm-ostree to rechunk an existing container image into + /// content-addressed layers for better deduplication and caching. + Rechunk(RechunkOpts), + + /// List available build manifests + /// + /// Shows all available manifests that can be used with build-rootfs. + List, +} + +/// Options for building a container root filesystem +#[derive(Debug, Parser, PartialEq, Eq)] +pub struct BuildRootfsOpts { + /// Also reinject the build configurations into the target + #[clap(long)] + pub reinject: bool, + + /// Use the specified manifest + #[clap(long, default_value = "default")] + pub manifest: String, + + /// Add a package to install + #[clap(long, action = clap::ArgAction::Append)] + pub install: Vec, + + /// Cache repo metadata and RPMs in specified directory + #[clap(long, default_value = "")] + pub cachedir: String, + + /// Copy directory contents into the target as an ostree overlay + #[clap(long, action = clap::ArgAction::Append)] + pub add_dir: Vec, + + /// Don't install documentation + #[clap(long)] + pub no_docs: bool, + + /// Run systemd-sysusers instead of injecting hardcoded passwd/group entries + #[clap(long)] + pub sysusers: bool, + + /// Hidden flag for nobody-99 compatibility + #[clap(long, hide = true)] + pub nobody_99: bool, + + /// Enable specific repositories only + #[clap(long, action = clap::ArgAction::Append)] + pub repo: Vec, + + /// Lock package to specific version (NEVRA or NEVR format) + #[clap(long, action = clap::ArgAction::Append)] + pub lock: Vec, + + /// Path to the target root directory that will be generated + pub target: Utf8PathBuf, + + /// Path to the source root directory used for dnf configuration (default: /) + pub source_root: Option, +} + +/// Options for rechunking a container image +#[derive(Debug, Parser, PartialEq, Eq)] +pub struct RechunkOpts { + /// Configure the number of output layers + #[clap(long)] + pub max_layers: Option, + + /// Source image in container storage + pub from_image: String, + + /// Destination image in container storage + pub to_image: String, +} diff --git a/crates/imagectl/src/constants.rs b/crates/imagectl/src/constants.rs new file mode 100644 index 000000000..a31aca831 --- /dev/null +++ b/crates/imagectl/src/constants.rs @@ -0,0 +1,4 @@ +//! Constants used throughout the imagectl crate + +/// Directory containing rpm-ostree manifest files +pub(crate) const MANIFESTDIR: &str = "usr/share/doc/bootc-base-imagectl/manifests"; diff --git a/crates/imagectl/src/lib.rs b/crates/imagectl/src/lib.rs new file mode 100644 index 000000000..0aebd3178 --- /dev/null +++ b/crates/imagectl/src/lib.rs @@ -0,0 +1,32 @@ +//! # bootc imagectl +//! +//! Tools for building and manipulating bootc container images. +//! +//! This crate provides functionality for: +//! - Building container root filesystems using rpm-ostree +//! - Rechunking images into split, reproducible layers +//! - Managing and listing build manifests +//! +//! Originally ported from the Python `bootc-base-imagectl` script. + +mod build_rootfs; +mod cli; +mod constants; +mod list; +mod lockfile; +mod manifest; +mod rechunk; + +use anyhow::Result; + +// Re-export the CLI interface +pub use cli::ImageCtlCmd; + +/// Execute an imagectl command +pub fn run(cmd: &ImageCtlCmd) -> Result<()> { + match cmd { + ImageCtlCmd::List => list::list_manifests(), + ImageCtlCmd::BuildRootfs(opts) => build_rootfs::build_rootfs(opts), + ImageCtlCmd::Rechunk(opts) => rechunk::rechunk(opts), + } +} diff --git a/crates/imagectl/src/list.rs b/crates/imagectl/src/list.rs new file mode 100644 index 000000000..861aa17c2 --- /dev/null +++ b/crates/imagectl/src/list.rs @@ -0,0 +1,99 @@ +//! List available build manifests + +use anyhow::{Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use serde_json::Value; +use std::process::Command; + +use crate::constants::MANIFESTDIR; + +/// List all available manifests with their descriptions +pub fn list_manifests() -> Result<()> { + let manifest_dir = Utf8Path::new("/").join(MANIFESTDIR); + + if !manifest_dir.exists() { + anyhow::bail!( + "Manifest directory not found: {}. This command must be run in a bootc base image.", + manifest_dir + ); + } + + let entries = std::fs::read_dir(&manifest_dir) + .with_context(|| format!("Failed to read manifest directory: {}", manifest_dir))?; + + let mut manifests = Vec::new(); + + for entry in entries { + let entry = entry.context("Failed to read directory entry")?; + + // Skip symlinks + if entry + .file_type() + .context("Failed to get file type")? + .is_symlink() + { + continue; + } + + let path = Utf8PathBuf::try_from(entry.path()).context("Invalid UTF-8 in path")?; + let file_name = match path.file_name() { + Some(name) => name, + None => continue, + }; + + // Skip files that aren't .yaml or are .hidden.yaml + if !file_name.ends_with(".yaml") || file_name.ends_with(".hidden.yaml") { + continue; + } + + let name = file_name + .strip_suffix(".yaml") + .expect("Already checked .yaml suffix"); + + manifests.push((name.to_string(), path)); + } + + // Sort manifests by name + manifests.sort_by(|a, b| a.0.cmp(&b.0)); + + // Print each manifest with its description + for (name, path) in manifests { + match get_manifest_description(&path) { + Ok(description) => { + println!("{}: {}", name, description); + println!("---"); + } + Err(e) => { + tracing::warn!("Failed to get description for {}: {}", name, e); + println!("{}: ", name); + println!("---"); + } + } + } + + Ok(()) +} + +/// Get the description from a manifest file using rpm-ostree +fn get_manifest_description(path: &Utf8Path) -> Result { + let output = Command::new("rpm-ostree") + .args(["compose", "tree", "--print-only", path.as_str()]) + .output() + .context("Failed to execute rpm-ostree")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("rpm-ostree failed: {}", stderr); + } + + let manifest: Value = serde_json::from_slice(&output.stdout) + .context("Failed to parse rpm-ostree output as JSON")?; + + let description = manifest + .get("metadata") + .and_then(|m| m.get("summary")) + .and_then(|s| s.as_str()) + .unwrap_or(""); + + Ok(description.to_string()) +} diff --git a/crates/imagectl/src/lockfile.rs b/crates/imagectl/src/lockfile.rs new file mode 100644 index 000000000..418364a1a --- /dev/null +++ b/crates/imagectl/src/lockfile.rs @@ -0,0 +1,140 @@ +//! Lockfile generation for rpm-ostree + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Package lockfile structure +#[derive(Debug, Serialize, Deserialize)] +pub struct Lockfile { + pub packages: HashMap, +} + +/// Individual package lock entry +#[derive(Debug, Serialize, Deserialize)] +pub struct PackageLock { + #[serde(skip_serializing_if = "Option::is_none")] + pub evr: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub evra: Option, +} + +impl Lockfile { + /// Create a new empty lockfile + pub fn new() -> Self { + Self { + packages: HashMap::new(), + } + } + + /// Add a package from NEVRA or NEVR string + /// + /// The format can be either: + /// - NEVRA: name-epoch:version-release.arch + /// - NEVR: name-epoch:version-release + pub fn add_package(&mut self, nevra: &str) -> Result<()> { + // Split from the right to get name, epoch:version, and release[.arch] + let parts: Vec<&str> = nevra.rsplitn(3, '-').collect(); + if parts.len() != 3 { + anyhow::bail!("Invalid NEVRA/NEVR format: {}", nevra); + } + + let r_or_ra = parts[0]; + let ev = parts[1]; + let name = parts[2]; + + let evr_or_evra = format!("{}-{}", ev, r_or_ra); + + // Detect architecture based on common arch suffixes + let arch = std::env::consts::ARCH; + let is_evra = r_or_ra.ends_with(".noarch") || r_or_ra.ends_with(&format!(".{}", arch)); + + let lock = if is_evra { + PackageLock { + evr: None, + evra: Some(evr_or_evra), + } + } else { + PackageLock { + evr: Some(evr_or_evra), + evra: None, + } + }; + + tracing::debug!("Adding package lock: {} -> {:?}", name, lock); + self.packages.insert(name.to_string(), lock); + + Ok(()) + } + + /// Write this lockfile to a temporary JSON file + pub fn write_to_tempfile(&self) -> Result { + let mut tmpfile = tempfile::Builder::new() + .suffix(".json") + .tempfile() + .context("Failed to create temporary lockfile")?; + + serde_json::to_writer_pretty(&mut tmpfile, self) + .context("Failed to write lockfile to temporary file")?; + + Ok(tmpfile) + } +} + +impl Default for Lockfile { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add_package_nevra() { + let mut lockfile = Lockfile::new(); + + // Test NEVRA format (with architecture) + lockfile.add_package("bash-0:5.1.8-6.el9.x86_64").unwrap(); + + let bash_lock = lockfile.packages.get("bash").unwrap(); + assert_eq!(bash_lock.evra, Some("0:5.1.8-6.el9.x86_64".to_string())); + assert_eq!(bash_lock.evr, None); + } + + #[test] + fn test_add_package_nevr() { + let mut lockfile = Lockfile::new(); + + // Test NEVR format (without architecture) + lockfile.add_package("bash-0:5.1.8-6.el9").unwrap(); + + let bash_lock = lockfile.packages.get("bash").unwrap(); + assert_eq!(bash_lock.evr, Some("0:5.1.8-6.el9".to_string())); + assert_eq!(bash_lock.evra, None); + } + + #[test] + fn test_add_package_noarch() { + let mut lockfile = Lockfile::new(); + + // Test noarch package + lockfile + .add_package("python3-pip-21.2.3-6.el9.noarch") + .unwrap(); + + let pip_lock = lockfile.packages.get("python3-pip").unwrap(); + assert_eq!(pip_lock.evra, Some("21.2.3-6.el9.noarch".to_string())); + assert_eq!(pip_lock.evr, None); + } + + #[test] + fn test_invalid_format() { + let mut lockfile = Lockfile::new(); + + // Test invalid format + let result = lockfile.add_package("invalid"); + assert!(result.is_err()); + } +} diff --git a/crates/imagectl/src/manifest.rs b/crates/imagectl/src/manifest.rs new file mode 100644 index 000000000..0fb89d4c4 --- /dev/null +++ b/crates/imagectl/src/manifest.rs @@ -0,0 +1,86 @@ +//! Manifest handling utilities + +use anyhow::{Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use crate::constants::MANIFESTDIR; + +/// Find a manifest file by name, checking both .yaml and .hidden.yaml variants +pub fn find_manifest(name: &str) -> Result { + let manifest_dir = Utf8Path::new("/").join(MANIFESTDIR); + + for suffix in ["yaml", "hidden.yaml"] { + let filename = format!("{}.{}", name, suffix); + let path = manifest_dir.join(&filename); + if path.exists() { + tracing::debug!("Found manifest at: {}", path); + return Ok(path); + } + } + + anyhow::bail!("Manifest not found: {}", name) +} + +/// Manifest override structure that gets serialized to JSON +#[derive(Debug, Serialize, Deserialize)] +pub struct ManifestOverride { + /// Path to the base manifest to include + pub include: String, + + /// Additional packages to install + #[serde(skip_serializing_if = "Option::is_none")] + pub packages: Option>, + + /// OSTree overlay layers + #[serde( + skip_serializing_if = "Option::is_none", + rename = "ostree-override-layers" + )] + pub ostree_override_layers: Option>, + + /// Documentation setting + #[serde(skip_serializing_if = "Option::is_none")] + pub documentation: Option, + + /// Sysusers setting + #[serde(skip_serializing_if = "Option::is_none")] + pub sysusers: Option, + + /// Variables (e.g., passwd_mode) + #[serde(skip_serializing_if = "Option::is_none")] + pub variables: Option>, + + /// Repository overrides + #[serde(skip_serializing_if = "Option::is_none")] + pub repos: Option>, +} + +impl ManifestOverride { + /// Create a new manifest override with the given base manifest path + pub fn new(base_manifest: String) -> Self { + Self { + include: base_manifest, + packages: None, + ostree_override_layers: None, + documentation: None, + sysusers: None, + variables: None, + repos: None, + } + } + + /// Write this override to a temporary JSON file + pub fn write_to_tempfile(&self) -> Result { + let mut tmpfile = tempfile::Builder::new() + .suffix(".json") + .tempfile() + .context("Failed to create temporary manifest file")?; + + serde_json::to_writer_pretty(&mut tmpfile, self) + .context("Failed to write manifest override to temporary file")?; + + Ok(tmpfile) + } +} diff --git a/crates/imagectl/src/rechunk.rs b/crates/imagectl/src/rechunk.rs new file mode 100644 index 000000000..dad2b91af --- /dev/null +++ b/crates/imagectl/src/rechunk.rs @@ -0,0 +1,117 @@ +//! Rechunk container images into content-addressed layers + +use anyhow::{Context, Result}; +use std::process::Command; + +use crate::cli::RechunkOpts; + +/// Rechunk a container image into split, reproducible layers +/// +/// This uses `rpm-ostree experimental compose build-chunked-oci` to create +/// a new container image with content-addressed, reproducible layers. +pub fn rechunk(opts: &RechunkOpts) -> Result<()> { + // Validate inputs + anyhow::ensure!(!opts.from_image.is_empty(), "Source image cannot be empty"); + anyhow::ensure!( + !opts.to_image.is_empty(), + "Destination image cannot be empty" + ); + anyhow::ensure!( + opts.from_image != opts.to_image, + "Source and destination images must be different" + ); + + let mut argv = vec![ + "rpm-ostree".to_string(), + "experimental".to_string(), + "compose".to_string(), + "build-chunked-oci".to_string(), + ]; + + // Add max-layers if specified + if let Some(max_layers) = opts.max_layers { + argv.push(format!("--max-layers={}", max_layers)); + } + + // Add required flags + argv.push("--bootc".to_string()); + argv.push("--format-version=1".to_string()); + argv.push(format!("--from={}", opts.from_image)); + argv.push(format!("--output=containers-storage:{}", opts.to_image)); + + tracing::info!("Rechunking {} -> {}", opts.from_image, opts.to_image); + if let Some(max_layers) = opts.max_layers { + tracing::debug!("Using max-layers: {}", max_layers); + } + tracing::debug!("Executing: {}", argv.join(" ")); + + // Execute rpm-ostree command + // We use inherit for stdio to show rpm-ostree's progress output directly + let status = Command::new(&argv[0]) + .args(&argv[1..]) + .status() + .context("Failed to execute rpm-ostree")?; + + if !status.success() { + anyhow::bail!( + "rpm-ostree command failed with exit code: {}", + status.code().unwrap_or(-1) + ); + } + + tracing::info!("Successfully rechunked image to {}", opts.to_image); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validation_same_images() { + let opts = RechunkOpts { + max_layers: None, + from_image: "test".to_string(), + to_image: "test".to_string(), + }; + + let result = rechunk(&opts); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Source and destination images must be different")); + } + + #[test] + fn test_validation_empty_source() { + let opts = RechunkOpts { + max_layers: None, + from_image: "".to_string(), + to_image: "dest".to_string(), + }; + + let result = rechunk(&opts); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Source image cannot be empty")); + } + + #[test] + fn test_validation_empty_dest() { + let opts = RechunkOpts { + max_layers: None, + from_image: "source".to_string(), + to_image: "".to_string(), + }; + + let result = rechunk(&opts); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Destination image cannot be empty")); + } +} diff --git a/crates/lib/Cargo.toml b/crates/lib/Cargo.toml index cdc278d49..b06b2fb12 100644 --- a/crates/lib/Cargo.toml +++ b/crates/lib/Cargo.toml @@ -15,6 +15,7 @@ include = ["/src", "LICENSE-APACHE", "LICENSE-MIT"] [dependencies] # Internal crates bootc-blockdev = { package = "bootc-internal-blockdev", path = "../blockdev", version = "0.0.0" } +bootc-imagectl = { path = "../imagectl" } bootc-kernel-cmdline = { path = "../kernel_cmdline", version = "0.0.0" } bootc-mount = { path = "../mount" } bootc-sysusers = { path = "../sysusers" } diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 23bae71d9..b5494a8a0 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -665,6 +665,12 @@ pub(crate) enum Opt { /// Stability: This interface may change in the future. #[clap(subcommand, hide = true)] Image(ImageOpts), + /// Build and manipulate bootc container images. + /// + /// Tools for building container root filesystems, rechunking images, + /// and managing build manifests. + #[clap(subcommand, alias = "image-build")] + Imagectl(bootc_imagectl::ImageCtlCmd), /// Execute the given command in the host mount namespace #[clap(hide = true)] ExecInHostMountNamespace { @@ -1472,6 +1478,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> { } } }, + Opt::Imagectl(cmd) => bootc_imagectl::run(&cmd), Opt::Install(opts) => match opts { #[cfg(feature = "install-to-disk")] InstallOpts::ToDisk(opts) => crate::install::install_to_disk(opts).await,