Skip to content

Commit 64a82e5

Browse files
Johan-Liebert1cgwalters
authored andcommitted
composefs: Implement bootc image copy-to-storage
Export a composefs repository as an OCI image. In this iteration the outputted files are in OCI Directory format and are plain TARs, i.e. not compressed Signed-off-by: Pragyan Poudyal <pragyanpoudyal41999@gmail.com>
1 parent 8c9ed98 commit 64a82e5

7 files changed

Lines changed: 290 additions & 4 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/lib/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ nom = "8.0.0"
6565
schemars = { version = "1.0.4", features = ["chrono04"] }
6666
serde_ignored = "0.1.10"
6767
serde_yaml = "0.9.34"
68+
tar = "0.4.43"
6869
tini = "1.3.0"
6970
uuid = { version = "1.8.0", features = ["v4"] }
7071
uapi-version = "0.4.0"
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
#![allow(dead_code, unused_variables)]
2+
3+
use std::io::{Read, Seek, Write};
4+
5+
use anyhow::{Context, Result};
6+
use canon_json::CanonJsonSerialize;
7+
use cap_std_ext::cap_std::{
8+
ambient_authority,
9+
fs::{Dir, MetadataExt, OpenOptions},
10+
};
11+
use composefs::{
12+
fsverity::FsVerityHashValue,
13+
splitstream::{SplitStreamData, SplitStreamReader},
14+
tree::{LeafContent, RegularFile},
15+
};
16+
use composefs_oci::tar::TarItem;
17+
use openssl::sha::Sha256;
18+
use ostree_ext::oci_spec::image::{Descriptor, Digest, ImageConfiguration, MediaType};
19+
use tar::{EntryType, Header};
20+
21+
use crate::{
22+
bootc_composefs::{
23+
status::{get_composefs_status, get_imginfo},
24+
update::str_to_sha256digest,
25+
},
26+
store::{BootedComposefs, Storage},
27+
};
28+
29+
fn get_entry_with_header<R: Read, ObjectID: FsVerityHashValue>(
30+
reader: &mut SplitStreamReader<R, ObjectID>,
31+
) -> anyhow::Result<Option<(Header, TarItem<ObjectID>)>> {
32+
loop {
33+
let mut buf = [0u8; 512];
34+
if !reader.read_inline_exact(&mut buf)? || buf == [0u8; 512] {
35+
return Ok(None);
36+
}
37+
38+
let header = tar::Header::from_byte_slice(&buf);
39+
40+
let size = header.entry_size()?;
41+
42+
let item = match reader.read_exact(size as usize, ((size + 511) & !511) as usize)? {
43+
SplitStreamData::External(id) => match header.entry_type() {
44+
EntryType::Regular | EntryType::Continuous => {
45+
TarItem::Leaf(LeafContent::Regular(RegularFile::External(id, size)))
46+
}
47+
_ => anyhow::bail!("Unsupported external-chunked entry {header:?} {id:?}"),
48+
},
49+
50+
SplitStreamData::Inline(content) => match header.entry_type() {
51+
EntryType::Directory => TarItem::Directory,
52+
// We do not care what the content is as we're re-archiving it anyway
53+
_ => TarItem::Leaf(LeafContent::Regular(RegularFile::Inline(content))),
54+
},
55+
};
56+
57+
return Ok(Some((header.clone(), item)));
58+
}
59+
}
60+
61+
pub async fn export_repo_to_oci(storage: &Storage, booted_cfs: &BootedComposefs) -> Result<()> {
62+
let host = get_composefs_status(storage, booted_cfs).await?;
63+
64+
let image = host
65+
.status
66+
.booted
67+
.as_ref()
68+
.ok_or_else(|| anyhow::anyhow!("Booted deployment not found"))?
69+
.image
70+
.as_ref()
71+
.unwrap();
72+
73+
let imginfo = get_imginfo(
74+
storage,
75+
&booted_cfs.cmdline.digest,
76+
// TODO: Make this optional
77+
&image.image,
78+
)
79+
.await?;
80+
81+
let config_name = &image.image_digest;
82+
let config_name = str_to_sha256digest(&config_name)?;
83+
84+
let var_tmp =
85+
Dir::open_ambient_dir("/var/tmp", ambient_authority()).context("Opening /var/tmp")?;
86+
87+
var_tmp
88+
.create_dir_all(&*booted_cfs.cmdline.digest)
89+
.context("Creating image dir")?;
90+
91+
let image_dir = var_tmp
92+
.open_dir(&*booted_cfs.cmdline.digest)
93+
.context("Opening image dir")?;
94+
95+
let mut config_stream = booted_cfs
96+
.repo
97+
.open_stream(&hex::encode(config_name), None)
98+
.context("Opening config stream")?;
99+
100+
let config = ImageConfiguration::from_reader(&mut config_stream)?;
101+
102+
// We can't guarantee that we'll get the same tar as the container image
103+
let mut new_config = config.clone();
104+
if let Some(history) = new_config.history_mut() {
105+
history.clear();
106+
}
107+
new_config.rootfs_mut().diff_ids_mut().clear();
108+
109+
let mut new_manifest = imginfo.manifest.clone();
110+
new_manifest.layers_mut().clear();
111+
112+
let mut file_open_opts = OpenOptions::new();
113+
file_open_opts.write(true).create(true);
114+
115+
for (idx, diff_id) in config.rootfs().diff_ids().iter().enumerate() {
116+
let layer_sha256 = str_to_sha256digest(diff_id)?;
117+
let layer_verity = config_stream.lookup(&layer_sha256)?;
118+
119+
let mut layer_stream = booted_cfs
120+
.repo
121+
.open_stream(&hex::encode(layer_sha256), Some(layer_verity))?;
122+
123+
let mut file = image_dir.open_with(hex::encode(layer_sha256), &file_open_opts)?;
124+
125+
let mut builder = tar::Builder::new(&mut file);
126+
127+
while let Some((header, entry)) = get_entry_with_header(&mut layer_stream)? {
128+
let hsize = header.size()? as usize;
129+
let mut v = vec![0; hsize];
130+
131+
match &entry {
132+
TarItem::Directory => {
133+
assert_eq!(hsize, 0);
134+
}
135+
136+
TarItem::Leaf(leaf_content) => {
137+
match &leaf_content {
138+
LeafContent::Regular(reg) => match reg {
139+
RegularFile::Inline(items) => {
140+
assert_eq!(hsize, items.len());
141+
v[..hsize].copy_from_slice(items);
142+
}
143+
144+
RegularFile::External(obj_id, size) => {
145+
assert_eq!(*size as usize, hsize);
146+
147+
let mut file =
148+
std::fs::File::from(booted_cfs.repo.open_object(obj_id)?);
149+
150+
file.read_exact(&mut v)?;
151+
}
152+
},
153+
154+
LeafContent::BlockDevice(_) => todo!(),
155+
LeafContent::CharacterDevice(_) => {
156+
todo!()
157+
}
158+
LeafContent::Fifo => todo!(),
159+
LeafContent::Socket => todo!(),
160+
161+
LeafContent::Symlink(..) => {
162+
// we don't need to write the data for symlinks as the
163+
// target will be in the header itself
164+
assert_eq!(hsize, 0);
165+
}
166+
}
167+
}
168+
169+
TarItem::Hardlink(..) => {
170+
// we don't need to write the data for hardlinks as the
171+
// target will be in the header itself
172+
assert_eq!(hsize, 0);
173+
}
174+
};
175+
176+
builder
177+
.append(&header, v.as_slice())
178+
.context("Failed to write entry")?;
179+
}
180+
181+
builder.finish().context("Finishing builder")?;
182+
drop(builder);
183+
184+
let mut new_diff_id = openssl::hash::Hasher::new(openssl::hash::MessageDigest::sha256())?;
185+
186+
file.seek(std::io::SeekFrom::Start(0))
187+
.context("Seek failed")?;
188+
std::io::copy(&mut file, &mut new_diff_id).context("Failed to compute hash")?;
189+
190+
let final_sha = new_diff_id.finish()?;
191+
let final_sha_str = hex::encode(final_sha);
192+
193+
rustix::fs::renameat(&image_dir, hex::encode(layer_sha256), &image_dir, &final_sha_str)
194+
.context("Renameat")?;
195+
196+
let digest = format!("sha256:{}", hex::encode(final_sha));
197+
198+
new_config.rootfs_mut().diff_ids_mut().push(digest.clone());
199+
200+
// TODO: Gzip this for manifest
201+
new_manifest.layers_mut().push(Descriptor::new(
202+
MediaType::ImageLayer,
203+
file.metadata()?.size(),
204+
Digest::try_from(digest)?,
205+
));
206+
207+
if let Some(old_history) = &config.history() {
208+
if idx >= old_history.len() {
209+
anyhow::bail!("Found more layers than history");
210+
}
211+
212+
let old_history = &old_history[idx];
213+
214+
let mut history = ostree_ext::oci_spec::image::HistoryBuilder::default();
215+
216+
if let Some(old_created) = old_history.created() {
217+
history = history.created(old_created);
218+
}
219+
220+
if let Some(old_created_by) = old_history.created_by() {
221+
history = history.created_by(old_created_by);
222+
}
223+
224+
if let Some(comment) = old_history.comment() {
225+
history = history.comment(comment);
226+
}
227+
228+
new_config
229+
.history_mut()
230+
.get_or_insert(Vec::new())
231+
.push(history.build().unwrap());
232+
}
233+
234+
// TODO: Fsync
235+
}
236+
237+
let config_json = new_config.to_canon_json_vec()?;
238+
239+
// Hash the new config
240+
let mut config_hash = Sha256::new();
241+
config_hash.update(&config_json);
242+
let config_hash = hex::encode(config_hash.finish());
243+
244+
// Write the config to Directory
245+
let mut cfg_file = image_dir
246+
.open_with(&config_hash, &file_open_opts)
247+
.context("Opening config file")?;
248+
249+
cfg_file
250+
.write_all(&config_json)
251+
.context("Failed to write config")?;
252+
253+
// Write the manifest
254+
let mut manifest_file = image_dir
255+
.open_with("manifest.json", &file_open_opts)
256+
.context("Opening manifest file")?;
257+
258+
new_manifest.set_config(Descriptor::new(
259+
MediaType::ImageConfig,
260+
config_json.len() as u64,
261+
Digest::try_from(format!("sha256:{config_hash}"))?,
262+
));
263+
264+
manifest_file
265+
.write_all(&new_manifest.to_canon_json_vec()?)
266+
.context("Failed to write manifest")?;
267+
268+
println!("Image: {config_hash}");
269+
270+
Ok(())
271+
}

crates/lib/src/bootc_composefs/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub(crate) mod boot;
22
pub(crate) mod delete;
33
pub(crate) mod digest;
4+
pub(crate) mod export;
45
pub(crate) mod finalize;
56
pub(crate) mod gc;
67
pub(crate) mod repo;

crates/lib/src/bootc_composefs/repo.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ pub(crate) fn get_imgref(transport: &str, image: &str) -> String {
6161
let img = image.strip_prefix(":").unwrap_or(&image);
6262
let transport = transport.strip_suffix(":").unwrap_or(&transport);
6363

64-
if transport == "registry" {
64+
if transport == "registry" || transport == "docker://" {
6565
format!("docker://{img}")
6666
} else if transport == "docker-daemon" {
6767
format!("docker-daemon:{img}")

crates/lib/src/bootc_composefs/status.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,10 @@ pub(crate) async fn get_container_manifest_and_config(
198198
let config = containers_image_proxy::ImageProxyConfig::default();
199199
let proxy = containers_image_proxy::ImageProxy::new_with_config(config).await?;
200200

201-
let img = proxy.open_image(&imgref).await.context("Opening image")?;
201+
let img = proxy
202+
.open_image(&imgref)
203+
.await
204+
.with_context(|| format!("Opening image {imgref}"))?;
202205

203206
let (_, manifest) = proxy.fetch_manifest(&img).await?;
204207
let (mut reader, driver) = proxy.get_descriptor(&img, manifest.config()).await?;

crates/lib/src/cli.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,14 @@ use crate::bootc_composefs::{
4343
update::upgrade_composefs,
4444
};
4545
use crate::deploy::{MergeState, RequiredHostSpec};
46-
use crate::lints;
4746
use crate::podstorage::set_additional_image_store;
4847
use crate::progress_jsonl::{ProgressWriter, RawProgressFd};
4948
use crate::spec::Host;
5049
use crate::spec::ImageReference;
5150
use crate::store::{BootedOstree, Storage};
5251
use crate::store::{BootedStorage, BootedStorageKind};
5352
use crate::utils::sigpolicy_from_opt;
53+
use crate::{bootc_composefs, lints};
5454

5555
/// Shared progress options
5656
#[derive(Debug, Parser, PartialEq, Eq)]
@@ -1587,7 +1587,16 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
15871587
list_format,
15881588
} => crate::image::list_entrypoint(list_type, list_format).await,
15891589
ImageOpts::CopyToStorage { source, target } => {
1590-
crate::image::push_entrypoint(source.as_deref(), target.as_deref()).await
1590+
let storage = get_storage().await?;
1591+
1592+
match storage.kind()? {
1593+
BootedStorageKind::Ostree(..) => {
1594+
crate::image::push_entrypoint(source.as_deref(), target.as_deref()).await
1595+
}
1596+
BootedStorageKind::Composefs(booted) => {
1597+
bootc_composefs::export::export_repo_to_oci(&storage, &booted).await
1598+
}
1599+
}
15911600
}
15921601
ImageOpts::SetUnified => crate::image::set_unified_entrypoint().await,
15931602
ImageOpts::PullFromDefaultStorage { image } => {

0 commit comments

Comments
 (0)