diff --git a/rust/src/cli_experimental.rs b/rust/src/cli_experimental.rs index afa238bbf3..1ccd020515 100644 --- a/rust/src/cli_experimental.rs +++ b/rust/src/cli_experimental.rs @@ -18,14 +18,38 @@ enum Cmd { /// This command does nothing, it's a placeholder for future expansion. #[clap(hide = true)] Stub, + /// Options for building. + Compose { + #[clap(subcommand)] + cmd: ComposeCmd, + }, +} + +#[derive(Debug, clap::Subcommand)] +enum ComposeCmd { + BuildChunkedOCI { + #[clap(flatten)] + opts: crate::compose::BuildChunkedOCI, + }, +} + +impl ComposeCmd { + fn run(self) -> Result<()> { + match self { + ComposeCmd::BuildChunkedOCI { opts } => opts.run(), + } + } } impl Cmd { fn run(self) -> Result<()> { match self { - Cmd::Stub => println!("Did nothing successfully."), + Cmd::Stub => { + println!("Did nothing successfully."); + Ok(()) + } + Cmd::Compose { cmd } => cmd.run(), } - Ok(()) } } @@ -45,6 +69,9 @@ mod tests { let opt = Experimental::try_parse_from(["experimental", "stub"]).unwrap(); match opt.cmd { Cmd::Stub => {} + o => { + panic!("Unexpected {o:?}") + } } Ok(()) } diff --git a/rust/src/compose.rs b/rust/src/compose.rs index e05a17d5b3..43d033bbea 100644 --- a/rust/src/compose.rs +++ b/rust/src/compose.rs @@ -4,21 +4,31 @@ use std::fs::File; use std::io::{BufWriter, Write}; +use std::os::fd::{AsFd, AsRawFd}; use std::process::Command; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::fs::Dir; use clap::Parser; +use fn_error_context::context; use oci_spec::image::ImageManifest; use ostree::gio; -use ostree_ext::container as ostree_container; use ostree_ext::containers_image_proxy; +use ostree_ext::glib::{Cast, ToVariant}; use ostree_ext::keyfileext::{map_keyfile_optional, KeyFileExt}; +use ostree_ext::ostree::MutableTree; +use ostree_ext::{container as ostree_container, glib}; use ostree_ext::{oci_spec, ostree}; use crate::cmdutils::CommandRunExt; use crate::cxxrsutil::{CxxResult, FFIGObjectWrapper}; +const SYSROOT: &str = "sysroot"; +const USR: &str = "usr"; +const ETC: &str = "etc"; +const USR_ETC: &str = "usr/etc"; + #[derive(clap::ValueEnum, Clone, Debug)] enum OutputFormat { Ociarchive, @@ -143,6 +153,222 @@ struct Opt { output: Utf8PathBuf, } +/// Generate a "chunked" OCI archive from an input rootfs. +#[derive(Debug, Parser)] +pub(crate) struct BuildChunkedOCI { + /// Path to the source root filesystem tree. + #[clap(long, required = true)] + rootfs: Utf8PathBuf, + + /// If set, generate an ostree commit from the source. + #[clap(long)] + ostree: bool, + + /// Tag to use for output image, or `latest` if unset. + #[clap(long, default_value = "latest")] + reference: String, + + /// Path to an OCI directory. Will be created if nonexistent. + #[clap(long, required = true)] + output: Utf8PathBuf, +} + +impl BuildChunkedOCI { + pub(crate) fn run(self) -> Result<()> { + anyhow::ensure!(self.rootfs.as_path() != "/"); + let rootfs = &Dir::open_ambient_dir(self.rootfs.as_path(), cap_std::ambient_authority()) + .with_context(|| format!("Opening {}", self.rootfs))?; + if !self.ostree { + anyhow::bail!("--ostree is currently required"); + } + + let td = tempfile::tempdir_in("/var/tmp")?; + + let repo_path: Utf8PathBuf = td.path().join("repo").try_into()?; + let repo = ostree::Repo::create_at( + libc::AT_FDCWD, + repo_path.as_str(), + ostree::RepoMode::BareUser, + None, + gio::Cancellable::NONE, + )?; + + println!("Generating commit..."); + // It's only the tests that override + let modifier = + ostree::RepoCommitModifier::new(ostree::RepoCommitModifierFlags::empty(), None); + let commitid = generate_commit_from_rootfs(&repo, rootfs, modifier)?; + + let imgref = format!("oci:{}:{}", self.output.as_str(), self.reference.as_str()); + + crate::isolation::self_command() + .args([ + "compose", + "container-encapsulate", + "--repo", + repo_path.as_str(), + commitid.as_str(), + imgref.as_str(), + ]) + .run()?; + + Ok(()) + } +} + +fn label_to_xattrs(label: Option<&str>) -> Option { + let xattrs = label.map(|label| { + let mut label: Vec<_> = label.to_owned().into(); + label.push(0); + vec![(c"security.selinux".to_bytes_with_nul(), label)] + }); + xattrs.map(|x| x.to_variant()) +} + +fn create_root_dirmeta(policy: &ostree::SePolicy) -> Result { + let finfo = gio::FileInfo::new(); + finfo.set_attribute_uint32("unix::uid", 0); + finfo.set_attribute_uint32("unix::gid", 0); + finfo.set_attribute_uint32("unix::mode", libc::S_IFDIR | 0o755); + let label = policy.label("/", 0o777 | libc::S_IFDIR, gio::Cancellable::NONE)?; + let xattrs = label_to_xattrs(label.as_deref()); + let r = ostree::create_directory_metadata(&finfo, xattrs.as_ref()); + Ok(r) +} + +enum MtreeEntry { + #[allow(dead_code)] + Leaf(String), + Directory(MutableTree), +} + +impl MtreeEntry { + fn require_dir(self) -> Result { + match self { + MtreeEntry::Leaf(_) => anyhow::bail!("Expected a directory"), + MtreeEntry::Directory(t) => Ok(t), + } + } +} + +/// The two returns value in C are mutually exclusive; also map "not found" to None. +fn mtree_lookup(t: &ostree::MutableTree, path: &str) -> Result> { + let r = match t.lookup(path) { + Ok((Some(leaf), None)) => Some(MtreeEntry::Leaf(leaf.into())), + Ok((_, Some(subdir))) => Some(MtreeEntry::Directory(subdir)), + Ok((None, None)) => unreachable!(), + Err(e) if e.matches(gio::IOErrorEnum::NotFound) => None, + Err(e) => return Err(e.into()), + }; + Ok(r) +} + +// Given a root filesystem, perform some in-memory postprocessing. +// At the moment, that's just ensuring /etc is /usr/etc. +#[context("Postprocessing commit")] +fn postprocess_mtree(repo: &ostree::Repo, rootfs: &ostree::MutableTree) -> Result<()> { + let etc_subdir = mtree_lookup(rootfs, ETC)? + .map(|e| e.require_dir().context("/etc")) + .transpose()?; + let usr_etc_subdir = mtree_lookup(rootfs, USR_ETC)? + .map(|e| e.require_dir().context("/usr/etc")) + .transpose()?; + match (etc_subdir, usr_etc_subdir) { + (None, None) => { + // No /etc? We'll let you try it. + } + (None, Some(_)) => { + // Having just /usr/etc is the expected ostree default. + } + (Some(etc), None) => { + // We need to write the etc dir now to generate checksums, + // then move it. + repo.write_mtree(&etc, gio::Cancellable::NONE)?; + let usr = rootfs + .lookup(USR)? + .1 + .ok_or_else(|| anyhow!("Missing /usr"))?; + let usretc = usr.ensure_dir(ETC)?; + usretc.set_contents_checksum(&etc.contents_checksum()); + usretc.set_metadata_checksum(&etc.metadata_checksum()); + rootfs.remove(ETC, false)?; + } + (Some(_), Some(_)) => { + anyhow::bail!("Found both /etc and /usr/etc"); + } + } + Ok(()) +} + +fn generate_commit_from_rootfs( + repo: &ostree::Repo, + rootfs: &Dir, + modifier: ostree::RepoCommitModifier, +) -> Result { + let root_mtree = ostree::MutableTree::new(); + let cancellable = gio::Cancellable::NONE; + let tx = repo.auto_transaction(cancellable)?; + + let policy = ostree::SePolicy::new_at(rootfs.as_fd().as_raw_fd(), cancellable)?; + modifier.set_sepolicy(Some(&policy)); + + let root_dirmeta = create_root_dirmeta(&policy)?; + let root_metachecksum = repo.write_metadata( + ostree::ObjectType::DirMeta, + None, + &root_dirmeta, + cancellable, + )?; + root_mtree.set_metadata_checksum(&root_metachecksum.to_hex()); + + for ent in rootfs.entries_utf8()? { + let ent = ent?; + let name = ent.file_name()?; + + let ftype = ent.file_type()?; + // Skip the contents of the sysroot + if ftype.is_dir() && name == SYSROOT { + let child_mtree = root_mtree.ensure_dir(&name)?; + child_mtree.set_metadata_checksum(&root_metachecksum.to_hex()); + } else if ftype.is_dir() { + let child_mtree = root_mtree.ensure_dir(&name)?; + let child = ent.open_dir()?; + repo.write_dfd_to_mtree( + child.as_raw_fd(), + ".", + &child_mtree, + Some(&modifier), + cancellable, + )?; + } else if ftype.is_symlink() { + let contents: Utf8PathBuf = rootfs + .read_link_contents(&name) + .with_context(|| format!("Reading {name}"))? + .try_into()?; + // Label lookups need to be absolute + let selabel_path = format!("/{name}"); + let label = policy.label(selabel_path.as_str(), 0o777 | libc::S_IFLNK, cancellable)?; + let xattrs = label_to_xattrs(label.as_deref()); + let link_checksum = + repo.write_symlink(None, 0, 0, xattrs.as_ref(), contents.as_str(), cancellable)?; + root_mtree.replace_file(&name, &link_checksum)?; + } else { + // Yes we could support this but it's a surprising amount of typing + anyhow::bail!("Unsupported regular file {name} at toplevel"); + } + } + + postprocess_mtree(repo, &root_mtree)?; + + let ostree_root = repo.write_mtree(&root_mtree, cancellable)?; + let ostree_root = ostree_root.downcast_ref::().unwrap(); + let commit = + repo.write_commit_with_time(None, None, None, None, ostree_root, 0, cancellable)?; + + tx.commit(cancellable)?; + Ok(commit.into()) +} + /// Metadata relevant to base image builds that we extract from the container metadata. struct ImageMetadata { manifest: ImageManifest, @@ -422,3 +648,95 @@ pub(crate) fn configure_build_repo_from_target( Ok(()) } + +#[cfg(test)] +mod tests { + use cap_std_ext::cap_tempfile; + use gio::prelude::FileExt; + + use super::*; + + fn commit_filter( + _repo: &ostree::Repo, + _name: &str, + info: &gio::FileInfo, + ) -> ostree::RepoCommitFilterResult { + info.set_attribute_uint32("unix::uid", 0); + info.set_attribute_uint32("unix::gid", 0); + ostree::RepoCommitFilterResult::Allow + } + + #[test] + fn write_commit() -> Result<()> { + let cancellable = gio::Cancellable::NONE; + let repo_td = cap_tempfile::tempdir(cap_std::ambient_authority())?; + let repo = + ostree::Repo::create_at_dir(repo_td.as_fd(), ".", ostree::RepoMode::BareUser, None)?; + let td = cap_tempfile::tempdir(cap_std::ambient_authority())?; + + let modifier = ostree::RepoCommitModifier::new( + ostree::RepoCommitModifierFlags::SKIP_XATTRS + | ostree::RepoCommitModifierFlags::CANONICAL_PERMISSIONS, + Some(Box::new(commit_filter)), + ); + + let commit = generate_commit_from_rootfs(&repo, &td, modifier.clone()).unwrap(); + assert_eq!( + commit, + "6109d33e99f48e9b90cdf8ad037b8e5d20ef899697cfd3eb492cf78800aed498" + ); + + td.create_dir_all("usr/bin")?; + td.write("usr/bin/bash", "bash binary")?; + td.create_dir("etc")?; + td.write("etc/foo", "some etc content")?; + td.create_dir_all("sysroot/ostree/repo/objects/00")?; + td.write( + "sysroot/ostree/repo/objects/00/foo.commit", + "commit to ignore", + )?; + + let commit = generate_commit_from_rootfs(&repo, &td, modifier.clone()).unwrap(); + assert_eq!( + commit, + "192642d885cd1f0a743455466bb918415f004c2af6b69e87e00768623ad7fa04" + ); + + let ostree_root = repo.read_commit(&commit, cancellable)?.0; + + let foo = ostree_root.resolve_relative_path("usr/etc/foo"); + assert!(foo.query_exists(cancellable)); + let meta = foo.query_info( + "standard::*", + gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS, + cancellable, + )?; + assert_eq!(meta.size(), "some etc content".len() as i64); + assert!(!ostree_root + .resolve_relative_path("etc") + .query_exists(cancellable)); + + let ostree_top = ostree_root.resolve_relative_path("sysroot"); + let meta = ostree_top.query_info( + "standard::*", + gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS, + cancellable, + )?; + assert_eq!(meta.file_type(), gio::FileType::Directory); + assert!(!ostree_root + .resolve_relative_path("sysroot/ostree") + .query_exists(cancellable)); + + let bash_path = ostree_root.resolve_relative_path("usr/bin/bash"); + let bash_path = bash_path.downcast_ref::().unwrap(); + bash_path.ensure_resolved()?; + let bashmeta = bash_path.query_info( + "standard::*", + gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS, + cancellable, + )?; + assert_eq!(bashmeta.size(), 11); + + Ok(()) + } +}