Skip to content
Open
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
29 changes: 29 additions & 0 deletions crates/composefs-ctl/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,22 @@ enum OstreeCommand {
#[clap(long, default_value = "")]
subject: String,
},
/// Export an ostree commit to a local ostree repository
///
/// Writes all objects (files, dirtrees, dirmetas, commit) to the
/// destination repo. File content is reflinked when possible.
/// Only bare, bare-user, and bare-user-only repos are supported.
Export {
/// Ostree ref name or commit ID to export
#[arg(add = ArgValueCompleter::new(complete_ostree_refs))]
source: String,
/// Path to the destination ostree repository
#[arg(value_hint = clap::ValueHint::DirPath)]
ostree_repo_path: PathBuf,
/// Ref name to set in the destination repo
#[clap(long)]
reference: Option<String>,
},
/// List all ostree commits in the repository
#[clap(name = "images")]
ListCommits,
Expand Down Expand Up @@ -1889,6 +1905,19 @@ where
println!("tagged {ref_name}");
}
}
OstreeCommand::Export {

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.

Nonblocking here but just a reminder, I think we want a varlink API for this

ref source,
ref ostree_repo_path,
ref reference,
} => {
let dest = composefs_ostree::LocalRepo::open_path(&repo, CWD, ostree_repo_path)?;
let commit_id =
composefs_ostree::export_commit(&repo, source, &dest, reference.as_deref())?;
println!("commit {commit_id}");
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
78 changes: 72 additions & 6 deletions crates/composefs-integration-tests/src/tests/ostree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ use composefs_integration_tests::create_test_repository;
/// - Symlinks with changed targets
/// - Nested directory changes (new dirtree metadata)
fn create_ostree_test_content(parent: &Path, version: u32) -> Result<std::path::PathBuf> {
create_ostree_test_content_opts(parent, version, false)
}

fn create_ostree_test_content_opts(
parent: &Path,
version: u32,
bare_user_only: bool,
) -> Result<std::path::PathBuf> {
let root = parent.join("content");
if root.exists() {
std::fs::remove_dir_all(&root)?;
Expand Down Expand Up @@ -60,12 +68,14 @@ fn create_ostree_test_content(parent: &Path, version: u32) -> Result<std::path::
&xattr_file,
vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
)?;
rustix::fs::setxattr(
&xattr_file,
c"user.testxattr",
b"testvalue",
rustix::fs::XattrFlags::CREATE,
)?;
if !bare_user_only {
rustix::fs::setxattr(
&xattr_file,
c"user.testxattr",
b"testvalue",
rustix::fs::XattrFlags::CREATE,
)?;
}

// Symlink (unchanged across versions)
std::os::unix::fs::symlink("libshared.so.1", root.join("lib/libcompat.so"))?;
Expand Down Expand Up @@ -543,6 +553,62 @@ fn test_ostree_commit_roundtrip() -> Result<()> {
}
integration_test!(test_ostree_commit_roundtrip);

fn test_ostree_export() -> Result<()> {
let sh = Shell::new()?;
let tmpdir = TempDir::new()?;

let composefs_dir = TempDir::new()?;
let repo = create_test_repository(&composefs_dir)?;

for mode in &["bare-user", "bare-user-only"] {
let is_buo = *mode == "bare-user-only";
let content = create_ostree_test_content_opts(tmpdir.path(), 1, is_buo)?;

let ostree_source = tmpdir.path().join(format!("ostree-source-{mode}"));
init_ostree_repo(&sh, &ostree_source, "archive-z2")?;
let source_str = ostree_source.to_str().unwrap();
let content_str = content.to_str().unwrap();

let original_commit_id = if is_buo {
cmd!(
sh,
"ostree commit --repo={source_str} --branch=test --owner-uid=0 --owner-gid=0 --no-xattrs {content_str}"
)
.read()?
.trim()
.to_string()
} else {
commit_to_ostree(&sh, &ostree_source, "test", &content)?
};

pull_and_get_image_id(&repo, &ostree_source, "test", None)?;

let dest_path = tmpdir.path().join(format!("ostree-export-{mode}"));
init_ostree_repo(&sh, &dest_path, mode)?;

let dest = composefs_ostree::LocalRepo::open_path(&repo, rustix::fs::CWD, &dest_path)?;
let exported_id = composefs_ostree::export_commit(&repo, "test", &dest, Some("exported"))?;

assert_eq!(
original_commit_id, exported_id,
"{mode}: exported commit ID differs from original"
);

let dest_str = dest_path.to_str().unwrap();
cmd!(sh, "ostree --repo={dest_str} fsck").run()?;

let ref_output = cmd!(sh, "ostree --repo={dest_str} rev-parse exported").read()?;
assert_eq!(
original_commit_id,
ref_output.trim(),
"{mode}: ref 'exported' points to wrong commit"
);
}

Ok(())
}
integration_test!(test_ostree_export);

fn test_ostree_apply_delta_offline() -> Result<()> {
let sh = Shell::new()?;
let tmpdir = TempDir::new()?;
Expand Down
2 changes: 2 additions & 0 deletions crates/composefs-ostree/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ anyhow = { version = "1.0.87", default-features = false }
base64 = { version = "0.22", default-features = false, features = ["std"] }
bsdiff = { version = "0.2.1", default-features = false }
chrono = { version = "0.4", default-features = false, features = ["alloc"] }
cap-std = { version = "4.0.0", default-features = false }
composefs = { workspace = true }
configparser = { version = "3.1.0", features = [] }
flate2 = { version = "1.1.2", default-features = true }
gvariant = { version = "0.5.1", default-features = true}
hex = { version = "0.4.0", default-features = false, features = ["std"] }
rand = { version = "0.10.0", default-features = false, features = ["thread_rng"] }
rustix = { version = "1.0.0", default-features = false, features = ["fs", "mount", "process", "std"] }
sha2 = { version = "0.11.0", default-features = false }
zerocopy = { version = "0.8.0", default-features = false, features = ["derive", "std"] }
Expand Down
105 changes: 105 additions & 0 deletions crates/composefs-ostree/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,111 @@ pub fn commit_filesystem<ObjectID: FsVerityHashValue>(
Ok((verity, hex::encode(commit_id)))
}

/// Export an ostree commit from the composefs repository to a local ostree repository.
///
/// Walks the commit's tree depth-first, writing all objects (files, dirtrees,
/// dirmetas, commit) to the destination repo. File content is reflinked when
/// possible. If `reference` is given, the ref is set in the destination repo.
///
/// Only bare, bare-user, and bare-user-only destination repos are supported.
pub fn export_commit<ObjectID: FsVerityHashValue>(
repo: &Repository<ObjectID>,
source: &str,
dest: &LocalRepo<ObjectID>,
reference: Option<&str>,
) -> Result<String> {
if dest.mode() == ostree::RepoMode::Archive {
bail!("Export to archive-mode repositories is not supported");
}

let content_id = resolve_source(repo, source)?;
let reader = CommitReader::<ObjectID>::load(repo, &content_id)?;
let commit_id = reader.commit_id();

let commit_data = reader
.lookup_data(&commit_id)?
.ok_or_else(|| anyhow::anyhow!("commit object not found in stream"))?;
let commit = ostree::OstreeCommit::from_data(commit_data)?;

export_dir(
repo,
&reader,
dest,
&commit.root_tree,
&commit.root_metadata,
)?;

dest.write_metadata_object(&commit_id, ostree::ObjectType::Commit, commit_data)?;

if let Some(ref_name) = reference {
dest.write_ref(ref_name, &commit_id)?;
}

rustix::fs::syncfs(dest.dir())?;

Ok(hex::encode(commit_id))
}

fn export_dir<ObjectID: FsVerityHashValue>(
repo: &Repository<ObjectID>,
reader: &CommitReader<ObjectID>,
dest: &LocalRepo<ObjectID>,
dirtree_id: &Sha256Digest,
dirmeta_id: &Sha256Digest,
) -> Result<()> {
let (_obj_id, dirtree_data) = reader
.lookup(dirtree_id)?
.ok_or_else(|| anyhow::anyhow!("Missing dirtree object {}", hex::encode(dirtree_id)))?;
let dirtree = ostree::OstreeDirTree::from_data(dirtree_data)?;

let (_obj_id, dirmeta_data) = reader
.lookup(dirmeta_id)?
.ok_or_else(|| anyhow::anyhow!("Missing dirmeta object {}", hex::encode(dirmeta_id)))?;
// Write file objects first
for (_, file_checksum) in &dirtree.files {
export_file(repo, reader, dest, file_checksum)?;
}

// Recurse into subdirectories (depth-first)
for (_, tree_checksum, meta_checksum) in &dirtree.dirs {
export_dir(repo, reader, dest, tree_checksum, meta_checksum)?;
}

// Now write the metadata objects (all children exist)
dest.write_metadata_object(dirmeta_id, ostree::ObjectType::DirMeta, dirmeta_data)?;
dest.write_metadata_object(dirtree_id, ostree::ObjectType::DirTree, dirtree_data)?;

Ok(())
}

fn export_file<ObjectID: FsVerityHashValue>(
repo: &Repository<ObjectID>,
reader: &CommitReader<ObjectID>,
dest: &LocalRepo<ObjectID>,
checksum: &Sha256Digest,
) -> Result<()> {
if dest.has_object(checksum, ostree::ObjectType::File) {
return Ok(());
}

let (maybe_obj_id, file_data) = reader
.lookup(checksum)?
.ok_or_else(|| anyhow::anyhow!("Missing file object {}", hex::encode(checksum)))?;

let (sized_data, remaining_data) = ostree::split_sized_variant(file_data)?;
let header = ostree::OstreeFileHeader::from_zlib_sized(sized_data)?;

let content = if let Some(obj_id) = maybe_obj_id {
repo::FileContent::External(std::fs::File::from(repo.open_object(obj_id)?))
} else if !remaining_data.is_empty() {
repo::FileContent::Inline(remaining_data)
} else {
repo::FileContent::Empty
};

dest.write_file_object(checksum, &header, content)
}

/// Reads and parses an ostree commit object.
///
/// `source` can be an ostree ref name or a commit ID / prefix.
Expand Down
Loading