ostree_ext/container/
encapsulate.rs

1//! APIs for creating container images from OSTree commits
2
3use 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
25/// The label which may be used in addition to the standard OCI label.
26pub const LEGACY_VERSION_LABEL: &str = "version";
27/// The label which indicates where the ostree layers stop, and the
28/// derived ones start.
29pub const DIFFID_LABEL: &str = "ostree.final-diffid";
30/// The label for bootc.
31pub const BOOTC_LABEL: &str = "containers.bootc";
32
33/// Annotation injected into the layer to say that this is an ostree commit.
34/// However, because this gets lost when converted to D2S2 https://docs.docker.com/registry/spec/manifest-v2-2/
35/// schema, it's not actually useful today.  But, we keep it
36/// out of principle.
37const BLOB_OSTREE_ANNOTATION: &str = "ostree.encapsulated";
38/// Configuration for the generated container.
39#[derive(Debug, Default)]
40pub struct Config {
41    /// Additional labels.
42    pub labels: Option<BTreeMap<String, String>>,
43    /// The equivalent of a `Dockerfile`'s `CMD` instruction.
44    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    // Copy standard metadata keys `ostree.bootable` and `ostree.linux`.
69    // Bootable is an odd one out in being a boolean.
70    #[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    // Handle any other string-typed values here.
76    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/// Write an ostree commit to an OCI blob
105#[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    // In V1, the ostree layer comes first
122    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    // Then, we have a label that points to the last chunk.
128    // Note in the pathological case of a single layer chunked v1 image, this could be the ostree layer.
129    let last_digest = layers
130        .last()
131        .map(|v| &v.0)
132        .unwrap_or(&ostree_layer)
133        .uncompressed_sha256
134        .clone();
135
136    // Add the ostree layer
137    ociw.push_layer(manifest, imgcfg, ostree_layer, description, None);
138    // Add the component/content layers
139    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    // This label (mentioned above) points to the last layer that is part of
155    // the ostree commit.
156    labels.insert(
157        DIFFID_LABEL.into(),
158        format!("sha256:{}", last_digest.digest()),
159    );
160    Ok(())
161}
162
163/// Generate an OCI image from a given ostree root
164#[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    // If no chunking was provided, create a logical single chunk.
225    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    // Lookup the cmd embedded in commit metadata
262    let cmd = commit_meta.lookup::<Vec<String>>(ostree::COMMIT_META_CONTAINER_CMD)?;
263    // But support it being overridden by CLI options
264
265    // https://github.com/rust-lang/rust-clippy/pull/7639#issuecomment-1050340564
266    #[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
290/// Interpret a filesystem path as optionally including a tag.  Paths
291/// such as `/foo/bar` will return `("/foo/bar"`, None)`, whereas
292/// e.g. `/foo/bar:latest` will return `("/foo/bar", Some("latest"))`.
293pub(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/// Helper for `build()` that avoids generics
301#[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        // Minor TODO: refactor to avoid clone
331        let authfile = opts.authfile.clone();
332        build_oci(repo, ostree_ref, &mut ocidir, None, config, opts)?;
333        drop(ocidir);
334
335        // Pass the temporary oci directory as the current working directory for the skopeo process
336        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        // If `skopeo copy` doesn't have `--digestfile` yet, then fall back
355        // to running an inspect cycle.
356        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/// Options controlling commit export into OCI
368#[derive(Clone, Debug, Default)]
369#[non_exhaustive]
370pub struct ExportOpts<'m, 'o> {
371    /// If true, do not perform gzip compression of the tar layers.
372    pub skip_compression: bool,
373    /// A set of commit metadata keys to copy as image labels.
374    pub copy_meta_keys: Vec<String>,
375    /// A set of optionally-present commit metadata keys to copy as image labels.
376    pub copy_meta_opt_keys: Vec<String>,
377    /// Maximum number of layers to use
378    pub max_layers: Option<NonZeroU32>,
379    /// Path to Docker-formatted authentication file.
380    pub authfile: Option<std::path::PathBuf>,
381    /// Also include the legacy `version` label.
382    pub legacy_version_label: bool,
383    /// Image runtime configuration that will be used as a base
384    pub container_config: Option<oci_image::Config>,
385    /// A reference to the metadata for a previous build; used to optimize
386    /// the packing structure.
387    pub prior_build: Option<&'m oci_image::ImageManifest>,
388    /// Metadata mapping between objects and their owning component/package;
389    /// used to optimize packing.
390    pub contentmeta: Option<&'o ObjectMetaSized>,
391    /// Sets the created tag in the image manifest.
392    pub created: Option<String>,
393}
394
395impl<'m, 'o> ExportOpts<'m, 'o> {
396    /// Return the gzip compression level to use, as configured by the export options.
397    fn compression(&self) -> Compression {
398        if self.skip_compression {
399            Compression::fast()
400        } else {
401            Compression::default()
402        }
403    }
404}
405
406/// Given an OSTree repository and ref, generate a container image.
407///
408/// The returned `ImageReference` will contain a digested (e.g. `@sha256:`) version of the destination.
409pub 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}