1
0
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:
Colin Walters
2026-01-30 16:54:24 +00:00
parent 51dabaa5cb
commit 46b0bac421
9 changed files with 690 additions and 1 deletions

2
Cargo.lock generated
View File

@@ -2640,11 +2640,13 @@ dependencies = [
"indoc",
"libtest-mimic",
"oci-spec",
"rand 0.9.2",
"rexpect",
"rustix",
"scopeguard",
"serde",
"serde_json",
"tar",
"tempfile",
"xshell",
]

View File

@@ -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')]

View File

@@ -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;

View 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(())
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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),
];

View 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 -->

View File

@@ -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 -->