1use 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
19const BOUND_IMAGE_DIR: &str = "usr/lib/bootc/bound-images.d";
22
23#[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
40pub(crate) async fn pull_bound_images(sysroot: &Storage, deployment: &Deployment) -> Result<()> {
42 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 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 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 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 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 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 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
218fn 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 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 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 td.symlink("./blah", format!("{BOUND_IMAGE_DIR}/blah.image"))
299 .unwrap();
300 assert!(query_bound_images(td).is_err());
301
302 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 let value = String::from("quay.io/foo/foo:latest");
315 assert_eq!(parse_spec_value(&value).unwrap(), value);
316
317 let value = String::from("quay.io/foo/%%foo:latest");
319 assert_eq!(parse_spec_value(&value).unwrap(), "quay.io/foo/%foo:latest");
320
321 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 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 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 let value = String::from("quay.io/foo/%foo:latest");
344 assert!(parse_spec_value(&value).is_err());
345
346 let value = String::from("quay.io/foo/%%%foo:latest");
348 assert!(parse_spec_value(&value).is_err());
349
350 let value = String::from("quay.io/foo/%f%ooo:latest");
352 assert!(parse_spec_value(&value).is_err());
353
354 let value = String::from("%fquay.io/foo/foo:latest");
356 assert!(parse_spec_value(&value).is_err());
357
358 let value = String::from("quay.io/foo/foo:latest%f");
360 assert!(parse_spec_value(&value).is_err());
361
362 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 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 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 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 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 let file_contents = tini::Ini::from_string("[Container]\n").unwrap();
405 assert!(parse_container_file(&file_contents).is_err());
406
407 Ok(())
408 }
409}