mirror of
https://github.com/containers/bootc.git
synced 2026-02-05 15:45:53 +01:00
container: Add path-based compute-composefs-digest command
Add a new `bootc container compute-composefs-digest` command that computes the bootable composefs digest directly from a filesystem directory path, defaulting to `/target`. This enables computing digests in container environments without requiring access to container storage or a booted host system. The existing container-storage-based behavior is preserved and renamed to `compute-composefs-digest-from-storage` (hidden). The `hack/compute-composefs-digest` script is updated to use the renamed command. The core digest computation logic is extracted into a new `bootc_composefs::digest` module with: - `new_temp_composefs_repo()` helper for DRY temp repository creation - `compute_composefs_digest()` function with "/" path rejection Unit tests and an integration test verify the command works correctly, producing valid SHA-512 hex digests with consistent results across multiple invocations. Exact digest values are not asserted due to environmental variations (SELinux labels, timestamps, etc.). Closes: https://github.com/bootc-dev/bootc/issues/1862 Assisted-by: OpenCode (Claude Opus 4.5) Signed-off-by: John Eckersberg <jeckersb@redhat.com>
This commit is contained in:
committed by
Colin Walters
parent
d90f0197c0
commit
72f1f2720d
152
crates/lib/src/bootc_composefs/digest.rs
Normal file
152
crates/lib/src/bootc_composefs/digest.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
//! Composefs digest computation utilities.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::BufWriter;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use camino::Utf8Path;
|
||||
use cap_std_ext::cap_std;
|
||||
use cap_std_ext::cap_std::fs::Dir;
|
||||
use composefs::dumpfile;
|
||||
use composefs::fsverity::FsVerityHashValue;
|
||||
use composefs_boot::BootOps as _;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::store::ComposefsRepository;
|
||||
|
||||
/// Creates a temporary composefs repository for computing digests.
|
||||
///
|
||||
/// Returns the TempDir guard (must be kept alive for the repo to remain valid)
|
||||
/// and the repository wrapped in Arc.
|
||||
pub(crate) fn new_temp_composefs_repo() -> Result<(TempDir, Arc<ComposefsRepository>)> {
|
||||
let td_guard = tempfile::tempdir_in("/var/tmp")?;
|
||||
let td_path = td_guard.path();
|
||||
let td_dir = Dir::open_ambient_dir(td_path, cap_std::ambient_authority())?;
|
||||
|
||||
td_dir.create_dir("repo")?;
|
||||
let repo_dir = td_dir.open_dir("repo")?;
|
||||
let mut repo = ComposefsRepository::open_path(&repo_dir, ".").context("Init cfs repo")?;
|
||||
// We don't need to hard require verity on the *host* system, we're just computing a checksum here
|
||||
repo.set_insecure(true);
|
||||
Ok((td_guard, Arc::new(repo)))
|
||||
}
|
||||
|
||||
/// Computes the bootable composefs digest for a filesystem at the given path.
|
||||
///
|
||||
/// This reads the filesystem from the specified path, transforms it for boot,
|
||||
/// and computes the composefs image ID.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - Path to the filesystem root to compute digest for
|
||||
/// * `write_dumpfile_to` - Optional path to write a dumpfile
|
||||
///
|
||||
/// # Returns
|
||||
/// The computed digest as a 128-character hex string (SHA-512).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if:
|
||||
/// * The path is "/" (cannot operate on active root filesystem)
|
||||
/// * The filesystem cannot be read
|
||||
/// * The transform or digest computation fails
|
||||
pub(crate) fn compute_composefs_digest(
|
||||
path: &Utf8Path,
|
||||
write_dumpfile_to: Option<&Utf8Path>,
|
||||
) -> Result<String> {
|
||||
if path.as_str() == "/" {
|
||||
anyhow::bail!("Cannot operate on active root filesystem; mount separate target instead");
|
||||
}
|
||||
|
||||
let (_td_guard, repo) = new_temp_composefs_repo()?;
|
||||
|
||||
// Read filesystem from path, transform for boot, compute digest
|
||||
let mut fs =
|
||||
composefs::fs::read_filesystem(rustix::fs::CWD, path.as_std_path(), Some(&repo), false)?;
|
||||
fs.transform_for_boot(&repo).context("Preparing for boot")?;
|
||||
let id = fs.compute_image_id();
|
||||
let digest = id.to_hex();
|
||||
|
||||
if let Some(dumpfile_path) = write_dumpfile_to {
|
||||
let mut w = File::create(dumpfile_path)
|
||||
.with_context(|| format!("Opening {dumpfile_path}"))
|
||||
.map(BufWriter::new)?;
|
||||
dumpfile::write_dumpfile(&mut w, &fs).context("Writing dumpfile")?;
|
||||
}
|
||||
|
||||
Ok(digest)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::{self, Permissions};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
/// Helper to create a minimal test filesystem structure
|
||||
fn create_test_filesystem(root: &std::path::Path) -> Result<()> {
|
||||
// Create directories required by transform_for_boot
|
||||
fs::create_dir_all(root.join("boot"))?;
|
||||
fs::create_dir_all(root.join("sysroot"))?;
|
||||
|
||||
// Create usr/bin directory
|
||||
let usr_bin = root.join("usr/bin");
|
||||
fs::create_dir_all(&usr_bin)?;
|
||||
|
||||
// Create usr/bin/hello with executable permissions
|
||||
let hello_path = usr_bin.join("hello");
|
||||
fs::write(&hello_path, "test\n")?;
|
||||
fs::set_permissions(&hello_path, Permissions::from_mode(0o755))?;
|
||||
|
||||
// Create etc directory
|
||||
let etc = root.join("etc");
|
||||
fs::create_dir_all(&etc)?;
|
||||
|
||||
// Create etc/config with regular file permissions
|
||||
let config_path = etc.join("config");
|
||||
fs::write(&config_path, "test\n")?;
|
||||
fs::set_permissions(&config_path, Permissions::from_mode(0o644))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_composefs_digest() {
|
||||
// Create temp directory with test filesystem structure
|
||||
let td = tempfile::tempdir().unwrap();
|
||||
create_test_filesystem(td.path()).unwrap();
|
||||
|
||||
// Compute the digest
|
||||
let path = Utf8Path::from_path(td.path()).unwrap();
|
||||
let digest = compute_composefs_digest(path, None).unwrap();
|
||||
|
||||
// Verify it's a valid hex string of expected length (SHA-512 = 128 hex chars)
|
||||
assert_eq!(
|
||||
digest.len(),
|
||||
128,
|
||||
"Expected 512-bit hex digest, got length {}",
|
||||
digest.len()
|
||||
);
|
||||
assert!(
|
||||
digest.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"Digest contains non-hex characters: {digest}"
|
||||
);
|
||||
|
||||
// Verify consistency - computing twice on the same filesystem produces the same result
|
||||
let digest2 = compute_composefs_digest(path, None).unwrap();
|
||||
assert_eq!(
|
||||
digest, digest2,
|
||||
"Digest should be consistent across multiple computations"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_composefs_digest_rejects_root() {
|
||||
let result = compute_composefs_digest(Utf8Path::new("/"), None);
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("Cannot operate on active root filesystem"),
|
||||
"Unexpected error message: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub(crate) mod boot;
|
||||
pub(crate) mod delete;
|
||||
pub(crate) mod digest;
|
||||
pub(crate) mod finalize;
|
||||
pub(crate) mod gc;
|
||||
pub(crate) mod repo;
|
||||
|
||||
@@ -7,7 +7,6 @@ use std::fs::File;
|
||||
use std::io::{BufWriter, Seek};
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, ensure, Context, Result};
|
||||
use camino::{Utf8Path, Utf8PathBuf};
|
||||
@@ -32,10 +31,10 @@ use ostree_ext::ostree;
|
||||
use ostree_ext::sysroot::SysrootLock;
|
||||
use schemars::schema_for;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tempfile::tempdir_in;
|
||||
|
||||
use crate::bootc_composefs::delete::delete_composefs_deployment;
|
||||
use crate::bootc_composefs::{
|
||||
digest::{compute_composefs_digest, new_temp_composefs_repo},
|
||||
finalize::{composefs_backend_finalize, get_etc_diff},
|
||||
rollback::composefs_rollback,
|
||||
state::composefs_usr_overlay,
|
||||
@@ -48,7 +47,7 @@ use crate::podstorage::set_additional_image_store;
|
||||
use crate::progress_jsonl::{ProgressWriter, RawProgressFd};
|
||||
use crate::spec::Host;
|
||||
use crate::spec::ImageReference;
|
||||
use crate::store::{BootedOstree, ComposefsRepository, Storage};
|
||||
use crate::store::{BootedOstree, Storage};
|
||||
use crate::store::{BootedStorage, BootedStorageKind};
|
||||
use crate::utils::sigpolicy_from_opt;
|
||||
|
||||
@@ -358,9 +357,20 @@ pub(crate) enum ContainerOpts {
|
||||
#[clap(long)]
|
||||
no_truncate: bool,
|
||||
},
|
||||
/// Output the bootable composefs digest.
|
||||
/// Output the bootable composefs digest for a directory.
|
||||
#[clap(hide = true)]
|
||||
ComputeComposefsDigest {
|
||||
/// Path to the filesystem root
|
||||
#[clap(default_value = "/target")]
|
||||
path: Utf8PathBuf,
|
||||
|
||||
/// Additionally generate a dumpfile written to the target path
|
||||
#[clap(long)]
|
||||
write_dumpfile_to: Option<Utf8PathBuf>,
|
||||
},
|
||||
/// Output the bootable composefs digest from container storage.
|
||||
#[clap(hide = true)]
|
||||
ComputeComposefsDigestFromStorage {
|
||||
/// Additionally generate a dumpfile written to the target path
|
||||
#[clap(long)]
|
||||
write_dumpfile_to: Option<Utf8PathBuf>,
|
||||
@@ -1499,21 +1509,18 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
ContainerOpts::ComputeComposefsDigest {
|
||||
path,
|
||||
write_dumpfile_to,
|
||||
} => {
|
||||
let digest = compute_composefs_digest(&path, write_dumpfile_to.as_deref())?;
|
||||
println!("{digest}");
|
||||
Ok(())
|
||||
}
|
||||
ContainerOpts::ComputeComposefsDigestFromStorage {
|
||||
write_dumpfile_to,
|
||||
image,
|
||||
} => {
|
||||
// Allocate a tempdir
|
||||
let td = tempdir_in("/var/tmp")?;
|
||||
let td = td.path();
|
||||
let td = &Dir::open_ambient_dir(td, cap_std::ambient_authority())?;
|
||||
|
||||
td.create_dir("repo")?;
|
||||
let repo = td.open_dir("repo")?;
|
||||
let mut repo =
|
||||
ComposefsRepository::open_path(&repo, ".").context("Init cfs repo")?;
|
||||
// We don't need to hard require verity on the *host* system, we're just computing a checksum here
|
||||
repo.set_insecure(true);
|
||||
let repo = &Arc::new(repo);
|
||||
let (_td_guard, repo) = new_temp_composefs_repo()?;
|
||||
|
||||
let mut proxycfg = ImageProxyConfig::default();
|
||||
|
||||
@@ -1540,11 +1547,11 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
|
||||
};
|
||||
|
||||
let imgref = format!("containers-storage:{image}");
|
||||
let (imgid, verity) = composefs_oci::pull(repo, &imgref, None, Some(proxycfg))
|
||||
let (imgid, verity) = composefs_oci::pull(&repo, &imgref, None, Some(proxycfg))
|
||||
.await
|
||||
.context("Pulling image")?;
|
||||
let imgid = hex::encode(imgid);
|
||||
let mut fs = composefs_oci::image::create_filesystem(repo, &imgid, Some(&verity))
|
||||
let mut fs = composefs_oci::image::create_filesystem(&repo, &imgid, Some(&verity))
|
||||
.context("Populating fs")?;
|
||||
fs.transform_for_boot(&repo).context("Preparing for boot")?;
|
||||
let id = fs.compute_image_id();
|
||||
|
||||
@@ -125,6 +125,61 @@ fn test_variant_base_crosscheck() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that compute-composefs-digest works on a directory
|
||||
pub(crate) fn test_compute_composefs_digest() -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
// Create temp directory with test filesystem structure
|
||||
let td = tempfile::tempdir()?;
|
||||
let root = td.path();
|
||||
|
||||
// Create directories required by transform_for_boot
|
||||
fs::create_dir_all(root.join("boot"))?;
|
||||
fs::create_dir_all(root.join("sysroot"))?;
|
||||
|
||||
// Create usr/bin/hello (executable)
|
||||
let usr_bin = root.join("usr/bin");
|
||||
fs::create_dir_all(&usr_bin)?;
|
||||
let hello_path = usr_bin.join("hello");
|
||||
fs::write(&hello_path, "test\n")?;
|
||||
fs::set_permissions(&hello_path, fs::Permissions::from_mode(0o755))?;
|
||||
|
||||
// Create etc/config (regular file)
|
||||
let etc = root.join("etc");
|
||||
fs::create_dir_all(&etc)?;
|
||||
let config_path = etc.join("config");
|
||||
fs::write(&config_path, "test\n")?;
|
||||
fs::set_permissions(&config_path, fs::Permissions::from_mode(0o644))?;
|
||||
|
||||
// Run bootc container compute-composefs-digest
|
||||
let sh = Shell::new()?;
|
||||
let path_str = root.to_str().unwrap();
|
||||
let digest = cmd!(sh, "bootc container compute-composefs-digest {path_str}").read()?;
|
||||
let digest = digest.trim();
|
||||
|
||||
// Verify it's a valid hex string of expected length (SHA-512 = 128 hex chars)
|
||||
assert_eq!(
|
||||
digest.len(),
|
||||
128,
|
||||
"Expected 512-bit hex digest, got length {}",
|
||||
digest.len()
|
||||
);
|
||||
assert!(
|
||||
digest.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"Digest contains non-hex characters: {digest}"
|
||||
);
|
||||
|
||||
// Verify consistency - running the command twice produces the same result
|
||||
let digest2 = cmd!(sh, "bootc container compute-composefs-digest {path_str}").read()?;
|
||||
assert_eq!(
|
||||
digest,
|
||||
digest2.trim(),
|
||||
"Digest should be consistent across multiple invocations"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests that should be run in a default container image.
|
||||
#[context("Container tests")]
|
||||
pub(crate) fn run(testargs: libtest_mimic::Arguments) -> Result<()> {
|
||||
@@ -136,6 +191,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("compute-composefs-digest", test_compute_composefs_digest),
|
||||
];
|
||||
|
||||
libtest_mimic::run(&testargs, tests.into()).exit()
|
||||
|
||||
@@ -8,4 +8,4 @@ graphroot=$(podman system info -f '{{.Store.GraphRoot}}')
|
||||
# --pull=never because we don't want to pollute the output with progress and most use cases
|
||||
# for this really should be operating on pre-pulled images.
|
||||
exec podman run --pull=never --quiet --rm --privileged --read-only --security-opt=label=disable -v /sys:/sys:ro --net=none \
|
||||
-v ${graphroot}:/run/host-container-storage:ro --tmpfs /var "$image" bootc container compute-composefs-digest
|
||||
-v ${graphroot}:/run/host-container-storage:ro --tmpfs /var "$image" bootc container compute-composefs-digest-from-storage
|
||||
|
||||
Reference in New Issue
Block a user