bootc_lib/bootc_composefs/
export.rs

1use std::{fs::File, os::fd::AsRawFd};
2
3use anyhow::{Context, Result};
4use cap_std_ext::cap_std::{ambient_authority, fs::Dir};
5use cfsctl::composefs;
6use cfsctl::composefs_oci;
7use composefs::splitstream::SplitStreamData;
8use composefs_oci::open_config;
9use ocidir::{OciDir, oci_spec::image::Platform};
10use ostree_ext::container::Transport;
11use ostree_ext::container::skopeo;
12use tar::EntryType;
13
14use crate::image::get_imgrefs_for_copy;
15use crate::{
16    bootc_composefs::status::{get_composefs_status, get_imginfo},
17    store::{BootedComposefs, Storage},
18};
19
20/// Exports a composefs repository to a container image in containers-storage:
21pub async fn export_repo_to_image(
22    storage: &Storage,
23    booted_cfs: &BootedComposefs,
24    source: Option<&str>,
25    target: Option<&str>,
26) -> Result<()> {
27    let host = get_composefs_status(storage, booted_cfs).await?;
28
29    let (source, dest_imgref) = get_imgrefs_for_copy(&host, source, target).await?;
30
31    let mut depl_verity = None;
32
33    for depl in host.list_deployments() {
34        let img = &depl.image.as_ref().unwrap().image;
35
36        // Not checking transport here as we'll be pulling from the repo anyway
37        // So, image name is all we need
38        if img.image == source.name {
39            depl_verity = Some(depl.require_composefs()?.verity.clone());
40            break;
41        }
42    }
43
44    let depl_verity = depl_verity.ok_or_else(|| anyhow::anyhow!("Image {source} not found"))?;
45
46    let imginfo = get_imginfo(storage, &depl_verity, None).await?;
47
48    // We want the digest in the form of "sha256:abc123"
49    let config_digest = format!("{}", imginfo.manifest.config().digest());
50
51    let var_tmp =
52        Dir::open_ambient_dir("/var/tmp", ambient_authority()).context("Opening /var/tmp")?;
53
54    let tmpdir = cap_std_ext::cap_tempfile::tempdir_in(&var_tmp)?;
55    let oci_dir = OciDir::ensure(tmpdir.try_clone()?).context("Opening OCI")?;
56
57    // Use composefs_oci::open_config to get the config and layer map
58    let (config, layer_map) =
59        open_config(&*booted_cfs.repo, &config_digest, None).context("Opening config")?;
60
61    // We can't guarantee that we'll get the same tar stream as the container image
62    // So we create new config and manifest
63    let mut new_config = config.clone();
64    if let Some(history) = new_config.history_mut() {
65        history.clear();
66    }
67    new_config.rootfs_mut().diff_ids_mut().clear();
68
69    let mut new_manifest = imginfo.manifest.clone();
70    new_manifest.layers_mut().clear();
71
72    let total_layers = config.rootfs().diff_ids().len();
73
74    for (idx, old_diff_id) in config.rootfs().diff_ids().iter().enumerate() {
75        // Look up the layer verity from the map
76        let layer_verity = layer_map
77            .get(old_diff_id.as_str())
78            .ok_or_else(|| anyhow::anyhow!("Layer {old_diff_id} not found in config"))?;
79
80        let mut layer_stream = booted_cfs.repo.open_stream("", Some(layer_verity), None)?;
81
82        let mut layer_writer = oci_dir.create_layer(None)?;
83        layer_writer.follow_symlinks(false);
84
85        let mut got_zero_block = false;
86
87        loop {
88            let mut buf = [0u8; 512];
89
90            if !layer_stream
91                .read_inline_exact(&mut buf)
92                .context("Reading into buffer")?
93            {
94                break;
95            }
96
97            let all_zeroes = buf.iter().all(|x| *x == 0);
98
99            // EOF for tar
100            if all_zeroes && got_zero_block {
101                break;
102            } else if all_zeroes {
103                got_zero_block = true;
104                continue;
105            }
106
107            got_zero_block = false;
108
109            let header = tar::Header::from_byte_slice(&buf);
110
111            let size = header.entry_size()?;
112
113            match layer_stream.read_exact(size as usize, ((size as usize) + 511) & !511)? {
114                SplitStreamData::External(obj_id) => match header.entry_type() {
115                    EntryType::Regular | EntryType::Continuous => {
116                        let file = File::from(booted_cfs.repo.open_object(&obj_id)?);
117
118                        layer_writer
119                            .append(&header, file)
120                            .context("Failed to write external entry")?;
121                    }
122
123                    _ => anyhow::bail!("Unsupported external-chunked entry {header:?} {obj_id:?}"),
124                },
125
126                SplitStreamData::Inline(content) => match header.entry_type() {
127                    EntryType::Directory => {
128                        layer_writer.append(&header, std::io::empty())?;
129                    }
130
131                    // We do not care what the content is as we're re-archiving it anyway
132                    _ => {
133                        layer_writer
134                            .append(&header, &*content)
135                            .context("Failed to write inline entry")?;
136                    }
137                },
138            };
139        }
140
141        layer_writer.finish()?;
142
143        let layer = layer_writer
144            .into_inner()
145            .context("Getting inner layer writer")?
146            .complete()
147            .context("Writing layer to disk")?;
148
149        tracing::debug!(
150            "Wrote layer: {layer_sha} #{layer_num}/{total_layers}",
151            layer_sha = layer.uncompressed_sha256_as_digest(),
152            layer_num = idx + 1,
153        );
154
155        let previous_annotations = imginfo
156            .manifest
157            .layers()
158            .get(idx)
159            .and_then(|l| l.annotations().as_ref())
160            .cloned();
161
162        let history = imginfo.config.history().as_ref();
163        let history_entry = history.and_then(|v| v.get(idx));
164        let previous_description = history_entry
165            .clone()
166            .and_then(|h| h.comment().as_deref())
167            .unwrap_or_default();
168
169        let previous_created = history_entry
170            .and_then(|h| h.created().as_deref())
171            .and_then(bootc_utils::try_deserialize_timestamp)
172            .unwrap_or_default();
173
174        oci_dir.push_layer_full(
175            &mut new_manifest,
176            &mut new_config,
177            layer,
178            previous_annotations,
179            previous_description,
180            previous_created,
181        );
182    }
183
184    let descriptor = oci_dir.write_config(new_config).context("Writing config")?;
185
186    new_manifest.set_config(descriptor);
187    oci_dir
188        .insert_manifest(new_manifest, None, Platform::default())
189        .context("Writing manifest")?;
190
191    // Pass the temporary oci directory as the current working directory for the skopeo process
192    let tempoci = ostree_ext::container::ImageReference {
193        transport: Transport::OciDir,
194        name: format!("/proc/self/fd/{}", tmpdir.as_raw_fd()),
195    };
196
197    skopeo::copy(
198        &tempoci,
199        &dest_imgref,
200        None,
201        Some((
202            std::sync::Arc::new(tmpdir.try_clone()?.into()),
203            tmpdir.as_raw_fd(),
204        )),
205        true,
206    )
207    .await?;
208
209    Ok(())
210}