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