bootc_lib/
boundimage.rs

1//! # Implementation of "logically bound" container images
2//!
3//! This module implements the design in <https://github.com/bootc-dev/bootc/issues/128>
4//! for "logically bound" container images. These container images are
5//! pre-pulled (and in the future, pinned) before a new image root
6//! is considered ready.
7
8use anyhow::{Context, Result};
9use camino::Utf8Path;
10use cap_std_ext::cap_std::fs::Dir;
11use cap_std_ext::dirext::CapStdExtDirExt;
12use fn_error_context::context;
13use ostree_ext::containers_image_proxy;
14use ostree_ext::ostree::Deployment;
15
16use crate::podstorage::{CStorage, PullMode};
17use crate::store::Storage;
18
19/// The path in a root for bound images; this directory should only contain
20/// symbolic links to `.container` or `.image` files.
21const BOUND_IMAGE_DIR: &str = "usr/lib/bootc/bound-images.d";
22
23/// A subset of data parsed from a `.image` or `.container` file with
24/// the minimal information necessary to fetch the image.
25///
26/// In the future this may be extended to include e.g. certificates or
27/// other pull options.
28#[derive(Debug, PartialEq, Eq)]
29pub(crate) struct BoundImage {
30    pub(crate) image: String,
31    pub(crate) auth_file: Option<String>,
32}
33
34#[derive(Debug, PartialEq, Eq)]
35pub(crate) struct ResolvedBoundImage {
36    pub(crate) image: String,
37    pub(crate) digest: String,
38}
39
40/// Given a deployment, pull all container images it references.
41pub(crate) async fn pull_bound_images(sysroot: &Storage, deployment: &Deployment) -> Result<()> {
42    // Log the bound images operation to systemd journal
43    const BOUND_IMAGES_JOURNAL_ID: &str = "1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5";
44    tracing::info!(
45        message_id = BOUND_IMAGES_JOURNAL_ID,
46        bootc.deployment.osname = deployment.osname().as_str(),
47        bootc.deployment.checksum = deployment.csum().as_str(),
48        "Starting pull of bound images for deployment"
49    );
50
51    let ostree = sysroot.get_ostree()?;
52    let bound_images = query_bound_images_for_deployment(ostree, deployment)?;
53    tracing::info!(
54        message_id = BOUND_IMAGES_JOURNAL_ID,
55        bootc.bound_images_count = bound_images.len(),
56        "Found {} bound images to pull",
57        bound_images.len()
58    );
59    pull_images(sysroot, bound_images).await
60}
61
62#[context("Querying bound images")]
63pub(crate) fn query_bound_images_for_deployment(
64    sysroot: &ostree_ext::ostree::Sysroot,
65    deployment: &Deployment,
66) -> Result<Vec<BoundImage>> {
67    let deployment_root = &crate::utils::deployment_fd(sysroot, deployment)?;
68    query_bound_images(deployment_root)
69}
70
71#[context("Querying bound images")]
72pub(crate) fn query_bound_images(root: &Dir) -> Result<Vec<BoundImage>> {
73    let spec_dir = BOUND_IMAGE_DIR;
74    let Some(bound_images_dir) = root.open_dir_optional(spec_dir)? else {
75        tracing::debug!("Missing {spec_dir}");
76        return Ok(Default::default());
77    };
78    // And open a view of the dir that uses RESOLVE_IN_ROOT so we
79    // handle absolute symlinks.
80    let absroot = &root.open_dir_rooted_ext(".")?;
81
82    let mut bound_images = Vec::new();
83
84    for entry in bound_images_dir
85        .entries()
86        .context("Unable to read entries")?
87    {
88        //validate entry is a symlink with correct extension
89        let entry = entry?;
90        let file_name = entry.file_name();
91        let file_name = if let Some(n) = file_name.to_str() {
92            n
93        } else {
94            anyhow::bail!("Invalid non-UTF8 filename: {file_name:?} in {}", spec_dir);
95        };
96
97        if !entry.file_type()?.is_symlink() {
98            anyhow::bail!("Not a symlink: {file_name}");
99        }
100
101        //parse the file contents
102        let path = Utf8Path::new(spec_dir).join(file_name);
103        let file_contents = absroot.read_to_string(&path)?;
104
105        let file_ini = tini::Ini::from_string(&file_contents).context("Parse to ini")?;
106        let file_extension = Utf8Path::new(file_name).extension();
107        let bound_image = match file_extension {
108            Some("image") => parse_image_file(&file_ini).with_context(|| format!("Parsing {path}")),
109            Some("container") => {
110                parse_container_file(&file_ini).with_context(|| format!("Parsing {path}"))
111            }
112            _ => anyhow::bail!("Invalid file extension: {file_name}"),
113        }?;
114
115        bound_images.push(bound_image);
116    }
117
118    Ok(bound_images)
119}
120
121impl ResolvedBoundImage {
122    #[context("resolving bound image {}", src.image)]
123    pub(crate) async fn from_image(src: &BoundImage) -> Result<Self> {
124        let config = crate::deploy::new_proxy_config();
125        let proxy = containers_image_proxy::ImageProxy::new_with_config(config).await?;
126        let img = proxy
127            .open_image(&format!("containers-storage:{}", src.image))
128            .await?;
129        let digest = proxy.fetch_manifest(&img).await?.0;
130        Ok(Self {
131            image: src.image.clone(),
132            digest,
133        })
134    }
135}
136
137fn parse_image_file(file_contents: &tini::Ini) -> Result<BoundImage> {
138    let image: String = file_contents
139        .get("Image", "Image")
140        .ok_or_else(|| anyhow::anyhow!("Missing Image field"))?;
141
142    //TODO: auth_files have some semi-complicated edge cases that we need to handle,
143    //      so for now let's bail out if we see one since the existence of an authfile
144    //      will most likely result in a failure to pull the image
145    let auth_file: Option<String> = file_contents.get("Image", "AuthFile");
146    if auth_file.is_some() {
147        anyhow::bail!("AuthFile is not supported by bound bootc images");
148    }
149
150    let bound_image = BoundImage::new(image.to_string(), None)?;
151    Ok(bound_image)
152}
153
154fn parse_container_file(file_contents: &tini::Ini) -> Result<BoundImage> {
155    let image: String = file_contents
156        .get("Container", "Image")
157        .ok_or_else(|| anyhow::anyhow!("Missing Image field"))?;
158
159    let bound_image = BoundImage::new(image.to_string(), None)?;
160    Ok(bound_image)
161}
162
163#[context("Pulling bound images")]
164pub(crate) async fn pull_images(
165    sysroot: &Storage,
166    bound_images: Vec<crate::boundimage::BoundImage>,
167) -> Result<()> {
168    // Always initialize the img store to ensure labels are set when upgrading
169    let imgstore = sysroot.get_ensure_imgstore()?;
170    if bound_images.is_empty() {
171        return Ok(());
172    }
173    pull_images_impl(imgstore, bound_images).await
174}
175
176#[context("Pulling bound images")]
177pub(crate) async fn pull_images_impl(
178    imgstore: &CStorage,
179    bound_images: Vec<crate::boundimage::BoundImage>,
180) -> Result<()> {
181    let n = bound_images.len();
182    tracing::debug!("Pulling bound images: {n}");
183    // TODO: do this in parallel
184    for bound_image in bound_images {
185        let image = &bound_image.image;
186        if imgstore.exists(image).await? {
187            tracing::debug!("Bound image already present: {image}");
188            continue;
189        }
190        let desc = format!("Fetching bound image: {image}");
191        crate::utils::async_task_with_spinner(&desc, async move {
192            imgstore
193                .pull(&bound_image.image, PullMode::IfNotExists)
194                .await
195        })
196        .await?;
197    }
198
199    println!("Bound images stored: {n}");
200
201    Ok(())
202}
203
204impl BoundImage {
205    fn new(image: String, auth_file: Option<String>) -> Result<BoundImage> {
206        let image = parse_spec_value(&image).context("Invalid image value")?;
207
208        let auth_file = if let Some(auth_file) = &auth_file {
209            Some(parse_spec_value(auth_file).context("Invalid auth_file value")?)
210        } else {
211            None
212        };
213
214        Ok(BoundImage { image, auth_file })
215    }
216}
217
218/// Given a string, parse it in a way similar to how systemd would do it.
219/// The primary thing here is that we reject any "specifiers" such as `%a`
220/// etc. We do allow a quoted `%%` to appear in the string, which will
221/// result in a single unquoted `%`.
222fn parse_spec_value(value: &str) -> Result<String> {
223    let mut it = value.chars();
224    let mut ret = String::new();
225    while let Some(c) = it.next() {
226        if c != '%' {
227            ret.push(c);
228            continue;
229        }
230        let c = it.next().ok_or_else(|| anyhow::anyhow!("Unterminated %"))?;
231        match c {
232            '%' => {
233                ret.push('%');
234            }
235            _ => {
236                anyhow::bail!("Systemd specifiers are not supported by bound bootc images: {value}")
237            }
238        }
239    }
240    Ok(ret)
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use cap_std_ext::cap_std;
247
248    #[test]
249    fn test_parse_spec_dir() -> Result<()> {
250        const CONTAINER_IMAGE_DIR: &str = "usr/share/containers/systemd";
251
252        // Empty dir should return an empty vector
253        let td = &cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
254        let images = query_bound_images(td).unwrap();
255        assert_eq!(images.len(), 0);
256
257        td.create_dir_all(BOUND_IMAGE_DIR).unwrap();
258        td.create_dir_all(CONTAINER_IMAGE_DIR).unwrap();
259        let images = query_bound_images(td).unwrap();
260        assert_eq!(images.len(), 0);
261
262        // Should return BoundImages
263        td.write(
264            format!("{CONTAINER_IMAGE_DIR}/foo.image"),
265            indoc::indoc! { r#"
266            [Image]
267            Image=quay.io/foo/foo:latest
268        "# },
269        )
270        .unwrap();
271        td.symlink_contents(
272            format!("/{CONTAINER_IMAGE_DIR}/foo.image"),
273            format!("{BOUND_IMAGE_DIR}/foo.image"),
274        )
275        .unwrap();
276
277        td.write(
278            format!("{CONTAINER_IMAGE_DIR}/bar.image"),
279            indoc::indoc! { r#"
280            [Image]
281            Image=quay.io/bar/bar:latest
282            "# },
283        )
284        .unwrap();
285        td.symlink_contents(
286            format!("/{CONTAINER_IMAGE_DIR}/bar.image"),
287            format!("{BOUND_IMAGE_DIR}/bar.image"),
288        )
289        .unwrap();
290
291        let mut images = query_bound_images(td).unwrap();
292        images.sort_by(|a, b| a.image.as_str().cmp(&b.image.as_str()));
293        assert_eq!(images.len(), 2);
294        assert_eq!(images[0].image, "quay.io/bar/bar:latest");
295        assert_eq!(images[1].image, "quay.io/foo/foo:latest");
296
297        // Invalid symlink should return an error
298        td.symlink("./blah", format!("{BOUND_IMAGE_DIR}/blah.image"))
299            .unwrap();
300        assert!(query_bound_images(td).is_err());
301
302        // Invalid image contents should return an error
303        td.write("error.image", "[Image]\n").unwrap();
304        td.symlink_contents("/error.image", format!("{BOUND_IMAGE_DIR}/error.image"))
305            .unwrap();
306        assert!(query_bound_images(td).is_err());
307
308        Ok(())
309    }
310
311    #[test]
312    fn test_parse_spec_value() -> Result<()> {
313        //should parse string with no % characters
314        let value = String::from("quay.io/foo/foo:latest");
315        assert_eq!(parse_spec_value(&value).unwrap(), value);
316
317        //should parse string with % followed by another %
318        let value = String::from("quay.io/foo/%%foo:latest");
319        assert_eq!(parse_spec_value(&value).unwrap(), "quay.io/foo/%foo:latest");
320
321        //should parse string with multiple separate %%
322        let value = String::from("quay.io/foo/%%foo:%%latest");
323        assert_eq!(
324            parse_spec_value(&value).unwrap(),
325            "quay.io/foo/%foo:%latest"
326        );
327
328        //should parse the string with %% at the start or end
329        let value = String::from("%%quay.io/foo/foo:latest%%");
330        assert_eq!(
331            parse_spec_value(&value).unwrap(),
332            "%quay.io/foo/foo:latest%"
333        );
334
335        //should not return an error with multiple %% in a row
336        let value = String::from("quay.io/foo/%%%%foo:latest");
337        assert_eq!(
338            parse_spec_value(&value).unwrap(),
339            "quay.io/foo/%%foo:latest"
340        );
341
342        //should return error when % is NOT followed by another %
343        let value = String::from("quay.io/foo/%foo:latest");
344        assert!(parse_spec_value(&value).is_err());
345
346        //should return an error when %% is followed by a specifier
347        let value = String::from("quay.io/foo/%%%foo:latest");
348        assert!(parse_spec_value(&value).is_err());
349
350        //should return an error when there are two specifiers
351        let value = String::from("quay.io/foo/%f%ooo:latest");
352        assert!(parse_spec_value(&value).is_err());
353
354        //should return an error with a specifier at the start
355        let value = String::from("%fquay.io/foo/foo:latest");
356        assert!(parse_spec_value(&value).is_err());
357
358        //should return an error with a specifier at the end
359        let value = String::from("quay.io/foo/foo:latest%f");
360        assert!(parse_spec_value(&value).is_err());
361
362        //should return an error with a single % at the end
363        let value = String::from("quay.io/foo/foo:latest%");
364        assert!(parse_spec_value(&value).is_err());
365
366        Ok(())
367    }
368
369    #[test]
370    fn test_parse_image_file() -> Result<()> {
371        //should return BoundImage when no auth_file is present
372        let file_contents =
373            tini::Ini::from_string("[Image]\nImage=quay.io/foo/foo:latest").unwrap();
374        let bound_image = parse_image_file(&file_contents).unwrap();
375        assert_eq!(bound_image.image, "quay.io/foo/foo:latest");
376        assert_eq!(bound_image.auth_file, None);
377
378        //should error when auth_file is present
379        let file_contents = tini::Ini::from_string(indoc::indoc! { "
380            [Image]
381            Image=quay.io/foo/foo:latest
382            AuthFile=/etc/containers/auth.json
383        " })
384        .unwrap();
385        assert!(parse_image_file(&file_contents).is_err());
386
387        //should return error when missing image field
388        let file_contents = tini::Ini::from_string("[Image]\n").unwrap();
389        assert!(parse_image_file(&file_contents).is_err());
390
391        Ok(())
392    }
393
394    #[test]
395    fn test_parse_container_file() -> Result<()> {
396        //should return BoundImage
397        let file_contents =
398            tini::Ini::from_string("[Container]\nImage=quay.io/foo/foo:latest").unwrap();
399        let bound_image = parse_container_file(&file_contents).unwrap();
400        assert_eq!(bound_image.image, "quay.io/foo/foo:latest");
401        assert_eq!(bound_image.auth_file, None);
402
403        //should return error when missing image field
404        let file_contents = tini::Ini::from_string("[Container]\n").unwrap();
405        assert!(parse_container_file(&file_contents).is_err());
406
407        Ok(())
408    }
409}