Skip to content

Commit 7196079

Browse files
shi2wei3cgwalters
authored andcommitted
upgrade: Add pre-flight disk space check
Adds a pre-flight disk space check for bootc upgrade (and switch) operations that fires after prepare() resolves which layers need to be fetched but before any data is downloaded. Extends PR #1245's approach to all bootc upgrade modes that download layers (default, --apply, --download-only). Moves check_disk_space() to deploy.rs for reuse by both install and upgrade operations. This prevents wasted bandwidth and provides immediate feedback when insufficient disk space is available, matching the install behavior. Also adds a tmt integration test (test-35) that constructs a minimal fake OCI image directory with an astronomically large declared layer size (999 TiB), then verifies that bootc switch fails with "Insufficient free space" before attempting to fetch any layers. Related: BIFROST-1088 Assisted-by: AI Signed-off-by: Wei Shi <wshi@redhat.com>
1 parent 37a0427 commit 7196079

4 files changed

Lines changed: 150 additions & 25 deletions

File tree

crates/lib/src/deploy.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
55
use std::collections::HashSet;
66
use std::io::{BufRead, Write};
7+
use std::os::fd::AsFd;
78
use std::process::Command;
89

910
use anyhow::{Context, Result, anyhow};
@@ -361,6 +362,55 @@ pub(crate) async fn prune_container_store(sysroot: &Storage) -> Result<()> {
361362
Ok(())
362363
}
363364

365+
/// Core disk space check: verify that `bytes_to_fetch` fits within available space,
366+
/// leaving at least `min_free` bytes reserved.
367+
fn check_disk_space_inner(
368+
fd: impl AsFd,
369+
bytes_to_fetch: u64,
370+
min_free: u64,
371+
imgref: &ImageReference,
372+
) -> Result<()> {
373+
let stat = rustix::fs::fstatvfs(fd)?;
374+
let bytes_avail = stat.f_bsize.checked_mul(stat.f_bavail).unwrap_or(u64::MAX);
375+
let usable = bytes_avail.saturating_sub(min_free);
376+
tracing::trace!("bytes_avail: {bytes_avail} min_free: {min_free} usable: {usable}");
377+
378+
if bytes_to_fetch > usable {
379+
anyhow::bail!(
380+
"Insufficient free space for {image} (available: {available} required: {required})",
381+
available = ostree_ext::glib::format_size(usable),
382+
required = ostree_ext::glib::format_size(bytes_to_fetch),
383+
image = imgref.image,
384+
);
385+
}
386+
Ok(())
387+
}
388+
389+
/// Verify there is sufficient disk space to pull an image into the ostree repo.
390+
/// Respects the repository's configured min-free-space threshold.
391+
pub(crate) fn check_disk_space_ostree(
392+
repo: &ostree::Repo,
393+
image_meta: &PreparedImportMeta,
394+
imgref: &ImageReference,
395+
) -> Result<()> {
396+
let min_free = repo.min_free_space_bytes().unwrap_or(0);
397+
check_disk_space_inner(
398+
repo.dfd_borrow(),
399+
image_meta.bytes_to_fetch,
400+
min_free,
401+
imgref,
402+
)
403+
}
404+
405+
/// Verify there is sufficient disk space to pull an image into the composefs store.
406+
pub(crate) fn check_disk_space_composefs(
407+
cfs: &crate::store::ComposefsRepository,
408+
image_meta: &PreparedImportMeta,
409+
imgref: &ImageReference,
410+
) -> Result<()> {
411+
check_disk_space_inner(cfs.objects_dir()?, image_meta.bytes_to_fetch, 0, imgref)
412+
}
413+
364414
pub(crate) struct PreparedImportMeta {
365415
pub imp: ImageImporter,
366416
pub prep: Box<PreparedImport>,
@@ -550,6 +600,11 @@ pub(crate) async fn pull_unified(
550600
Ok(existing)
551601
}
552602
PreparedPullResult::Ready(prepared_image_meta) => {
603+
check_disk_space_composefs(
604+
store.get_ensure_composefs()?.as_ref(),
605+
&prepared_image_meta,
606+
imgref,
607+
)?;
553608
// To avoid duplicate success logs, pass a containers-storage imgref to the importer
554609
let cs_imgref = ImageReference {
555610
transport: "containers-storage".to_string(),
@@ -658,6 +713,8 @@ pub(crate) async fn pull(
658713
Ok(existing)
659714
}
660715
PreparedPullResult::Ready(prepared_image_meta) => {
716+
// Check disk space before attempting to pull
717+
check_disk_space_ostree(repo, &prepared_image_meta, imgref)?;
661718
// Log that we're pulling a new image
662719
const PULLING_NEW_IMAGE_ID: &str = "6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0";
663720
tracing::info!(
@@ -1368,4 +1425,24 @@ UUID=6907-17CA /boot/efi vfat umask=0077,shortname=win
13681425
assert_eq!(tempdir.read_to_string("etc/fstab")?, modified);
13691426
Ok(())
13701427
}
1428+
#[test]
1429+
fn test_check_disk_space_inner() -> Result<()> {
1430+
let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
1431+
let imgref = ImageReference {
1432+
image: "quay.io/exampleos/exampleos:latest".into(),
1433+
transport: "registry".into(),
1434+
signature: None,
1435+
};
1436+
1437+
// 0 bytes needed always passes
1438+
check_disk_space_inner(&*td, 0, 0, &imgref)?;
1439+
1440+
// u64::MAX bytes needed always fails
1441+
assert!(check_disk_space_inner(&*td, u64::MAX, 0, &imgref).is_err());
1442+
1443+
// With min_free consuming all usable space, even a tiny fetch fails
1444+
assert!(check_disk_space_inner(&*td, 1, u64::MAX, &imgref).is_err());
1445+
1446+
Ok(())
1447+
}
13711448
}

crates/lib/src/install.rs

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -191,9 +191,7 @@ use crate::bootc_composefs::status::ComposefsCmdline;
191191
use crate::bootc_composefs::{boot::setup_composefs_boot, repo::initialize_composefs_repository};
192192
use crate::boundimage::{BoundImage, ResolvedBoundImage};
193193
use crate::containerenv::ContainerExecutionInfo;
194-
use crate::deploy::{
195-
MergeState, PreparedImportMeta, PreparedPullResult, prepare_for_pull, pull_from_prepared,
196-
};
194+
use crate::deploy::{MergeState, PreparedPullResult, prepare_for_pull, pull_from_prepared};
197195
use crate::install::config::Filesystem as FilesystemEnum;
198196
use crate::lsm;
199197
use crate::progress_jsonl::ProgressWriter;
@@ -1013,27 +1011,6 @@ async fn initialize_ostree_root(state: &State, root_setup: &RootSetup) -> Result
10131011
Ok((storage, has_ostree))
10141012
}
10151013

1016-
fn check_disk_space(
1017-
repo_fd: impl AsFd,
1018-
image_meta: &PreparedImportMeta,
1019-
imgref: &ImageReference,
1020-
) -> Result<()> {
1021-
let stat = rustix::fs::fstatvfs(repo_fd)?;
1022-
let bytes_avail: u64 = stat.f_bsize * stat.f_bavail;
1023-
tracing::trace!("bytes_avail: {bytes_avail}");
1024-
1025-
if image_meta.bytes_to_fetch > bytes_avail {
1026-
anyhow::bail!(
1027-
"Insufficient free space for {image} (available: {bytes_avail} required: {bytes_to_fetch})",
1028-
bytes_avail = ostree_ext::glib::format_size(bytes_avail),
1029-
bytes_to_fetch = ostree_ext::glib::format_size(image_meta.bytes_to_fetch),
1030-
image = imgref.image,
1031-
);
1032-
}
1033-
1034-
Ok(())
1035-
}
1036-
10371014
#[context("Creating ostree deployment")]
10381015
async fn install_container(
10391016
state: &State,
@@ -1101,7 +1078,7 @@ async fn install_container(
11011078
let pulled_image = match prepared {
11021079
PreparedPullResult::AlreadyPresent(existing) => existing,
11031080
PreparedPullResult::Ready(image_meta) => {
1104-
check_disk_space(root_setup.physical_root.as_fd(), &image_meta, &spec_imgref)?;
1081+
crate::deploy::check_disk_space_ostree(repo, &image_meta, &spec_imgref)?;
11051082
pull_from_prepared(&spec_imgref, false, ProgressWriter::default(), *image_meta).await?
11061083
}
11071084
};
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# number: 35
2+
# tmt:
3+
# summary: Verify pre-flight disk space check rejects images with inflated layer sizes
4+
# duration: 10m
5+
#
6+
# This test does NOT require a reboot.
7+
# It constructs a minimal fake OCI image directory that claims to have an
8+
# astronomically large layer (999 TiB), then verifies that bootc switch fails
9+
# with "Insufficient free space" before attempting to fetch any data.
10+
use std assert
11+
use tap.nu
12+
13+
tap begin "pre-flight disk space check"
14+
15+
def main [] {
16+
let td = mktemp -d
17+
18+
# --- Build a minimal but valid fake OCI image layout ---
19+
#
20+
# The config blob must be real (containers-image-proxy fetches it to
21+
# parse ImageConfiguration). The layer blob need not exist because
22+
# the disk-space check fires before any layer is fetched.
23+
24+
# Minimal OCI image config (empty rootfs, no layers referenced inside config)
25+
# The config must include a bootable label ("containers.bootc" or "ostree.bootable")
26+
# so that bootc's require_bootable() check in prepare() passes.
27+
let config_content = '{"architecture":"amd64","os":"linux","config":{"Labels":{"containers.bootc":"1"}},"rootfs":{"type":"layers","diff_ids":[]},"history":[{"created_by":"fake layer"}]}'
28+
let config_digest = $config_content | hash sha256
29+
let config_size = ($config_content | str length)
30+
31+
# Write config blob
32+
mkdir $"($td)/blobs/sha256"
33+
$config_content | save $"($td)/blobs/sha256/($config_digest)"
34+
35+
# Fake layer: a digest that points to a non-existent blob is fine because
36+
# the preflight check reads the declared size from the manifest only.
37+
let fake_layer_digest = "0000000000000000000000000000000000000000000000000000000000000000"
38+
let fake_layer_size = 999_999_999_999_999 # ~999 TiB — will never fit on disk
39+
40+
# OCI image manifest pointing to the real config + one fake large layer
41+
let manifest_content = $'{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:($config_digest)","size":($config_size)},"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","digest":"sha256:($fake_layer_digest)","size":($fake_layer_size)}]}'
42+
let manifest_digest = $manifest_content | hash sha256
43+
let manifest_size = ($manifest_content | str length)
44+
45+
# Write manifest blob
46+
$manifest_content | save $"($td)/blobs/sha256/($manifest_digest)"
47+
48+
# OCI layout marker
49+
'{"imageLayoutVersion":"1.0.0"}' | save $"($td)/oci-layout"
50+
51+
# Index pointing to our manifest
52+
let index_content = $'{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:($manifest_digest)","size":($manifest_size)}]}'
53+
$index_content | save $"($td)/index.json"
54+
55+
# --- Attempt bootc switch; expect pre-flight failure ---
56+
let result = do { bootc switch --transport oci $td } | complete
57+
print $"exit_code: ($result.exit_code)"
58+
print $"stderr: ($result.stderr)"
59+
60+
assert ($result.exit_code != 0) "bootc switch should have failed due to insufficient disk space"
61+
assert ($result.stderr | str contains "Insufficient free space") $"Expected 'Insufficient free space' in stderr, got: ($result.stderr)"
62+
63+
tap ok
64+
}
65+
66+
main

tmt/tests/tests.fmf

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@
102102
duration: 10m
103103
test: python3 booted/test-user-agent.py
104104

105+
/test-35-upgrade-preflight-disk-check:
106+
summary: Verify pre-flight disk space check rejects images with inflated layer sizes
107+
duration: 20m
108+
test: nu booted/test-upgrade-preflight-disk-check.nu
109+
105110
/test-36-rollback:
106111
summary: Test bootc rollback functionality through image switch and rollback cycle
107112
duration: 30m

0 commit comments

Comments
 (0)