Skip to content

Commit 05c26e2

Browse files
committed
install/flat: Use composefs pipeline for rootfs
--flat install implementation will go through the composefs repository. This gives several advantages: - SELinux labeling is handled by composefs-rs (selabel), which applies labels from the image's file_contexts rather than inheriting the running container's labels. - The kernel installation flow is reused from composefs-rs (get_boot_resources / UsrLibModulesVmlinuz). - The composefs repo is preserved at /sysroot/composefs, making it easy to convert to immutable/bootc mode later. Users who don't want the metadata overhead can rm -rf /sysroot/composefs. - write_to_path supports reflink copies on btrfs/XFS, sharing blocks with the composefs object store. - --source-imgref now works with --flat (the in_host_mountns guard is removed since we pull via the image reference, not via cp /). Signed-off-by: Eric Curtin <eric.curtin@docker.com>
1 parent 37a0427 commit 05c26e2

7 files changed

Lines changed: 393 additions & 18 deletions

File tree

ADOPTERS.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77

88
| Type | Name | Since | Website | Use-Case |
99
|:-|:-|:-|:-|:-|
10-
Vendor | Red Hat | 2024 | https://redhat.com | Image Based Linux
11-
Vendor | HeliumOS | 2024 | https://www.heliumos.org/ | An atomic desktop operating system for your devices
12-
Vendor | AlmaLinux (Atomic SIG) | 2025 | [atomic-desktop](https://github.com/AlmaLinux/atomic-desktop), [atomic-workstation](https://github.com/AlmaLinux/atomic-workstation) | Atomic AlmaLinux desktop respins
13-
Vendor | Caligra | 2025 | [workbench](https://caligra.com/workbench/) | An OS designed to accelerate knowledge work
14-
Vendor | CIQ | 2026 | https://ciq.com | Rocky Linux from CIQ (RLC) - Image Based Linux - Standard and Cloud variants
10+
| Vendor | Red Hat | 2024 | https://redhat.com | Image Based Linux |
11+
| Vendor | HeliumOS | 2024 | https://www.heliumos.org/ | An atomic desktop operating system for your devices |
12+
| Vendor | AlmaLinux (Atomic SIG) | 2025 | [atomic-desktop](https://github.com/AlmaLinux/atomic-desktop), [atomic-workstation](https://github.com/AlmaLinux/atomic-workstation) | Atomic AlmaLinux desktop respins |
13+
| Vendor | Caligra | 2025 | [workbench](https://caligra.com/workbench/) | An OS designed to accelerate knowledge work |
14+
| Vendor | CIQ | 2026 | https://ciq.com | Rocky Linux from CIQ (RLC) - Image Based Linux - Standard and Cloud variants |
1515

1616
# bootc Adopters (indirect, via ostree)
1717

@@ -24,13 +24,13 @@ is to be the successor to ostree, and it is our aim to seamlessly carry forward
2424

2525
| Type | Name | Since | Website | Use-Case |
2626
|:-|:-|:-|:-|:-|
27-
| Vendor | Endless | 2014 | [link](https://www.endlessos.org/os) | A Completely Free, User-Friendly Operating System Packed with Educational Tools, Games, and More
28-
| Vendor | Red Hat | 2015 | [link](https://redhat.com) | Image Based Linux
29-
| Vendor | Apertis | 2020 | [link](https://apertis.org) | Collaborative OS platform for products
30-
| Vendor | Fedora Project | 2021 | [link](https://fedoraproject.org/atomic-desktops/) | An atomic desktop operating system aimed at good support for container-focused workflows
27+
| Vendor | Endless | 2014 | [link](https://www.endlessos.org/os) | A Completely Free, User-Friendly Operating System Packed with Educational Tools, Games, and More |
28+
| Vendor | Red Hat | 2015 | [link](https://redhat.com) | Image Based Linux |
29+
| Vendor | Apertis | 2020 | [link](https://apertis.org) | Collaborative OS platform for products |
30+
| Vendor | Fedora Project | 2021 | [link](https://fedoraproject.org/atomic-desktops/) | An atomic desktop operating system aimed at good support for container-focused workflows |
3131
| Vendor | Playtron GameOS | 2022 | [link](https://www.playtron.one/) | A video game console OS that has integration with the top PC game stores |
32-
| Vendor | Universal Blue | 2022 | [link](https://universal-blue.org/) | The reliability of a Chromebook, but with the flexibility and power of a traditional Linux desktop
33-
| Vendor | Fyra Labs | 2024 | [link](https://fyralabs.com) | Bootc powers an experimental variant of Ultramarine Linux
32+
| Vendor | Universal Blue | 2022 | [link](https://universal-blue.org/) | The reliability of a Chromebook, but with the flexibility and power of a traditional Linux desktop |
33+
| Vendor | Fyra Labs | 2024 | [link](https://fyralabs.com) | Bootc powers an experimental variant of Ultramarine Linux |
3434

3535
### Adopter Types
3636

crates/lib/src/install.rs

Lines changed: 273 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ use aleph::InstallAleph;
162162
use anyhow::{Context, Result, anyhow, ensure};
163163
use bootc_kernel_cmdline::utf8::{Cmdline, CmdlineOwned};
164164
use bootc_utils::CommandRunExt;
165+
use composefs::fs::{read_file, write_to_path};
166+
use composefs_boot::bootloader::{BootEntry, get_boot_resources};
167+
use composefs_boot::selabel;
168+
use composefs_oci::image::create_filesystem as create_composefs_filesystem;
165169
use camino::Utf8Path;
166170
use camino::Utf8PathBuf;
167171
use canon_json::CanonJsonSerialize;
@@ -238,6 +242,9 @@ const DEFAULT_REPO_CONFIG: &[(&str, &str)] = &[
238242
/// Kernel argument used to specify we want the rootfs mounted read-write by default
239243
pub(crate) const RW_KARG: &str = "rw";
240244

245+
/// Marker file written to the target root to indicate a flat (non-ostree) install was performed.
246+
pub(crate) const FLAT_INSTALL_MARKER: &str = "etc/.bootc-flat";
247+
241248
#[derive(clap::Args, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
242249
pub(crate) struct InstallTargetOpts {
243250
// TODO: A size specifier which allocates free space for the root in *addition* to the base container image size
@@ -488,6 +495,15 @@ pub(crate) struct InstallTargetFilesystemOpts {
488495
/// is then the responsibility of the invoking code to perform those operations.
489496
#[clap(long)]
490497
pub(crate) skip_finalize: bool,
498+
499+
/// Install in "flat" mode: the container rootfs is written to the target filesystem
500+
/// as a regular writable directory tree (no composefs overlay at boot time). This is
501+
/// experimental. Post-install, bootc day-2 operations (upgrade, rollback, etc.) are
502+
/// unavailable on the installed system. A composefs repository is created at
503+
/// `/sysroot/composefs` as an intermediate step; it can be removed post-install or
504+
/// retained to enable future conversion to immutable mode.
505+
#[clap(long)]
506+
pub(crate) flat: bool,
491507
}
492508

493509
#[derive(Debug, Clone, clap::Parser, PartialEq, Eq)]
@@ -1286,6 +1302,8 @@ pub(crate) struct RootSetup {
12861302
skip_finalize: bool,
12871303
boot: Option<MountSpec>,
12881304
pub(crate) kargs: CmdlineOwned,
1305+
/// If true, perform a flat installation (no ostree/composefs)
1306+
pub(crate) flat: bool,
12891307
}
12901308

12911309
fn require_boot_uuid(spec: &MountSpec) -> Result<&str> {
@@ -1818,16 +1836,17 @@ async fn install_with_sysroot(
18181836

18191837
if cfg!(target_arch = "s390x") {
18201838
// TODO: Integrate s390x support into install_via_bootupd
1821-
crate::bootloader::install_via_zipl(&rootfs.device_info, boot_uuid)?;
1839+
install_bootloader_via_zipl(&rootfs.device_info, boot_uuid)?;
18221840
} else {
18231841
match postfetch.detected_bootloader {
18241842
Bootloader::Grub => {
1825-
crate::bootloader::install_via_bootupd(
1843+
let target_root = rootfs
1844+
.target_root_path
1845+
.as_ref()
1846+
.unwrap_or(&rootfs.physical_root_path);
1847+
install_bootloader_via_bootupd(
18261848
&rootfs.device_info,
1827-
&rootfs
1828-
.target_root_path
1829-
.clone()
1830-
.unwrap_or(rootfs.physical_root_path.clone()),
1849+
target_root,
18311850
&state.config_opts,
18321851
Some(&deployment_path.as_str()),
18331852
)?;
@@ -1863,6 +1882,38 @@ async fn install_with_sysroot(
18631882
Ok(())
18641883
}
18651884

1885+
/// Install the bootloader using bootupd.
1886+
///
1887+
/// This is a helper to reduce duplication between ostree and flat installs.
1888+
///
1889+
/// # Arguments
1890+
/// * `device_info` - Device information for the target
1891+
/// * `target_root` - Path to the target root filesystem
1892+
/// * `config_opts` - Configuration options
1893+
/// * `deployment_path` - Optional deployment path for ostree-based installs
1894+
#[context("Installing bootloader via bootupd")]
1895+
fn install_bootloader_via_bootupd(
1896+
device_info: &bootc_blockdev::Device,
1897+
target_root: &Utf8PathBuf,
1898+
config_opts: &InstallConfigOpts,
1899+
deployment_path: Option<&str>,
1900+
) -> Result<()> {
1901+
crate::bootloader::install_via_bootupd(device_info, target_root, config_opts, deployment_path)
1902+
}
1903+
1904+
/// Install the bootloader using zipl (s390x architecture).
1905+
///
1906+
/// # Arguments
1907+
/// * `device_info` - Device information for the target
1908+
/// * `boot_uuid` - Boot UUID (required for zipl)
1909+
#[context("Installing bootloader via zipl")]
1910+
fn install_bootloader_via_zipl(
1911+
device_info: &bootc_blockdev::Device,
1912+
boot_uuid: &str,
1913+
) -> Result<()> {
1914+
crate::bootloader::install_via_zipl(device_info, boot_uuid)
1915+
}
1916+
18661917
enum BoundImages {
18671918
Skip,
18681919
Resolved(Vec<ResolvedBoundImage>),
@@ -1899,6 +1950,162 @@ impl BoundImages {
18991950
}
19001951
}
19011952

1953+
/// Write a BLS entry for a flat (non-ostree) installation.
1954+
#[context("Creating flat BLS entry")]
1955+
fn create_flat_bls_entry(
1956+
rootfs: &RootSetup,
1957+
kernel_version: &str,
1958+
vmlinuz_boot_path: &Utf8PathBuf,
1959+
initramfs_boot_path: &Utf8PathBuf,
1960+
) -> Result<()> {
1961+
use crate::parsers::bls_config::{BLSConfig, BLSConfigType};
1962+
1963+
let mut cfg = BLSConfig::default();
1964+
cfg.with_title(format!("Linux {kernel_version}"))
1965+
.with_version(kernel_version.to_string())
1966+
.with_cfg(BLSConfigType::NonEFI {
1967+
linux: vmlinuz_boot_path.clone(),
1968+
initrd: vec![initramfs_boot_path.clone()],
1969+
options: Some(rootfs.kargs.clone()),
1970+
});
1971+
1972+
let entry_path = format!("boot/loader/entries/flat-{kernel_version}.conf");
1973+
rootfs
1974+
.physical_root
1975+
.create_dir_all("boot/loader/entries")
1976+
.context("Creating boot/loader/entries")?;
1977+
1978+
let content = format!("{cfg}");
1979+
rootfs
1980+
.physical_root
1981+
.atomic_write(&entry_path, content.as_bytes())
1982+
.with_context(|| format!("Writing BLS entry {entry_path}"))?;
1983+
1984+
tracing::debug!("Wrote BLS entry: {entry_path}");
1985+
Ok(())
1986+
}
1987+
1988+
/// Perform a flat (non-ostree) installation.
1989+
///
1990+
/// The approach here is to:
1991+
/// 1. Pull the image into a composefs repository at `composefs/` on the target.
1992+
/// This reuses composefs-rs's SELinux labeling support and kernel installation flow.
1993+
/// The composefs repo is preserved at `/sysroot/composefs`; users who want to convert
1994+
/// to immutable composefs mode later can do so, and anyone who doesn't want the extra
1995+
/// metadata can simply `rm -rf /sysroot/composefs`.
1996+
/// 2. Check out the filesystem from the composefs repo to the target using `write_to_path`,
1997+
/// which supports reflink copies on filesystems that enable it (btrfs, XFS).
1998+
/// 3. Write the kernel and initramfs to `/boot/` from the composefs repo objects.
1999+
/// 4. Create a standard BLS entry pointing at `root=UUID=...` (not a composefs overlay).
2000+
#[context("Performing flat install")]
2001+
async fn flat_install(state: &State, rootfs: &RootSetup) -> Result<()> {
2002+
println!("Installing in flat mode (experimental)");
2003+
2004+
// Step 1: Pull the image into the composefs repository on the target.
2005+
// allow_missing_fsverity=true because we don't use fsverity at boot time in flat mode.
2006+
let (image_id, _verity) =
2007+
initialize_composefs_repository(state, rootfs, true).await?;
2008+
2009+
// Step 2: Build the filesystem tree from the pulled image.
2010+
let repo = crate::bootc_composefs::repo::open_composefs_repo(&rootfs.physical_root)?;
2011+
let mut fs = create_composefs_filesystem(&repo, &image_id, None)?;
2012+
2013+
// Step 3: Apply SELinux labels from the image's file_contexts.
2014+
// Returns true if a policy was found and labels applied, false if no policy was found.
2015+
let _ = selabel::selabel(&mut fs, &repo).context("Applying SELinux labels")?;
2016+
2017+
// Step 4: Extract kernel/initramfs boot entries from the composefs tree.
2018+
let boot_entries = get_boot_resources(&fs, &repo)?;
2019+
let vmlinuz_entry = boot_entries
2020+
.into_iter()
2021+
.find_map(|e| match e {
2022+
BootEntry::UsrLibModulesVmLinuz(v) => Some(v),
2023+
_ => None,
2024+
})
2025+
.ok_or_else(|| anyhow!("No vmlinuz kernel found in flat install image"))?;
2026+
let kernel_version = vmlinuz_entry.kver.as_ref().to_owned();
2027+
2028+
// Step 5: Check out the filesystem to the target directory.
2029+
// On reflink-capable filesystems (btrfs, XFS) this efficiently shares blocks
2030+
// with the composefs object store.
2031+
let target_std = rootfs.physical_root_path.as_std_path().to_owned();
2032+
tokio::task::block_in_place(|| write_to_path(&repo, &fs.root, &target_std))
2033+
.context("Checking out container rootfs to target")?;
2034+
2035+
// Step 6: Write vmlinuz and initramfs to /boot/ on the target.
2036+
let vmlinuz_dest = format!("boot/vmlinuz-{kernel_version}");
2037+
rootfs
2038+
.physical_root
2039+
.create_dir_all("boot")
2040+
.context("Creating boot directory")?;
2041+
rootfs
2042+
.physical_root
2043+
.atomic_write(
2044+
&vmlinuz_dest,
2045+
read_file(&vmlinuz_entry.vmlinuz, &repo)
2046+
.context("Reading vmlinuz from composefs repo")?
2047+
.as_ref(),
2048+
)
2049+
.with_context(|| format!("Writing {vmlinuz_dest}"))?;
2050+
2051+
let initramfs_boot_path = Utf8PathBuf::from(format!("/boot/initramfs-{kernel_version}.img"));
2052+
if let Some(initramfs) = &vmlinuz_entry.initramfs {
2053+
let initramfs_dest = format!("boot/initramfs-{kernel_version}.img");
2054+
rootfs
2055+
.physical_root
2056+
.atomic_write(
2057+
&initramfs_dest,
2058+
read_file(initramfs, &repo)
2059+
.context("Reading initramfs from composefs repo")?
2060+
.as_ref(),
2061+
)
2062+
.with_context(|| format!("Writing {initramfs_dest}"))?;
2063+
} else {
2064+
crate::utils::medium_visibility_warning(
2065+
"No initramfs found in image; boot may require manual initramfs generation",
2066+
);
2067+
}
2068+
2069+
// Step 7: Create BLS entry.
2070+
let vmlinuz_boot_path = Utf8PathBuf::from(format!("/boot/vmlinuz-{kernel_version}"));
2071+
create_flat_bls_entry(rootfs, &kernel_version, &vmlinuz_boot_path, &initramfs_boot_path)?;
2072+
2073+
// Step 8: Install bootloader.
2074+
match state.config_opts.bootloader.as_ref() {
2075+
Some(crate::spec::Bootloader::None) => {
2076+
tracing::debug!("Skipping bootloader installation (bootloader=none)");
2077+
}
2078+
_ => {
2079+
if cfg!(target_arch = "s390x") {
2080+
let boot_uuid = rootfs
2081+
.get_boot_uuid()?
2082+
.or(rootfs.rootfs_uuid.as_deref())
2083+
.ok_or_else(|| anyhow!("No uuid for boot/root"))?;
2084+
install_bootloader_via_zipl(&rootfs.device_info, boot_uuid)?;
2085+
} else {
2086+
let target_root = rootfs
2087+
.target_root_path
2088+
.as_ref()
2089+
.unwrap_or(&rootfs.physical_root_path);
2090+
install_bootloader_via_bootupd(
2091+
&rootfs.device_info,
2092+
target_root,
2093+
&state.config_opts,
2094+
None,
2095+
)?;
2096+
}
2097+
}
2098+
}
2099+
2100+
// Step 9: Write flat install marker.
2101+
rootfs
2102+
.physical_root
2103+
.atomic_write(FLAT_INSTALL_MARKER, b"")
2104+
.context("Writing flat install marker")?;
2105+
2106+
Ok(())
2107+
}
2108+
19022109
async fn ostree_install(state: &State, rootfs: &RootSetup, cleanup: Cleanup) -> Result<()> {
19032110
// We verify this upfront because it's currently required by bootupd
19042111
let boot_uuid = rootfs
@@ -1971,7 +2178,9 @@ async fn install_to_filesystem_impl(
19712178
}
19722179
}
19732180

1974-
if state.composefs_options.composefs_backend {
2181+
if rootfs.flat {
2182+
flat_install(state, rootfs).await?;
2183+
} else if state.composefs_options.composefs_backend {
19752184
// Load a fd for the mounted target physical root
19762185

19772186
let (id, verity) = initialize_composefs_repository(
@@ -2608,6 +2817,7 @@ pub(crate) async fn install_to_filesystem(
26082817
boot,
26092818
kargs,
26102819
skip_finalize,
2820+
flat: fsopts.flat,
26112821
};
26122822

26132823
install_to_filesystem_impl(&state, &mut rootfs, cleanup).await?;
@@ -2658,6 +2868,7 @@ pub(crate) async fn install_to_existing_root(opts: InstallToExistingRootOpts) ->
26582868
replace: opts.replace,
26592869
skip_finalize: true,
26602870
acknowledge_destructive: opts.acknowledge_destructive,
2871+
flat: false,
26612872
},
26622873
source_opts: opts.source_opts,
26632874
target_opts: opts.target_opts,
@@ -3050,4 +3261,59 @@ UUID=boot-uuid /boot ext4 defaults 0 0
30503261

30513262
Ok(())
30523263
}
3264+
3265+
#[test]
3266+
fn test_create_flat_bls_entry() -> Result<()> {
3267+
let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
3268+
td.create_dir_all("boot")?;
3269+
3270+
let rootfs = RootSetup {
3271+
#[cfg(feature = "install-to-disk")]
3272+
luks_device: None,
3273+
device_info: bootc_blockdev::Device {
3274+
name: "vda".to_string(),
3275+
serial: None,
3276+
model: None,
3277+
partlabel: None,
3278+
parttype: None,
3279+
partuuid: None,
3280+
partn: None,
3281+
children: None,
3282+
size: 0,
3283+
maj_min: None,
3284+
start: None,
3285+
label: None,
3286+
fstype: None,
3287+
uuid: None,
3288+
path: None,
3289+
pttype: None,
3290+
},
3291+
physical_root_path: Utf8PathBuf::from("/test"),
3292+
physical_root: td.try_clone()?,
3293+
target_root_path: None,
3294+
rootfs_uuid: None,
3295+
skip_finalize: false,
3296+
boot: None,
3297+
kargs: CmdlineOwned::from("root=UUID=abc123 rw"),
3298+
flat: true,
3299+
};
3300+
3301+
let kernel_version = "6.12.0-100.fc41.x86_64";
3302+
let vmlinuz = Utf8PathBuf::from(format!("/boot/vmlinuz-{kernel_version}"));
3303+
let initramfs = Utf8PathBuf::from(format!("/boot/initramfs-{kernel_version}.img"));
3304+
3305+
create_flat_bls_entry(&rootfs, kernel_version, &vmlinuz, &initramfs)?;
3306+
3307+
// Read back the BLS entry and verify its contents
3308+
let entry_path = format!("boot/loader/entries/flat-{kernel_version}.conf");
3309+
let content = String::from_utf8(td.read(&entry_path)?)?;
3310+
3311+
assert!(content.contains(&format!("title Linux {kernel_version}")));
3312+
assert!(content.contains(&format!("version {kernel_version}")));
3313+
assert!(content.contains(&format!("linux /boot/vmlinuz-{kernel_version}")));
3314+
assert!(content.contains(&format!("initrd /boot/initramfs-{kernel_version}.img")));
3315+
assert!(content.contains("options root=UUID=abc123 rw"));
3316+
3317+
Ok(())
3318+
}
30533319
}

0 commit comments

Comments
 (0)