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 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 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 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 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
217fn 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 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 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 td.symlink("./blah", format!("{BOUND_IMAGE_DIR}/blah.image"))
298 .unwrap();
299 assert!(query_bound_images(td).is_err());
300
301 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 let value = String::from("quay.io/foo/foo:latest");
314 assert_eq!(parse_spec_value(&value).unwrap(), value);
315
316 let value = String::from("quay.io/foo/%%foo:latest");
318 assert_eq!(parse_spec_value(&value).unwrap(), "quay.io/foo/%foo:latest");
319
320 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 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 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 let value = String::from("quay.io/foo/%foo:latest");
343 assert!(parse_spec_value(&value).is_err());
344
345 let value = String::from("quay.io/foo/%%%foo:latest");
347 assert!(parse_spec_value(&value).is_err());
348
349 let value = String::from("quay.io/foo/%f%ooo:latest");
351 assert!(parse_spec_value(&value).is_err());
352
353 let value = String::from("%fquay.io/foo/foo:latest");
355 assert!(parse_spec_value(&value).is_err());
356
357 let value = String::from("quay.io/foo/foo:latest%f");
359 assert!(parse_spec_value(&value).is_err());
360
361 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 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 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 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 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 let file_contents = tini::Ini::from_string("[Container]\n").unwrap();
404 assert!(parse_container_file(&file_contents).is_err());
405
406 Ok(())
407 }
408}