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 proxy = containers_image_proxy::ImageProxy::new().await?;
125        let img = proxy
126            .open_image(&format!("containers-storage:{}", src.image))
127            .await?;
128        let digest = proxy.fetch_manifest(&img).await?.0;
129        Ok(Self {
130            image: src.image.clone(),
131            digest,
132        })
133    }
134}
135
136fn parse_image_file(file_contents: &tini::Ini) -> Result<BoundImage> {
137    let image: String = file_contents
138        .get("Image", "Image")
139        .ok_or_else(|| anyhow::anyhow!("Missing Image field"))?;
140
141    //TODO: auth_files have some semi-complicated edge cases that we need to handle,
142    //      so for now let's bail out if we see one since the existence of an authfile
143    //      will most likely result in a failure to pull the image
144    let auth_file: Option<String> = file_contents.get("Image", "AuthFile");
145    if auth_file.is_some() {
146        anyhow::bail!("AuthFile is not supported by bound bootc images");
147    }
148
149    let bound_image = BoundImage::new(image.to_string(), None)?;
150    Ok(bound_image)
151}
152
153fn parse_container_file(file_contents: &tini::Ini) -> Result<BoundImage> {
154    let image: String = file_contents
155        .get("Container", "Image")
156        .ok_or_else(|| anyhow::anyhow!("Missing Image field"))?;
157
158    let bound_image = BoundImage::new(image.to_string(), None)?;
159    Ok(bound_image)
160}
161
162#[context("Pulling bound images")]
163pub(crate) async fn pull_images(
164    sysroot: &Storage,
165    bound_images: Vec<crate::boundimage::BoundImage>,
166) -> Result<()> {
167    // Always initialize the img store to ensure labels are set when upgrading
168    let imgstore = sysroot.get_ensure_imgstore()?;
169    if bound_images.is_empty() {
170        return Ok(());
171    }
172    pull_images_impl(imgstore, bound_images).await
173}
174
175#[context("Pulling bound images")]
176pub(crate) async fn pull_images_impl(
177    imgstore: &CStorage,
178    bound_images: Vec<crate::boundimage::BoundImage>,
179) -> Result<()> {
180    let n = bound_images.len();
181    tracing::debug!("Pulling bound images: {n}");
182    // TODO: do this in parallel
183    for bound_image in bound_images {
184        let image = &bound_image.image;
185        if imgstore.exists(image).await? {
186            tracing::debug!("Bound image already present: {image}");
187            continue;
188        }
189        let desc = format!("Fetching bound image: {image}");
190        crate::utils::async_task_with_spinner(&desc, async move {
191            imgstore
192                .pull(&bound_image.image, PullMode::IfNotExists)
193                .await
194        })
195        .await?;
196    }
197
198    println!("Bound images stored: {n}");
199
200    Ok(())
201}
202
203impl BoundImage {
204    fn new(image: String, auth_file: Option<String>) -> Result<BoundImage> {
205        let image = parse_spec_value(&image).context("Invalid image value")?;
206
207        let auth_file = if let Some(auth_file) = &auth_file {
208            Some(parse_spec_value(auth_file).context("Invalid auth_file value")?)
209        } else {
210            None
211        };
212
213        Ok(BoundImage { image, auth_file })
214    }
215}
216
217/// Given a string, parse it in a way similar to how systemd would do it.
218/// The primary thing here is that we reject any "specifiers" such as `%a`
219/// etc. We do allow a quoted `%%` to appear in the string, which will
220/// result in a single unquoted `%`.
221fn parse_spec_value(value: &str) -> Result<String> {
222    let mut it = value.chars();
223    let mut ret = String::new();
224    while let Some(c) = it.next() {
225        if c != '%' {
226            ret.push(c);
227            continue;
228        }
229        let c = it.next().ok_or_else(|| anyhow::anyhow!("Unterminated %"))?;
230        match c {
231            '%' => {
232                ret.push('%');
233            }
234            _ => {
235                anyhow::bail!("Systemd specifiers are not supported by bound bootc images: {value}")
236            }
237        }
238    }
239    Ok(ret)
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use cap_std_ext::cap_std;
246
247    #[test]
248    fn test_parse_spec_dir() -> Result<()> {
249        const CONTAINER_IMAGE_DIR: &str = "usr/share/containers/systemd";
250
251        // Empty dir should return an empty vector
252        let td = &cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
253        let images = query_bound_images(td).unwrap();
254        assert_eq!(images.len(), 0);
255
256        td.create_dir_all(BOUND_IMAGE_DIR).unwrap();
257        td.create_dir_all(CONTAINER_IMAGE_DIR).unwrap();
258        let images = query_bound_images(td).unwrap();
259        assert_eq!(images.len(), 0);
260
261        // Should return BoundImages
262        td.write(
263            format!("{CONTAINER_IMAGE_DIR}/foo.image"),
264            indoc::indoc! { r#"
265            [Image]
266            Image=quay.io/foo/foo:latest
267        "# },
268        )
269        .unwrap();
270        td.symlink_contents(
271            format!("/{CONTAINER_IMAGE_DIR}/foo.image"),
272            format!("{BOUND_IMAGE_DIR}/foo.image"),
273        )
274        .unwrap();
275
276        td.write(
277            format!("{CONTAINER_IMAGE_DIR}/bar.image"),
278            indoc::indoc! { r#"
279            [Image]
280            Image=quay.io/bar/bar:latest
281            "# },
282        )
283        .unwrap();
284        td.symlink_contents(
285            format!("/{CONTAINER_IMAGE_DIR}/bar.image"),
286            format!("{BOUND_IMAGE_DIR}/bar.image"),
287        )
288        .unwrap();
289
290        let mut images = query_bound_images(td).unwrap();
291        images.sort_by(|a, b| a.image.as_str().cmp(&b.image.as_str()));
292        assert_eq!(images.len(), 2);
293        assert_eq!(images[0].image, "quay.io/bar/bar:latest");
294        assert_eq!(images[1].image, "quay.io/foo/foo:latest");
295
296        // Invalid symlink should return an error
297        td.symlink("./blah", format!("{BOUND_IMAGE_DIR}/blah.image"))
298            .unwrap();
299        assert!(query_bound_images(td).is_err());
300
301        // Invalid image contents should return an error
302        td.write("error.image", "[Image]\n").unwrap();
303        td.symlink_contents("/error.image", format!("{BOUND_IMAGE_DIR}/error.image"))
304            .unwrap();
305        assert!(query_bound_images(td).is_err());
306
307        Ok(())
308    }
309
310    #[test]
311    fn test_parse_spec_value() -> Result<()> {
312        //should parse string with no % characters
313        let value = String::from("quay.io/foo/foo:latest");
314        assert_eq!(parse_spec_value(&value).unwrap(), value);
315
316        //should parse string with % followed by another %
317        let value = String::from("quay.io/foo/%%foo:latest");
318        assert_eq!(parse_spec_value(&value).unwrap(), "quay.io/foo/%foo:latest");
319
320        //should parse string with multiple separate %%
321        let value = String::from("quay.io/foo/%%foo:%%latest");
322        assert_eq!(
323            parse_spec_value(&value).unwrap(),
324            "quay.io/foo/%foo:%latest"
325        );
326
327        //should parse the string with %% at the start or end
328        let value = String::from("%%quay.io/foo/foo:latest%%");
329        assert_eq!(
330            parse_spec_value(&value).unwrap(),
331            "%quay.io/foo/foo:latest%"
332        );
333
334        //should not return an error with multiple %% in a row
335        let value = String::from("quay.io/foo/%%%%foo:latest");
336        assert_eq!(
337            parse_spec_value(&value).unwrap(),
338            "quay.io/foo/%%foo:latest"
339        );
340
341        //should return error when % is NOT followed by another %
342        let value = String::from("quay.io/foo/%foo:latest");
343        assert!(parse_spec_value(&value).is_err());
344
345        //should return an error when %% is followed by a specifier
346        let value = String::from("quay.io/foo/%%%foo:latest");
347        assert!(parse_spec_value(&value).is_err());
348
349        //should return an error when there are two specifiers
350        let value = String::from("quay.io/foo/%f%ooo:latest");
351        assert!(parse_spec_value(&value).is_err());
352
353        //should return an error with a specifier at the start
354        let value = String::from("%fquay.io/foo/foo:latest");
355        assert!(parse_spec_value(&value).is_err());
356
357        //should return an error with a specifier at the end
358        let value = String::from("quay.io/foo/foo:latest%f");
359        assert!(parse_spec_value(&value).is_err());
360
361        //should return an error with a single % at the end
362        let value = String::from("quay.io/foo/foo:latest%");
363        assert!(parse_spec_value(&value).is_err());
364
365        Ok(())
366    }
367
368    #[test]
369    fn test_parse_image_file() -> Result<()> {
370        //should return BoundImage when no auth_file is present
371        let file_contents =
372            tini::Ini::from_string("[Image]\nImage=quay.io/foo/foo:latest").unwrap();
373        let bound_image = parse_image_file(&file_contents).unwrap();
374        assert_eq!(bound_image.image, "quay.io/foo/foo:latest");
375        assert_eq!(bound_image.auth_file, None);
376
377        //should error when auth_file is present
378        let file_contents = tini::Ini::from_string(indoc::indoc! { "
379            [Image]
380            Image=quay.io/foo/foo:latest
381            AuthFile=/etc/containers/auth.json
382        " })
383        .unwrap();
384        assert!(parse_image_file(&file_contents).is_err());
385
386        //should return error when missing image field
387        let file_contents = tini::Ini::from_string("[Image]\n").unwrap();
388        assert!(parse_image_file(&file_contents).is_err());
389
390        Ok(())
391    }
392
393    #[test]
394    fn test_parse_container_file() -> Result<()> {
395        //should return BoundImage
396        let file_contents =
397            tini::Ini::from_string("[Container]\nImage=quay.io/foo/foo:latest").unwrap();
398        let bound_image = parse_container_file(&file_contents).unwrap();
399        assert_eq!(bound_image.image, "quay.io/foo/foo:latest");
400        assert_eq!(bound_image.auth_file, None);
401
402        //should return error when missing image field
403        let file_contents = tini::Ini::from_string("[Container]\n").unwrap();
404        assert!(parse_container_file(&file_contents).is_err());
405
406        Ok(())
407    }
408}