Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions crates/composefs-ctl/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,21 @@ enum OstreeCommand {
#[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
name: String,
},
/// Create an ostree commit from a composefs image in the repository
///
/// The image is specified by its object ID or refs/ name (the same
/// format used by `cfsctl mount` and `cfsctl image-objects`).
Commit {
/// Composefs image ID or refs/ name
#[arg(add = ArgValueCompleter::new(complete_image_refs))]
image: String,
/// Ostree ref name to tag the commit with
#[clap(long)]
reference: Option<String>,
/// One-line commit subject
#[clap(long, default_value = "")]
subject: String,
},
/// List all ostree commits in the repository
#[clap(name = "images")]
ListCommits,
Expand Down Expand Up @@ -1834,6 +1849,46 @@ where
OstreeCommand::Untag { ref name } => {
composefs_ostree::untag(&repo, name)?;
}
OstreeCommand::Commit {
ref image,
ref reference,
ref subject,
} => {
use std::time::{SystemTime, UNIX_EPOCH};

let (img_fd, _) = repo.open_image(image)?;
let mut img_buf = Vec::new();
std::fs::File::from(img_fd).read_to_end(&mut img_buf)?;
let fs = composefs::erofs::reader::erofs_to_filesystem(&img_buf)?;

let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut commit_meta = composefs_ostree::ostree::CommitMetadata::default()
.subject(subject.as_str())
.timestamp(timestamp);
if let Some(ref_name) = reference {
commit_meta = commit_meta.add_metadata(
"ostree.ref-binding",
composefs_ostree::ostree::MetadataValue::StringArray(vec![
ref_name.clone(),
]),
);
}

let (verity, commit_id) = composefs_ostree::commit_filesystem(
&repo,
&fs,
commit_meta,
reference.as_deref(),
)?;
println!("commit {commit_id}");
println!("verity {}", verity.to_hex());
if let Some(ref_name) = reference {
println!("tagged {ref_name}");
}
}
OstreeCommand::ListCommits => {
let commits = composefs_ostree::list_commits(&repo)?;
if commits.is_empty() {
Expand Down
31 changes: 31 additions & 0 deletions crates/composefs-integration-tests/src/tests/ostree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,37 @@ fn test_ostree_pull_remote_diff_delta() -> Result<()> {
}
integration_test!(test_ostree_pull_remote_diff_delta);

fn test_ostree_commit_roundtrip() -> Result<()> {
let sh = Shell::new()?;
let tmpdir = TempDir::new()?;
let content = create_ostree_test_content(tmpdir.path(), 1)?;

let ostree_repo_path = tmpdir.path().join("ostree-repo");
init_ostree_repo(&sh, &ostree_repo_path, "archive-z2")?;
let original_commit_id = commit_to_ostree(&sh, &ostree_repo_path, "original", &content)?;

let composefs_dir = TempDir::new()?;
let repo = create_test_repository(&composefs_dir)?;
pull_and_get_image_id(&repo, &ostree_repo_path, "original", None)?;

// Read the original commit's metadata and filesystem
let fs = composefs_ostree::create_filesystem(&repo, "original")?;
let commit = composefs_ostree::read_commit(&repo, "original")?;
let commit_meta = commit.commit_metadata();

// Recreate the commit — should produce the same commit ID
let (_, roundtrip_commit_id) =
composefs_ostree::commit_filesystem(&repo, &fs, commit_meta, None)?;

assert_eq!(
original_commit_id, roundtrip_commit_id,
"roundtrip commit ID differs from original"
);

Ok(())
}
integration_test!(test_ostree_commit_roundtrip);

fn test_ostree_apply_delta_offline() -> Result<()> {
let sh = Shell::new()?;
let tmpdir = TempDir::new()?;
Expand Down
189 changes: 186 additions & 3 deletions crates/composefs-ostree/src/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,32 @@ use composefs::{
};

use crate::ostree::{
OstreeCommit, OstreeDirMeta, OstreeDirTree, OstreeFileHeader, split_sized_variant,
CommitMetadata, OstreeCommit, OstreeDirMeta, OstreeDirTree, OstreeFileHeader,
should_inline_file, split_sized_variant,
};

const OSTREE_COMMIT_CONTENT_TYPE: u64 = 0xAFE138C18C463EF1;

const S_IFMT: u32 = 0o170000;
const S_IFLNK: u32 = 0o120000;
fn stat_xattrs_to_vec(stat: &Stat) -> Vec<(Vec<u8>, Vec<u8>)> {
stat.xattrs
.iter()
.map(|(k, v)| {
let mut val = v.to_vec();
// The kernel stores security.selinux with a NUL terminator,
// but programmatic labeling (e.g. bootc's selabel()) may omit
// it. Canonicalize to always include the NUL so that ostree
// checksums match what ostree fsck expects.
if k.as_bytes() == b"security.selinux" && !val.ends_with(&[0]) {
val.push(0);
}
(k.as_bytes().to_vec(), val)
})
.collect()
}

const S_IFMT: u32 = composefs::erofs::format::S_IFMT as u32;
const S_IFDIR: u32 = composefs::erofs::format::S_IFDIR as u32;
const S_IFLNK: u32 = composefs::erofs::format::S_IFLNK as u32;

fn xattrs_to_btreemap(xattrs: &[(Vec<u8>, Vec<u8>)]) -> BTreeMap<Box<OsStr>, Box<[u8]>> {
xattrs
Expand Down Expand Up @@ -173,6 +192,170 @@ impl<ObjectID: FsVerityHashValue> CommitWriter<ObjectID> {
Ok(writer)
}

/// Build a commit from an in-memory filesystem tree.
///
/// Walks the tree bottom-up, producing ostree dirtree/dirmeta/file
/// objects with correct checksums, then creates the commit object.
/// Returns the writer and the commit's ostree checksum.
pub fn from_filesystem(
repo: &Arc<Repository<ObjectID>>,
fs: &FileSystem<ObjectID>,
metadata: CommitMetadata,
) -> Result<(Self, Sha256Digest)> {
let mut writer = CommitWriter::new();
let mut leaf_cache: HashMap<LeafId, Sha256Digest> = HashMap::new();

let (root_tree_id, root_meta_id) =
Self::write_dir(repo, &mut writer, fs, &fs.root, &mut leaf_cache)?;

let commit = metadata.into_commit(root_tree_id, root_meta_id);
let commit_data = commit.serialize();
let commit_id: Sha256Digest = Sha256::digest(&commit_data).into();
writer.insert(&commit_id, None, &commit_data);
writer.set_commit_id(&commit_id);

Ok((writer, commit_id))
}

fn write_dir(
repo: &Arc<Repository<ObjectID>>,
writer: &mut CommitWriter<ObjectID>,
fs: &FileSystem<ObjectID>,
dir: &Directory<ObjectID>,
leaf_cache: &mut HashMap<LeafId, Sha256Digest>,
) -> Result<(Sha256Digest, Sha256Digest)> {
let mut file_entries = Vec::new();
let mut dir_entries = Vec::new();

for (name, inode) in dir.entries() {
let name_str = name
.to_str()
.ok_or_else(|| anyhow!("non-UTF8 filename: {:?}", name))?;
match inode {
Inode::Leaf(leaf_id, _) => {
let checksum = if let Some(cached) = leaf_cache.get(leaf_id) {
*cached
} else {
let checksum = Self::write_leaf(repo, writer, fs, *leaf_id)?;
leaf_cache.insert(*leaf_id, checksum);
checksum
};
file_entries.push((name_str.to_string(), checksum));
}
Inode::Directory(subdir) => {
let (tree_id, meta_id) = Self::write_dir(repo, writer, fs, subdir, leaf_cache)?;
dir_entries.push((name_str.to_string(), tree_id, meta_id));
}
}
}

let dirmeta = OstreeDirMeta {
uid: dir.stat.st_uid,
gid: dir.stat.st_gid,
mode: (dir.stat.st_mode & !S_IFMT) | S_IFDIR,
xattrs: stat_xattrs_to_vec(&dir.stat),
};
let dirmeta_data = dirmeta.serialize();
let dirmeta_id: Sha256Digest = Sha256::digest(&dirmeta_data).into();
writer.insert(&dirmeta_id, None, &dirmeta_data);

let dirtree = OstreeDirTree {
files: file_entries,
dirs: dir_entries,
};
let dirtree_data = dirtree.serialize();
let dirtree_id: Sha256Digest = Sha256::digest(&dirtree_data).into();
writer.insert(&dirtree_id, None, &dirtree_data);

Ok((dirtree_id, dirmeta_id))
}

fn write_leaf(
repo: &Arc<Repository<ObjectID>>,
writer: &mut CommitWriter<ObjectID>,
fs: &FileSystem<ObjectID>,
leaf_id: LeafId,
) -> Result<Sha256Digest> {
let leaf = fs.leaf(leaf_id);

let symlink_target = match &leaf.content {
LeafContent::Symlink(target) => target
.to_str()
.ok_or_else(|| anyhow!("non-UTF8 symlink target: {:?}", target))?
.to_string(),
_ => String::new(),
};

let file_size = match &leaf.content {
Comment thread
alexlarsson marked this conversation as resolved.
LeafContent::Regular(reg) => reg.file_size(),
_ => 0,
};

let header = OstreeFileHeader {
size: file_size,
uid: leaf.stat.st_uid,
gid: leaf.stat.st_gid,
mode: (leaf.stat.st_mode & !S_IFMT) | leaf.content.file_type_bits(),
symlink_target,
xattrs: stat_xattrs_to_vec(&leaf.stat),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not strictly related, as it works correctly if your Filesystem has the correct xattrs (i.e. matching on-disk format for selinux labels), which they would if you created one from an actual filesystem. However, given that existing code like selabel() gets this wrong I added some canonicalization to avoid running into this again.

};

let regular_header = header.serialize_regular_sized();
let zlib_header = header.serialize_zlib_sized();

match &leaf.content {
LeafContent::Regular(
RegularFile::External(obj_id, size) | RegularFile::ExternalNoVerity(obj_id, size),
) if !should_inline_file::<ObjectID>(*size as usize) => {
// Stream through the hasher without buffering the entire file.
let mut hasher = Sha256::new();
hasher.update(&*regular_header);
let fd = repo.open_object(obj_id)?;
let mut file = std::io::BufReader::new(std::fs::File::from(fd));
let mut buf = [0u8; 8192];
loop {
let n = std::io::Read::read(&mut file, &mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
let checksum: Sha256Digest = hasher.finalize().into();
writer.insert(&checksum, Some(obj_id), &zlib_header);
Ok(checksum)
}
_ => {
// Inline content: symlinks, small files, devices, etc.
let content: Option<Vec<u8>> = match &leaf.content {
LeafContent::Regular(RegularFile::Inline(data)) => Some(data.to_vec()),
LeafContent::Regular(
RegularFile::External(obj_id, _) | RegularFile::ExternalNoVerity(obj_id, _),
) => Some(repo.read_object(obj_id)?),
LeafContent::Regular(RegularFile::Sparse(size)) => {
Some(vec![0u8; *size as usize])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't happen - honestly I'd rather just error out on sparse files in this case because again they're just a leftover bogosity from the composefs-c project I think.

But, if we're going ot handle them we should not potentially heap allocate gigabytes of zeroes...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
_ => None,
};

let mut hasher = Sha256::new();
hasher.update(&*regular_header);
if let Some(ref bytes) = content {
hasher.update(bytes);
}
let checksum: Sha256Digest = hasher.finalize().into();

if let Some(bytes) = content {
let mut data = zlib_header;
data.with_vec(|v| v.extend_from_slice(&bytes));
writer.insert(&checksum, None, &data);
} else {
writer.insert(&checksum, None, &zlib_header);
}
Ok(checksum)
}
}
}

pub fn serialize(
&self,
repo: &Arc<Repository<ObjectID>>,
Expand Down
42 changes: 42 additions & 0 deletions crates/composefs-ostree/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,48 @@ pub fn create_filesystem<ObjectID: FsVerityHashValue>(
Ok(fs)
}

/// Create an ostree commit from an in-memory filesystem tree.
///
/// Walks the tree to produce ostree objects (dirtrees, dirmetas, file
/// headers), serializes them into a commit splitstream, and generates
/// the EROFS image. Returns the splitstream verity and the hex-encoded
/// commit ID.
///
/// Use [`ostree::CommitMetadata::default()`] for a minimal commit, the
/// builder methods to set fields, or [`ostree::OstreeCommit::commit_metadata()`]
/// to round-trip an existing commit.
pub fn commit_filesystem<ObjectID: FsVerityHashValue>(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The challenge I see here is that now composefs holds the GC root for the ostree commit...and actually since we aren't writing to an ostree repo at all, I am not sure how we'd sanely do in-place upgrades.

IOW a goal of the bootc unified storage is that (for compatibility) every aspect of ostree continues to work unchanged (including e.g. rpm-ostree package layering), we just share storage. ostree handles GC on its side, etc.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you worried about a gc in the ostree repo removing a commit, and but the ostree commit (as well as the oci i guess) is not GC:ed in the composefs repo?

repo: &Arc<Repository<ObjectID>>,
fs: &FileSystem<ObjectID>,
metadata: ostree::CommitMetadata,
reference: Option<&str>,
) -> Result<(ObjectID, String)> {
let (writer, commit_id) = CommitWriter::from_filesystem(repo, fs, metadata)?;

let content_id = format!("ostree-commit-{}", hex::encode(commit_id));
let ref_path = reference.map(ostree_ref_path);

let image_id = fs.commit_image(repo, None)?;
let verity = writer.serialize(repo, &content_id, ref_path.as_deref(), Some(&image_id))?;

Ok((verity, hex::encode(commit_id)))
}

/// Reads and parses an ostree commit object.
///
/// `source` can be an ostree ref name or a commit ID / prefix.
pub fn read_commit<ObjectID: FsVerityHashValue>(
repo: &Repository<ObjectID>,
source: &str,
) -> Result<ostree::OstreeCommit> {
let content_id = resolve_source(repo, source)?;
let reader = CommitReader::<ObjectID>::load(repo, &content_id)?;
let commit_data = reader
.lookup_data(&reader.commit_id())?
.ok_or_else(|| anyhow::anyhow!("commit object not found in stream"))?;
ostree::OstreeCommit::from_data(commit_data)
}

/// Prints the contents of an ostree commit object.
///
/// `source` can be an ostree ref name or a commit ID / prefix.
Expand Down
Loading