diff --git a/Cargo.lock b/Cargo.lock index d49f32ed5..d7b5f4c6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2640,11 +2640,13 @@ dependencies = [ "indoc", "libtest-mimic", "oci-spec", + "rand 0.9.2", "rexpect", "rustix", "scopeguard", "serde", "serde_json", + "tar", "tempfile", "xshell", ] diff --git a/Justfile b/Justfile index dc865a38f..26acf6446 100644 --- a/Justfile +++ b/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')] diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 5ff4233b6..27751fc8f 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -415,6 +415,40 @@ pub(crate) enum ContainerOpts { #[clap(last = true)] args: Vec, }, + /// 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, + + /// 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; diff --git a/crates/lib/src/container_export.rs b/crates/lib/src/container_export.rs new file mode 100644 index 000000000..fadfdef3a --- /dev/null +++ b/crates/lib/src/container_export.rs @@ -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 = 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( + tar_builder: &mut tar::Builder, + 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( + tar_builder: &mut tar::Builder, + 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> { + 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( + tar_builder: &mut tar::Builder, + 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( + tar_builder: &mut tar::Builder, + 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( + tar_builder: &mut tar::Builder, + 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( + tar_builder: &mut tar::Builder, + 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(tar_builder: &mut tar::Builder, 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 { + 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( + tar_builder: &mut tar::Builder, + 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(()) +} diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index d7e88c3cc..a8e75c86c 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -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; diff --git a/crates/tests-integration/Cargo.toml b/crates/tests-integration/Cargo.toml index a2e66662a..5a96b630e 100644 --- a/crates/tests-integration/Cargo.toml +++ b/crates/tests-integration/Cargo.toml @@ -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 diff --git a/crates/tests-integration/src/container.rs b/crates/tests-integration/src/container.rs index 2063aac2e..3fb92392f 100644 --- a/crates/tests-integration/src/container.rs +++ b/crates/tests-integration/src/container.rs @@ -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)> = 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), ]; diff --git a/docs/src/man/bootc-container-export.8.md b/docs/src/man/bootc-container-export.8.md new file mode 100644 index 000000000..3c68280f3 --- /dev/null +++ b/docs/src/man/bootc-container-export.8.md @@ -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 + + +**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 + + + +# 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 + + diff --git a/docs/src/man/bootc-container.8.md b/docs/src/man/bootc-container.8.md index b8b540972..043af78d9 100644 --- a/docs/src/man/bootc-container.8.md +++ b/docs/src/man/bootc-container.8.md @@ -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 |