mirror of
https://github.com/containers/bootc.git
synced 2026-02-05 06:45:13 +01:00
container: Add export --format=tar command
Some people want to use container build tools, but for compatibility with older systems export a tar format of the OS state e.g. Anaconda liveimg expects this. Basically this is only *slightly* more than just `tar cf`; we need to handle SELinux labeling and move the kernel. Ref: #1957 Assisted-by: OpenCode (Sonnet 4.5) Signed-off-by: Colin Walters <walters@verbum.org>
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -2640,11 +2640,13 @@ dependencies = [
|
||||
"indoc",
|
||||
"libtest-mimic",
|
||||
"oci-spec",
|
||||
"rand 0.9.2",
|
||||
"rexpect",
|
||||
"rustix",
|
||||
"scopeguard",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"xshell",
|
||||
]
|
||||
|
||||
2
Justfile
2
Justfile
@@ -101,7 +101,7 @@ test-tmt *ARGS: build
|
||||
[group('core')]
|
||||
test-container: build build-units
|
||||
podman run --rm --read-only localhost/bootc-units /usr/bin/bootc-units
|
||||
podman run --rm --env=BOOTC_variant={{variant}} --env=BOOTC_base={{base}} {{base_img}} bootc-integration-tests container
|
||||
podman run --rm --env=BOOTC_variant={{variant}} --env=BOOTC_base={{base}} --mount=type=image,source={{base_img}},target=/run/target {{base_img}} bootc-integration-tests container
|
||||
|
||||
# Build and test sealed composefs images
|
||||
[group('core')]
|
||||
|
||||
@@ -415,6 +415,40 @@ pub(crate) enum ContainerOpts {
|
||||
#[clap(last = true)]
|
||||
args: Vec<OsString>,
|
||||
},
|
||||
/// Export container filesystem as a tar archive.
|
||||
///
|
||||
/// This command exports the container filesystem in a bootable format with proper
|
||||
/// SELinux labeling. The output is written to stdout by default or to a specified file.
|
||||
///
|
||||
/// Example:
|
||||
/// bootc container export /target > output.tar
|
||||
Export {
|
||||
/// Format for export output
|
||||
#[clap(long, default_value = "tar")]
|
||||
format: ExportFormat,
|
||||
|
||||
/// Output file (defaults to stdout)
|
||||
#[clap(long, short = 'o')]
|
||||
output: Option<Utf8PathBuf>,
|
||||
|
||||
/// Copy kernel and initramfs from /usr/lib/modules to /boot for legacy compatibility.
|
||||
/// This is useful for installers that expect the kernel in /boot.
|
||||
#[clap(long)]
|
||||
kernel_in_boot: bool,
|
||||
|
||||
/// Disable SELinux labeling in the exported archive.
|
||||
#[clap(long)]
|
||||
disable_selinux: bool,
|
||||
|
||||
/// Path to the container filesystem root
|
||||
target: Utf8PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, ValueEnum, PartialEq, Eq)]
|
||||
pub(crate) enum ExportFormat {
|
||||
/// Export as tar archive
|
||||
Tar,
|
||||
}
|
||||
|
||||
/// Subcommands which operate on images.
|
||||
@@ -1626,6 +1660,22 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
|
||||
kargs,
|
||||
args,
|
||||
} => crate::ukify::build_ukify(&rootfs, &kargs, &args),
|
||||
ContainerOpts::Export {
|
||||
format,
|
||||
target,
|
||||
output,
|
||||
kernel_in_boot,
|
||||
disable_selinux,
|
||||
} => {
|
||||
crate::container_export::export(
|
||||
&format,
|
||||
&target,
|
||||
output.as_deref(),
|
||||
kernel_in_boot,
|
||||
disable_selinux,
|
||||
)
|
||||
.await
|
||||
}
|
||||
},
|
||||
Opt::Completion { shell } => {
|
||||
use clap_complete::aot::generate;
|
||||
|
||||
358
crates/lib/src/container_export.rs
Normal file
358
crates/lib/src/container_export.rs
Normal file
@@ -0,0 +1,358 @@
|
||||
//! # Container Export Functionality
|
||||
//!
|
||||
//! This module implements the `bootc container export` command which exports
|
||||
//! container filesystems as bootable tar archives with proper SELinux labeling
|
||||
//! and legacy boot compatibility.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use camino::Utf8Path;
|
||||
use cap_std_ext::dirext::{CapStdExtDirExt, WalkConfiguration};
|
||||
use fn_error_context::context;
|
||||
use ostree_ext::ostree;
|
||||
use std::fs::File;
|
||||
use std::io::{self, Write};
|
||||
use std::ops::ControlFlow;
|
||||
|
||||
use crate::cli::ExportFormat;
|
||||
|
||||
/// Options for container export.
|
||||
#[derive(Debug, Default)]
|
||||
struct ExportOptions {
|
||||
/// Copy kernel and initramfs to /boot for legacy compatibility.
|
||||
kernel_in_boot: bool,
|
||||
/// Disable SELinux labeling.
|
||||
disable_selinux: bool,
|
||||
}
|
||||
|
||||
/// Export a container filesystem to tar format with bootc-specific features.
|
||||
#[context("Exporting container")]
|
||||
pub(crate) async fn export(
|
||||
format: &ExportFormat,
|
||||
target_path: &Utf8Path,
|
||||
output_path: Option<&Utf8Path>,
|
||||
kernel_in_boot: bool,
|
||||
disable_selinux: bool,
|
||||
) -> Result<()> {
|
||||
use cap_std_ext::cap_std;
|
||||
use cap_std_ext::cap_std::fs::Dir;
|
||||
|
||||
let options = ExportOptions {
|
||||
kernel_in_boot,
|
||||
disable_selinux,
|
||||
};
|
||||
|
||||
let root_dir = Dir::open_ambient_dir(target_path, cap_std::ambient_authority())
|
||||
.with_context(|| format!("Failed to open directory: {}", target_path))?;
|
||||
|
||||
match format {
|
||||
ExportFormat::Tar => export_tar(&root_dir, output_path, &options).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Export container filesystem as tar archive.
|
||||
#[context("Exporting to tar")]
|
||||
async fn export_tar(
|
||||
root_dir: &cap_std_ext::cap_std::fs::Dir,
|
||||
output_path: Option<&Utf8Path>,
|
||||
options: &ExportOptions,
|
||||
) -> Result<()> {
|
||||
let output: Box<dyn Write> = match output_path {
|
||||
Some(path) => {
|
||||
let file = File::create(path)
|
||||
.with_context(|| format!("Failed to create output file: {}", path))?;
|
||||
Box::new(file)
|
||||
}
|
||||
None => Box::new(io::stdout()),
|
||||
};
|
||||
|
||||
let mut tar_builder = tar::Builder::new(output);
|
||||
export_filesystem(&mut tar_builder, root_dir, options)?;
|
||||
tar_builder.finish().context("Finalizing tar archive")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn export_filesystem<W: Write>(
|
||||
tar_builder: &mut tar::Builder<W>,
|
||||
root_dir: &cap_std_ext::cap_std::fs::Dir,
|
||||
options: &ExportOptions,
|
||||
) -> Result<()> {
|
||||
// Load SELinux policy from the image filesystem.
|
||||
// We use the policy to compute labels rather than reading xattrs from the
|
||||
// mounted filesystem, because OCI images don't usually include selinux xattrs,
|
||||
// and the mounted runtime will have e.g. container_t
|
||||
let sepolicy = if options.disable_selinux {
|
||||
None
|
||||
} else {
|
||||
crate::lsm::new_sepolicy_at(root_dir)?
|
||||
};
|
||||
|
||||
export_filesystem_walk(tar_builder, root_dir, sepolicy.as_ref())?;
|
||||
|
||||
if options.kernel_in_boot {
|
||||
handle_kernel_relocation(tar_builder, root_dir)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a tar header from filesystem metadata.
|
||||
fn tar_header_from_meta(
|
||||
entry_type: tar::EntryType,
|
||||
size: u64,
|
||||
meta: &cap_std_ext::cap_std::fs::Metadata,
|
||||
) -> tar::Header {
|
||||
use cap_std_ext::cap_primitives::fs::{MetadataExt, PermissionsExt};
|
||||
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_entry_type(entry_type);
|
||||
header.set_size(size);
|
||||
header.set_mode(meta.permissions().mode() & !libc::S_IFMT);
|
||||
header.set_uid(meta.uid() as u64);
|
||||
header.set_gid(meta.gid() as u64);
|
||||
header
|
||||
}
|
||||
|
||||
/// Create a tar header for a root-owned directory with mode 0755.
|
||||
fn tar_header_dir_root() -> tar::Header {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_entry_type(tar::EntryType::Directory);
|
||||
header.set_size(0);
|
||||
header.set_mode(0o755);
|
||||
header.set_uid(0);
|
||||
header.set_gid(0);
|
||||
header
|
||||
}
|
||||
|
||||
fn export_filesystem_walk<W: Write>(
|
||||
tar_builder: &mut tar::Builder<W>,
|
||||
root_dir: &cap_std_ext::cap_std::fs::Dir,
|
||||
sepolicy: Option<&ostree::SePolicy>,
|
||||
) -> Result<()> {
|
||||
use std::path::Path;
|
||||
|
||||
// The target mount shouldn't have submounts, but just in case we use noxdev
|
||||
let walk_config = WalkConfiguration::default()
|
||||
.noxdev()
|
||||
.path_base(Path::new("/"));
|
||||
|
||||
root_dir.walk(&walk_config, |entry| -> std::io::Result<ControlFlow<()>> {
|
||||
let path = entry.path;
|
||||
|
||||
// Skip the root directory itself - it is meaningless in OCI right now
|
||||
// https://github.com/containers/composefs-rs/pull/209
|
||||
// The root is represented as "/" which has one component
|
||||
if path == Path::new("/") {
|
||||
return Ok(ControlFlow::Continue(()));
|
||||
}
|
||||
|
||||
// Ensure the path is relative by default
|
||||
let relative_path = path.strip_prefix("/").unwrap_or(path);
|
||||
|
||||
// Skip empty paths (shouldn't happen but be safe)
|
||||
if relative_path == Path::new("") {
|
||||
return Ok(ControlFlow::Continue(()));
|
||||
}
|
||||
|
||||
let file_type = entry.file_type;
|
||||
if file_type.is_dir() {
|
||||
add_directory_to_tar_from_walk(tar_builder, entry.dir, path, relative_path, sepolicy)
|
||||
.map_err(std::io::Error::other)?;
|
||||
} else if file_type.is_file() {
|
||||
add_file_to_tar_from_walk(
|
||||
tar_builder,
|
||||
entry.dir,
|
||||
entry.filename,
|
||||
path,
|
||||
relative_path,
|
||||
sepolicy,
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
} else if file_type.is_symlink() {
|
||||
add_symlink_to_tar_from_walk(
|
||||
tar_builder,
|
||||
entry.dir,
|
||||
entry.filename,
|
||||
path,
|
||||
relative_path,
|
||||
sepolicy,
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
} else {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"Unsupported file type: {}",
|
||||
relative_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(ControlFlow::Continue(()))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_directory_to_tar_from_walk<W: Write>(
|
||||
tar_builder: &mut tar::Builder<W>,
|
||||
dir: &cap_std_ext::cap_std::fs::Dir,
|
||||
absolute_path: &std::path::Path,
|
||||
relative_path: &std::path::Path,
|
||||
sepolicy: Option<&ostree::SePolicy>,
|
||||
) -> Result<()> {
|
||||
use cap_std_ext::cap_primitives::fs::PermissionsExt;
|
||||
|
||||
let metadata = dir.dir_metadata()?;
|
||||
let mut header = tar_header_from_meta(tar::EntryType::Directory, 0, &metadata);
|
||||
|
||||
if let Some(policy) = sepolicy {
|
||||
let label = compute_selinux_label(policy, absolute_path, metadata.permissions().mode())?;
|
||||
add_selinux_pax_extension(tar_builder, &label)?;
|
||||
}
|
||||
|
||||
tar_builder
|
||||
.append_data(&mut header, relative_path, &mut std::io::empty())
|
||||
.with_context(|| format!("Failed to add directory: {}", relative_path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_file_to_tar_from_walk<W: Write>(
|
||||
tar_builder: &mut tar::Builder<W>,
|
||||
dir: &cap_std_ext::cap_std::fs::Dir,
|
||||
filename: &std::ffi::OsStr,
|
||||
absolute_path: &std::path::Path,
|
||||
relative_path: &std::path::Path,
|
||||
sepolicy: Option<&ostree::SePolicy>,
|
||||
) -> Result<()> {
|
||||
use cap_std_ext::cap_primitives::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
|
||||
let filename_path = Path::new(filename);
|
||||
let metadata = dir.metadata(filename_path)?;
|
||||
let mut header = tar_header_from_meta(tar::EntryType::Regular, metadata.len(), &metadata);
|
||||
|
||||
if let Some(policy) = sepolicy {
|
||||
let label = compute_selinux_label(policy, absolute_path, metadata.permissions().mode())?;
|
||||
add_selinux_pax_extension(tar_builder, &label)?;
|
||||
}
|
||||
|
||||
let mut file = dir.open(filename_path)?;
|
||||
tar_builder
|
||||
.append_data(&mut header, relative_path, &mut file)
|
||||
.with_context(|| format!("Failed to add file: {}", relative_path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_symlink_to_tar_from_walk<W: Write>(
|
||||
tar_builder: &mut tar::Builder<W>,
|
||||
dir: &cap_std_ext::cap_std::fs::Dir,
|
||||
filename: &std::ffi::OsStr,
|
||||
absolute_path: &std::path::Path,
|
||||
relative_path: &std::path::Path,
|
||||
sepolicy: Option<&ostree::SePolicy>,
|
||||
) -> Result<()> {
|
||||
use cap_std_ext::cap_primitives::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
|
||||
let filename_path = Path::new(filename);
|
||||
let link_target = dir
|
||||
.read_link_contents(filename_path)
|
||||
.with_context(|| format!("Failed to read symlink: {:?}", filename))?;
|
||||
let metadata = dir.symlink_metadata(filename_path)?;
|
||||
let mut header = tar_header_from_meta(tar::EntryType::Symlink, 0, &metadata);
|
||||
|
||||
if let Some(policy) = sepolicy {
|
||||
// For symlinks, combine S_IFLNK with mode for proper label lookup
|
||||
let symlink_mode = libc::S_IFLNK | (metadata.permissions().mode() & !libc::S_IFMT);
|
||||
let label = compute_selinux_label(policy, absolute_path, symlink_mode)?;
|
||||
add_selinux_pax_extension(tar_builder, &label)?;
|
||||
}
|
||||
|
||||
tar_builder
|
||||
.append_link(&mut header, relative_path, &link_target)
|
||||
.with_context(|| format!("Failed to add symlink: {}", relative_path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy kernel and initramfs to /boot for legacy installers (e.g. Anaconda liveimg).
|
||||
fn handle_kernel_relocation<W: Write>(
|
||||
tar_builder: &mut tar::Builder<W>,
|
||||
root_dir: &cap_std_ext::cap_std::fs::Dir,
|
||||
) -> Result<()> {
|
||||
let kernel_info = match crate::kernel::find_kernel(root_dir)? {
|
||||
Some(kernel) => kernel,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
append_dir_entry(tar_builder, "boot")?;
|
||||
append_dir_entry(tar_builder, "boot/grub2")?;
|
||||
|
||||
// UKIs don't need relocation
|
||||
if kernel_info.kernel.unified {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(vmlinuz_path) = &kernel_info.vmlinuz {
|
||||
if root_dir.try_exists(vmlinuz_path)? {
|
||||
let metadata = root_dir.metadata(vmlinuz_path)?;
|
||||
let mut header =
|
||||
tar_header_from_meta(tar::EntryType::Regular, metadata.len(), &metadata);
|
||||
let mut file = root_dir.open(vmlinuz_path)?;
|
||||
let boot_path = format!("boot/vmlinuz-{}", kernel_info.kernel.version);
|
||||
tar_builder
|
||||
.append_data(&mut header, &boot_path, &mut file)
|
||||
.with_context(|| format!("Failed to add kernel: {}", boot_path))?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(initramfs_path) = &kernel_info.initramfs {
|
||||
if root_dir.try_exists(initramfs_path)? {
|
||||
let metadata = root_dir.metadata(initramfs_path)?;
|
||||
let mut header =
|
||||
tar_header_from_meta(tar::EntryType::Regular, metadata.len(), &metadata);
|
||||
let mut file = root_dir.open(initramfs_path)?;
|
||||
let boot_path = format!("boot/initramfs-{}.img", kernel_info.kernel.version);
|
||||
tar_builder
|
||||
.append_data(&mut header, &boot_path, &mut file)
|
||||
.with_context(|| format!("Failed to add initramfs: {}", boot_path))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_dir_entry<W: Write>(tar_builder: &mut tar::Builder<W>, path: &str) -> Result<()> {
|
||||
let mut header = tar_header_dir_root();
|
||||
tar_builder
|
||||
.append_data(&mut header, path, &mut std::io::empty())
|
||||
.with_context(|| format!("Failed to create {} directory", path))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compute_selinux_label(
|
||||
policy: &ostree::SePolicy,
|
||||
path: &std::path::Path,
|
||||
mode: u32,
|
||||
) -> Result<String> {
|
||||
use camino::Utf8Path;
|
||||
|
||||
// Convert path to UTF-8 for policy lookup - non-UTF8 paths are not supported
|
||||
let path_str = path
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Non-UTF8 path not supported: {:?}", path))?;
|
||||
let utf8_path = Utf8Path::new(path_str);
|
||||
|
||||
let label = crate::lsm::require_label(policy, utf8_path, mode)?;
|
||||
Ok(label.to_string())
|
||||
}
|
||||
|
||||
fn add_selinux_pax_extension<W: Write>(
|
||||
tar_builder: &mut tar::Builder<W>,
|
||||
selinux_context: &str,
|
||||
) -> Result<()> {
|
||||
tar_builder
|
||||
.append_pax_extensions([("SCHILY.xattr.security.selinux", selinux_context.as_bytes())])
|
||||
.context("Failed to add SELinux PAX extension")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -70,6 +70,7 @@ mod boundimage;
|
||||
mod cfsctl;
|
||||
pub mod cli;
|
||||
mod composefs_consts;
|
||||
mod container_export;
|
||||
mod containerenv;
|
||||
pub(crate) mod deploy;
|
||||
mod discoverable_partition_specification;
|
||||
|
||||
@@ -28,8 +28,10 @@ bootc-kernel-cmdline = { path = "../kernel_cmdline", version = "0.0.0" }
|
||||
# Crate-specific dependencies
|
||||
libtest-mimic = "0.8.0"
|
||||
oci-spec = "0.8.0"
|
||||
rand = "0.9"
|
||||
rexpect = "0.6"
|
||||
scopeguard = "1.2.0"
|
||||
tar = "0.4"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -183,6 +183,93 @@ fn test_variant_base_crosscheck() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verify exported tar has correct size/mode/content vs source.
|
||||
/// Checks all critical paths (kernel, boot) plus ~10% random sample.
|
||||
pub(crate) fn test_container_export_tar() -> Result<()> {
|
||||
use rand::{Rng, SeedableRng};
|
||||
use std::io::Read;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
const TARGET: &str = "/run/target";
|
||||
const CRITICAL: &[&str] = &["usr/lib/modules/", "usr/lib/ostree-boot/", "boot/"];
|
||||
|
||||
anyhow::ensure!(
|
||||
std::path::Path::new(TARGET).exists(),
|
||||
"Test requires image mounted at {TARGET}"
|
||||
);
|
||||
|
||||
let td = tempfile::tempdir()?;
|
||||
let tar_path = td.path().join("export.tar");
|
||||
let tar_str = tar_path.to_str().unwrap();
|
||||
|
||||
let sh = Shell::new()?;
|
||||
cmd!(
|
||||
sh,
|
||||
"bootc container export --format=tar -o {tar_str} {TARGET}"
|
||||
)
|
||||
.run()?;
|
||||
|
||||
// Collect tar entries: path -> (size, mode, first 4KB content)
|
||||
let mut entries: Vec<(String, u64, u32, Vec<u8>)> = Vec::new();
|
||||
for entry in tar::Archive::new(fs::File::open(&tar_path)?).entries()? {
|
||||
let mut entry = entry?;
|
||||
let header = entry.header();
|
||||
if header.entry_type() != tar::EntryType::Regular {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path()?.to_string_lossy().into_owned();
|
||||
let size: u64 = header.size()?;
|
||||
let mode = header.mode()?;
|
||||
let sample_len = usize::try_from(size).unwrap_or(usize::MAX).min(4096);
|
||||
let mut sample = vec![0u8; sample_len];
|
||||
entry.read_exact(&mut sample)?;
|
||||
entries.push((path, size, mode, sample));
|
||||
}
|
||||
assert!(entries.len() > 100, "too few files: {}", entries.len());
|
||||
|
||||
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
|
||||
let (mut verified, mut critical_count) = (0, 0);
|
||||
|
||||
for (path, tar_size, tar_mode, tar_sample) in &entries {
|
||||
let is_critical = CRITICAL.iter().any(|p| path.contains(p));
|
||||
if !is_critical && !rng.random_bool(0.1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let src = std::path::Path::new(TARGET).join(path);
|
||||
let Ok(meta) = src.symlink_metadata() else {
|
||||
continue;
|
||||
};
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
|
||||
assert_eq!(*tar_size, meta.len(), "{path}: size mismatch");
|
||||
assert_eq!(
|
||||
tar_mode & 0o7777,
|
||||
meta.mode() & 0o7777,
|
||||
"{path}: mode mismatch"
|
||||
);
|
||||
|
||||
let mut src_sample = vec![0u8; tar_sample.len()];
|
||||
fs::File::open(&src)?.read_exact(&mut src_sample)?;
|
||||
assert_eq!(tar_sample, &src_sample, "{path}: content mismatch");
|
||||
|
||||
verified += 1;
|
||||
if is_critical {
|
||||
critical_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(verified >= 50, "only verified {verified} files");
|
||||
assert!(critical_count >= 5, "only {critical_count} critical files");
|
||||
eprintln!(
|
||||
"Verified {verified}/{} files ({critical_count} critical)",
|
||||
entries.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that compute-composefs-digest works on a directory
|
||||
pub(crate) fn test_compute_composefs_digest() -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -249,6 +336,7 @@ pub(crate) fn run(testargs: libtest_mimic::Arguments) -> Result<()> {
|
||||
new_test("status", test_bootc_status),
|
||||
new_test("container inspect", test_bootc_container_inspect),
|
||||
new_test("system-reinstall --help", test_system_reinstall_help),
|
||||
new_test("container export tar", test_container_export_tar),
|
||||
new_test("compute-composefs-digest", test_compute_composefs_digest),
|
||||
];
|
||||
|
||||
|
||||
187
docs/src/man/bootc-container-export.8.md
Normal file
187
docs/src/man/bootc-container-export.8.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# NAME
|
||||
|
||||
bootc-container-export - Export container filesystem as a tar archive
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
bootc container export [OPTIONS] TARGET
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
Export container filesystem as a tar archive.
|
||||
|
||||
This command exports a container filesystem in a format suitable for
|
||||
unpacking onto a target system. The output includes proper SELinux
|
||||
labeling (if available) and can optionally relocate the kernel to /boot
|
||||
for compatibility with legacy installers like Anaconda's `liveimg` command.
|
||||
|
||||
The primary use case is enabling container-built OS images to be installed
|
||||
via traditional installer mechanisms that don't natively support OCI containers.
|
||||
|
||||
# OPTIONS
|
||||
|
||||
<!-- BEGIN GENERATED OPTIONS -->
|
||||
**TARGET**
|
||||
|
||||
Path to the container filesystem root
|
||||
|
||||
This argument is required.
|
||||
|
||||
**--format**=*FORMAT*
|
||||
|
||||
Format for export output
|
||||
|
||||
Possible values:
|
||||
- tar
|
||||
|
||||
Default: tar
|
||||
|
||||
**-o**, **--output**=*OUTPUT*
|
||||
|
||||
Output file (defaults to stdout)
|
||||
|
||||
**--kernel-in-boot**
|
||||
|
||||
Copy kernel and initramfs from /usr/lib/modules to /boot for legacy compatibility. This is useful for installers that expect the kernel in /boot
|
||||
|
||||
**--disable-selinux**
|
||||
|
||||
Disable SELinux labeling in the exported archive
|
||||
|
||||
<!-- END GENERATED OPTIONS -->
|
||||
|
||||
# EXAMPLES
|
||||
|
||||
Export a mounted container image to a tar file:
|
||||
|
||||
bootc container export /run/target -o /output/rootfs.tar
|
||||
|
||||
Export to stdout and pipe to another command:
|
||||
|
||||
bootc container export /run/target | tar -C /mnt -xf -
|
||||
|
||||
Export with kernel relocation for legacy installers:
|
||||
|
||||
bootc container export --kernel-in-boot /run/target -o rootfs.tar
|
||||
|
||||
Using podman to mount and export an image:
|
||||
|
||||
podman run --rm \
|
||||
--mount=type=image,source=quay.io/fedora/fedora-bootc:42,target=/run/target \
|
||||
quay.io/fedora/fedora-bootc:42 \
|
||||
bootc container export --kernel-in-boot -o /output/rootfs.tar /run/target
|
||||
|
||||
# ANACONDA LIVEIMG INTEGRATION
|
||||
|
||||
The tar export can be used with Anaconda's `liveimg` kickstart command to install
|
||||
bootc-built images on systems without native bootc support in the installer.
|
||||
|
||||
## Important Considerations
|
||||
|
||||
**This creates a traditional filesystem install, NOT a full bootc system.**
|
||||
The installed system will:
|
||||
|
||||
- Have the filesystem contents from the container image
|
||||
- Boot with a standard GRUB setup
|
||||
- NOT have ostree/bootc infrastructure for atomic updates
|
||||
|
||||
For full bootc functionality, use `bootc install` or Anaconda's native `bootc`
|
||||
kickstart command (available in Fedora 43+).
|
||||
|
||||
## Required Kickstart Configuration
|
||||
|
||||
When using the exported tar with Anaconda's `liveimg`, several kickstart
|
||||
options are required for a successful installation:
|
||||
|
||||
### Bootloader Handling
|
||||
|
||||
Anaconda's bootloader installation doesn't work correctly with bootc images.
|
||||
Use `bootloader --location=none` to skip Anaconda's bootloader setup, then
|
||||
install the bootloader via bootupd in a %post script:
|
||||
|
||||
```
|
||||
bootloader --location=none
|
||||
|
||||
%post --erroronfail
|
||||
# Install bootloader via bootupd (the bootc way)
|
||||
BOOT_DISK=$(lsblk -no PKNAME $(findmnt -no SOURCE /) | head -1)
|
||||
bootupctl backend install --auto --write-uuid --device /dev/$BOOT_DISK /
|
||||
%end
|
||||
```
|
||||
|
||||
### Installer Boot Options
|
||||
|
||||
Add these to the installer kernel command line:
|
||||
|
||||
- `inst.nosave=all_ks` - Prevents Anaconda from writing to /root (which may not exist)
|
||||
- `inst.ks=cdrom:/kickstart.ks` - Path to kickstart on the installation media
|
||||
|
||||
## Example Kickstart
|
||||
|
||||
Here is a complete example kickstart for installing a bootc image via liveimg.
|
||||
This assumes the tar file is accessible at a URL (adjust for your environment):
|
||||
|
||||
```
|
||||
# Install from bootc-exported tar
|
||||
liveimg --url=http://example.com/bootc-export.tar
|
||||
|
||||
# Basic configuration
|
||||
rootpw --plaintext changeme
|
||||
keyboard us
|
||||
timezone UTC
|
||||
|
||||
# Skip Anaconda bootloader - use bootupd in %post
|
||||
bootloader --location=none
|
||||
zerombr
|
||||
clearpart --all --initlabel
|
||||
|
||||
# UEFI partitioning
|
||||
part /boot/efi --fstype=efi --size=600
|
||||
part /boot --fstype=xfs --size=1024
|
||||
part / --fstype=xfs --grow
|
||||
|
||||
reboot
|
||||
|
||||
%post --erroronfail
|
||||
set -euo pipefail
|
||||
|
||||
# Install bootloader via bootupd
|
||||
BOOT_DISK=$(lsblk -no PKNAME $(findmnt -no SOURCE /) | head -1)
|
||||
if [ -z "$BOOT_DISK" ]; then
|
||||
BOOT_DISK="sda"
|
||||
fi
|
||||
bootupctl backend install --auto --write-uuid --device /dev/$BOOT_DISK /
|
||||
|
||||
# Create BLS entries for installed kernels
|
||||
mkdir -p /boot/loader/entries
|
||||
ROOT_UUID=$(findmnt -no UUID /)
|
||||
|
||||
if [ ! -f /etc/machine-id ] || [ ! -s /etc/machine-id ]; then
|
||||
systemd-machine-id-setup
|
||||
fi
|
||||
MACHINE_ID=$(cat /etc/machine-id)
|
||||
|
||||
for VMLINUZ in /boot/vmlinuz-*; do
|
||||
[ -f "$VMLINUZ" ] || continue
|
||||
KVER=$(basename "$VMLINUZ" | sed 's/vmlinuz-//')
|
||||
INITRAMFS="/boot/initramfs-${KVER}.img"
|
||||
[ -f "$INITRAMFS" ] || continue
|
||||
|
||||
cat > "/boot/loader/entries/${MACHINE_ID}-${KVER}.conf" << EOF
|
||||
title Fedora Linux ($KVER)
|
||||
version $KVER
|
||||
linux /vmlinuz-$KVER
|
||||
initrd /initramfs-${KVER}.img
|
||||
options root=UUID=$ROOT_UUID ro
|
||||
EOF
|
||||
done
|
||||
%end
|
||||
```
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
**bootc**(8), **bootc-container**(8), **bootc-install**(8), **bootupctl**(8)
|
||||
|
||||
# VERSION
|
||||
|
||||
<!-- VERSION PLACEHOLDER -->
|
||||
@@ -22,6 +22,7 @@ Operations which can be executed as part of a container build
|
||||
| **bootc container inspect** | Output information about the container image |
|
||||
| **bootc container lint** | Perform relatively inexpensive static analysis checks as part of a container build |
|
||||
| **bootc container ukify** | Build a Unified Kernel Image (UKI) using ukify |
|
||||
| **bootc container export** | Export container filesystem as a tar archive |
|
||||
|
||||
<!-- END GENERATED SUBCOMMANDS -->
|
||||
|
||||
|
||||
Reference in New Issue
Block a user