1use super::{COMPONENT_SEPARATOR, CONTENT_ANNOTATION, OstreeImageReference, Transport};
4use super::{ImageReference, OSTREE_COMMIT_LABEL, SignatureSource};
5use crate::chunking::{Chunk, Chunking, ObjectMetaSized};
6use crate::container::skopeo;
7use crate::objectsource::ContentID;
8use crate::tar as ostree_tar;
9use anyhow::{Context, Result, anyhow};
10use camino::{Utf8Path, Utf8PathBuf};
11use cap_std::fs::Dir;
12use cap_std_ext::cap_std;
13use chrono::DateTime;
14use containers_image_proxy::oci_spec;
15use flate2::Compression;
16use fn_error_context::context;
17use gio::glib;
18use oci_spec::image as oci_image;
19use ocidir::{Layer, OciDir};
20use ostree::gio;
21use std::borrow::Cow;
22use std::collections::{BTreeMap, HashMap};
23use std::num::NonZeroU32;
24use tracing::instrument;
25
26pub const LEGACY_VERSION_LABEL: &str = "version";
28pub const DIFFID_LABEL: &str = "ostree.final-diffid";
31pub const BOOTC_LABEL: &str = "containers.bootc";
33
34const BLOB_OSTREE_ANNOTATION: &str = "ostree.encapsulated";
39#[derive(Debug, Default)]
41pub struct Config {
42 pub labels: Option<BTreeMap<String, String>>,
44 pub cmd: Option<Vec<String>>,
46}
47
48fn commit_meta_to_labels<'a>(
49 meta: &glib::VariantDict,
50 keys: impl IntoIterator<Item = &'a str>,
51 opt_keys: impl IntoIterator<Item = &'a str>,
52 labels: &mut HashMap<String, String>,
53) -> Result<()> {
54 for k in keys {
55 let v = meta
56 .lookup::<String>(k)
57 .context("Expected string for commit metadata value")?
58 .ok_or_else(|| anyhow!("Could not find commit metadata key: {}", k))?;
59 labels.insert(k.to_string(), v);
60 }
61 for k in opt_keys {
62 let v = meta
63 .lookup::<String>(k)
64 .context("Expected string for commit metadata value")?;
65 if let Some(v) = v {
66 labels.insert(k.to_string(), v);
67 }
68 }
69 #[allow(clippy::explicit_auto_deref)]
72 if let Some(v) = meta.lookup::<bool>(ostree::METADATA_KEY_BOOTABLE)? {
73 labels.insert(ostree::METADATA_KEY_BOOTABLE.to_string(), v.to_string());
74 labels.insert(BOOTC_LABEL.into(), "1".into());
75 }
76 for k in &[&ostree::METADATA_KEY_LINUX] {
78 if let Some(v) = meta.lookup::<String>(k)? {
79 labels.insert(k.to_string(), v);
80 }
81 }
82 Ok(())
83}
84
85fn export_chunks(
86 repo: &ostree::Repo,
87 commit: &str,
88 ociw: &mut OciDir,
89 chunks: Vec<Chunk>,
90 opts: &ExportOpts,
91) -> Result<Vec<(Layer, String, Vec<String>)>> {
92 chunks
93 .into_iter()
94 .enumerate()
95 .map(|(i, chunk)| -> Result<_> {
96 let mut w = ociw.create_layer(Some(opts.compression()))?;
97 ostree_tar::export_chunk(
98 repo,
99 commit,
100 chunk.content,
101 &mut w,
102 opts.tar_create_parent_dirs,
103 )
104 .with_context(|| format!("Exporting chunk {i}"))?;
105 let w = w.into_inner()?;
106 Ok((w.complete()?, chunk.name, chunk.packages))
107 })
108 .collect()
109}
110
111#[context("Writing ostree root to blob")]
113#[allow(clippy::too_many_arguments)]
114pub(crate) fn export_chunked(
115 repo: &ostree::Repo,
116 commit: &str,
117 ociw: &mut OciDir,
118 manifest: &mut oci_image::ImageManifest,
119 imgcfg: &mut oci_image::ImageConfiguration,
120 labels: &mut HashMap<String, String>,
121 mut chunking: Chunking,
122 opts: &ExportOpts,
123 description: &str,
124) -> Result<()> {
125 let layers = export_chunks(repo, commit, ociw, chunking.take_chunks(), opts)?;
126 let compression = Some(opts.compression());
127
128 let mut w = ociw.create_layer(compression)?;
130 ostree_tar::export_final_chunk(
131 repo,
132 commit,
133 chunking.remainder,
134 &mut w,
135 opts.tar_create_parent_dirs,
136 )?;
137 let w = w.into_inner()?;
138 let ostree_layer = w.complete()?;
139
140 let last_digest = layers
143 .last()
144 .map(|v| &v.0)
145 .unwrap_or(&ostree_layer)
146 .uncompressed_sha256
147 .clone();
148
149 let created = imgcfg
150 .created()
151 .as_deref()
152 .and_then(bootc_utils::try_deserialize_timestamp)
153 .unwrap_or_default();
154 ociw.push_layer_full(
156 manifest,
157 imgcfg,
158 ostree_layer,
159 None::<HashMap<String, String>>,
160 description,
161 created,
162 );
163 let mut buf = [0; 8];
165 let sep = COMPONENT_SEPARATOR.encode_utf8(&mut buf);
166 for (layer, name, mut packages) in layers {
167 let mut annotation_component_layer = HashMap::new();
168 packages.sort();
169 annotation_component_layer.insert(CONTENT_ANNOTATION.to_string(), packages.join(sep));
170 ociw.push_layer_full(
171 manifest,
172 imgcfg,
173 layer,
174 Some(annotation_component_layer),
175 name.as_str(),
176 created,
177 );
178 }
179
180 labels.insert(
183 DIFFID_LABEL.into(),
184 format!("sha256:{}", last_digest.digest()),
185 );
186 Ok(())
187}
188
189#[context("Building oci")]
191#[allow(clippy::too_many_arguments)]
192fn build_oci(
193 repo: &ostree::Repo,
194 rev: &str,
195 writer: &mut OciDir,
196 tag: Option<&str>,
197 config: &Config,
198 opts: ExportOpts,
199) -> Result<()> {
200 let commit = repo.require_rev(rev)?;
201 let commit = commit.as_str();
202 let (commit_v, _) = repo.load_commit(commit)?;
203 let commit_timestamp = DateTime::from_timestamp(
204 ostree::commit_get_timestamp(&commit_v).try_into().unwrap(),
205 0,
206 )
207 .unwrap();
208 let commit_subject = commit_v.child_value(3);
209 let commit_subject = commit_subject.str().ok_or_else(|| {
210 anyhow::anyhow!(
211 "Corrupted commit {}; expecting string value for subject",
212 commit
213 )
214 })?;
215 let commit_meta = &commit_v.child_value(0);
216 let commit_meta = glib::VariantDict::new(Some(commit_meta));
217
218 let mut ctrcfg = opts.container_config.clone().unwrap_or_default();
219 let mut imgcfg = oci_image::ImageConfiguration::default();
220 if let Some(platform) = opts.platform.as_ref() {
222 imgcfg.set_architecture(platform.architecture().clone());
223 imgcfg.set_os(platform.os().clone());
224 }
225
226 let created_at = opts
227 .created
228 .clone()
229 .unwrap_or_else(|| commit_timestamp.format("%Y-%m-%dT%H:%M:%SZ").to_string());
230 imgcfg.set_created(Some(created_at));
231 let mut labels = HashMap::new();
232
233 commit_meta_to_labels(
234 &commit_meta,
235 opts.copy_meta_keys.iter().map(|k| k.as_str()),
236 opts.copy_meta_opt_keys.iter().map(|k| k.as_str()),
237 &mut labels,
238 )?;
239
240 let mut manifest = writer.new_empty_manifest()?.build().unwrap();
241
242 let chunking = opts
243 .package_contentmeta
244 .as_ref()
245 .map(|meta| {
246 crate::chunking::Chunking::from_mapping(
247 repo,
248 commit,
249 meta,
250 &opts.max_layers,
251 opts.prior_build,
252 opts.specific_contentmeta,
253 )
254 })
255 .transpose()?;
256 let chunking = chunking
258 .map(Ok)
259 .unwrap_or_else(|| crate::chunking::Chunking::new(repo, commit))?;
260
261 if let Some(version) = commit_meta.lookup::<String>("version")? {
262 if opts.legacy_version_label {
263 labels.insert(LEGACY_VERSION_LABEL.into(), version.clone());
264 }
265 labels.insert(oci_image::ANNOTATION_VERSION.into(), version);
266 }
267 labels.insert(OSTREE_COMMIT_LABEL.into(), commit.into());
268
269 for (k, v) in config.labels.iter().flat_map(|k| k.iter()) {
270 labels.insert(k.into(), v.into());
271 }
272
273 let mut annos = HashMap::new();
274 annos.insert(BLOB_OSTREE_ANNOTATION.to_string(), "true".to_string());
275 let description = if commit_subject.is_empty() {
276 Cow::Owned(format!("ostree export of commit {commit}"))
277 } else {
278 Cow::Borrowed(commit_subject)
279 };
280
281 export_chunked(
282 repo,
283 commit,
284 writer,
285 &mut manifest,
286 &mut imgcfg,
287 &mut labels,
288 chunking,
289 &opts,
290 &description,
291 )?;
292
293 let cmd = commit_meta.lookup::<Vec<String>>(ostree::COMMIT_META_CONTAINER_CMD)?;
295 #[allow(clippy::unnecessary_lazy_evaluations)]
299 let cmd = config.cmd.as_ref().or_else(|| cmd.as_ref());
300 if let Some(cmd) = cmd {
301 ctrcfg.set_cmd(Some(cmd.clone()));
302 }
303
304 let platform = oci_image::PlatformBuilder::default()
306 .architecture(imgcfg.architecture().clone())
307 .os(imgcfg.os().clone())
308 .build()
309 .unwrap();
310
311 ctrcfg
312 .labels_mut()
313 .get_or_insert_with(Default::default)
314 .extend(labels.clone());
315 imgcfg.set_config(Some(ctrcfg));
316 let ctrcfg = writer.write_config(imgcfg)?;
317 manifest.set_config(ctrcfg);
318 manifest.set_annotations(Some(labels));
319
320 if let Some(tag) = tag {
321 writer.insert_manifest(manifest, Some(tag), platform)?;
322 } else {
323 writer.replace_with_single_manifest(manifest, platform)?;
324 }
325
326 Ok(())
327}
328
329pub(crate) fn parse_oci_path_and_tag(path: &str) -> (&str, Option<&str>) {
333 match path.split_once(':') {
334 Some((path, tag)) => (path, Some(tag)),
335 None => (path, None),
336 }
337}
338
339#[instrument(level = "debug", skip_all)]
341async fn build_impl(
342 repo: &ostree::Repo,
343 ostree_ref: &str,
344 config: &Config,
345 opts: Option<ExportOpts<'_, '_>>,
346 dest: &ImageReference,
347) -> Result<oci_image::Digest> {
348 let mut opts = opts.unwrap_or_default();
349 if dest.transport == Transport::ContainerStorage {
350 opts.skip_compression = true;
351 }
352 let digest = if dest.transport == Transport::OciDir {
353 let (path, tag) = parse_oci_path_and_tag(dest.name.as_str());
354 tracing::debug!("using OCI path={path} tag={tag:?}");
355 if !Utf8Path::new(path).exists() {
356 std::fs::create_dir(path).with_context(|| format!("Creating {path}"))?;
357 }
358 let ocidir = Dir::open_ambient_dir(path, cap_std::ambient_authority())
359 .with_context(|| format!("Opening {path}"))?;
360 let mut ocidir = OciDir::ensure(ocidir).context("Opening OCI")?;
361 build_oci(repo, ostree_ref, &mut ocidir, tag, config, opts)?;
362 None
363 } else {
364 let tempdir = {
365 let vartmp = Dir::open_ambient_dir("/var/tmp", cap_std::ambient_authority())?;
366 cap_std_ext::cap_tempfile::tempdir_in(&vartmp)?
367 };
368 let mut ocidir = OciDir::ensure(tempdir.try_clone()?)?;
369
370 let authfile = opts.authfile.clone();
372 build_oci(repo, ostree_ref, &mut ocidir, None, config, opts)?;
373 drop(ocidir);
374
375 let target_fd = 3i32;
377 let tempoci = ImageReference {
378 transport: Transport::OciDir,
379 name: format!("/proc/self/fd/{target_fd}"),
380 };
381 let digest = skopeo::copy(
382 &tempoci,
383 dest,
384 authfile.as_deref(),
385 Some((std::sync::Arc::new(tempdir.try_clone()?.into()), target_fd)),
386 false,
387 )
388 .await?;
389 Some(digest)
390 };
391 if let Some(digest) = digest {
392 Ok(digest)
393 } else {
394 let imgref = OstreeImageReference {
397 sigverify: SignatureSource::ContainerPolicyAllowInsecure,
398 imgref: dest.to_owned(),
399 };
400 let (_, digest) = super::unencapsulate::fetch_manifest(&imgref)
401 .await
402 .context("Querying manifest after push")?;
403 Ok(digest)
404 }
405}
406
407#[derive(Clone, Debug, Default)]
409#[non_exhaustive]
410pub struct ExportOpts<'m, 'o> {
411 pub skip_compression: bool,
413 pub copy_meta_keys: Vec<String>,
415 pub copy_meta_opt_keys: Vec<String>,
417 pub max_layers: Option<NonZeroU32>,
419 pub authfile: Option<std::path::PathBuf>,
421 pub legacy_version_label: bool,
423 pub container_config: Option<oci_image::Config>,
425 pub platform: Option<oci_image::Platform>,
427 pub prior_build: Option<&'m oci_image::ImageManifest>,
430 pub package_contentmeta: Option<&'o ObjectMetaSized>,
433 pub specific_contentmeta: Option<&'o BTreeMap<ContentID, Vec<(Utf8PathBuf, String)>>>,
436 pub created: Option<String>,
438 pub tar_create_parent_dirs: bool,
440}
441
442impl ExportOpts<'_, '_> {
443 fn compression(&self) -> Compression {
445 if self.skip_compression {
446 Compression::fast()
447 } else {
448 Compression::default()
449 }
450 }
451}
452
453pub async fn encapsulate<S: AsRef<str>>(
457 repo: &ostree::Repo,
458 ostree_ref: S,
459 config: &Config,
460 opts: Option<ExportOpts<'_, '_>>,
461 dest: &ImageReference,
462) -> Result<oci_image::Digest> {
463 build_impl(repo, ostree_ref.as_ref(), config, opts, dest).await
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 #[test]
471 fn test_parse_ocipath() {
472 let default = "/foo/bar";
473 let untagged = "/foo/bar:baz";
474 let tagged = "/foo/bar:baz:latest";
475 assert_eq!(parse_oci_path_and_tag(default), ("/foo/bar", None));
476 assert_eq!(
477 parse_oci_path_and_tag(tagged),
478 ("/foo/bar", Some("baz:latest"))
479 );
480 assert_eq!(parse_oci_path_and_tag(untagged), ("/foo/bar", Some("baz")));
481 }
482}