Skip to content

Commit 870b529

Browse files
ckyrouaccgwalters
andcommitted
deploy: Retrieve bound images when staging new image
This parses any file pointed to by a symlink with a .container or .image extension found in /usr/lib/bootc/bound-images.d. An error is thrown if a systemd specifier is found in the parsed fields. It currently only supports the Image and AuthFile fields. Some known shortcomings are that each image is pulled synchronously. It does not do any cleanup during a rollback or if the switch fails after pulling an image. The install path also needs to pull bound images. Co-authored-by: Colin Walters <[email protected]> Signed-off-by: Chris Kyrouac <[email protected]> Signed-off-by: Colin Walters <[email protected]>
1 parent 42fe361 commit 870b529

File tree

5 files changed

+310
-2
lines changed

5 files changed

+310
-2
lines changed

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ liboverdrop = "0.1.0"
3030
libsystemd = "0.7"
3131
openssl = "^0.10.64"
3232
# TODO drop this in favor of rustix
33-
nix = { version = "0.29", features = ["ioctl", "sched"] }
33+
nix = { version = "0.29", features = ["ioctl", "sched", "fs"] }
3434
regex = "1.10.4"
3535
rustix = { "version" = "0.38.34", features = ["thread", "fs", "system", "process"] }
3636
schemars = { version = "0.8.17", features = ["chrono"] }
@@ -45,6 +45,7 @@ tempfile = "3.10.1"
4545
toml = "0.8.12"
4646
xshell = { version = "0.2.6", optional = true }
4747
uuid = { version = "1.8.0", features = ["v4"] }
48+
tini = "1.3.0"
4849

4950
[dev-dependencies]
5051
similar-asserts = { version = "1.5.0" }

lib/src/boundimage.rs

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
use crate::task::Task;
2+
use anyhow::{Context, Result};
3+
use camino::Utf8Path;
4+
use cap_std_ext::cap_std::fs::Dir;
5+
use cap_std_ext::dirext::CapStdExtDirExt;
6+
use fn_error_context::context;
7+
use ostree_ext::ostree::Deployment;
8+
use ostree_ext::sysroot::SysrootLock;
9+
use rustix::fd::BorrowedFd;
10+
use rustix::fs::{OFlags, ResolveFlags};
11+
use std::fs::File;
12+
use std::io::Read;
13+
use std::os::unix::io::AsFd;
14+
15+
const BOUND_IMAGE_DIR: &'static str = "usr/lib/bootc-experimental/bound-images.d";
16+
17+
// Access the file descriptor for a sysroot
18+
#[allow(unsafe_code)]
19+
pub(crate) fn sysroot_fd(sysroot: &ostree_ext::ostree::Sysroot) -> BorrowedFd {
20+
unsafe { BorrowedFd::borrow_raw(sysroot.fd()) }
21+
}
22+
23+
pub(crate) fn pull_bound_images(sysroot: &SysrootLock, deployment: &Deployment) -> Result<()> {
24+
let sysroot_fd = sysroot_fd(&sysroot);
25+
let sysroot_fd = Dir::reopen_dir(&sysroot_fd)?;
26+
let deployment_root_path = sysroot.deployment_dirpath(&deployment);
27+
let deployment_root = &sysroot_fd.open_dir(&deployment_root_path)?;
28+
29+
let bound_images = parse_spec_dir(&deployment_root, BOUND_IMAGE_DIR)?;
30+
pull_images(deployment_root, bound_images)?;
31+
32+
Ok(())
33+
}
34+
35+
#[context("parse bound image spec dir")]
36+
fn parse_spec_dir(root: &Dir, spec_dir: &str) -> Result<Vec<BoundImage>> {
37+
let Some(bound_images_dir) = root.open_dir_optional(spec_dir)? else {
38+
return Ok(Default::default());
39+
};
40+
41+
let mut bound_images = Vec::new();
42+
43+
for entry in bound_images_dir
44+
.entries()
45+
.context("Unable to read entries")?
46+
{
47+
//validate entry is a symlink with correct extension
48+
let entry = entry?;
49+
let file_name = entry.file_name();
50+
let file_name = if let Some(n) = file_name.to_str() {
51+
n
52+
} else {
53+
anyhow::bail!("Invalid non-UTF8 filename: {file_name:?} in {}", spec_dir);
54+
};
55+
56+
if !entry.file_type()?.is_symlink() {
57+
anyhow::bail!("Not a symlink: {file_name}");
58+
}
59+
60+
//parse the file contents
61+
let path = Utf8Path::new(spec_dir).join(file_name);
62+
let mut file: File = rustix::fs::openat2(
63+
root.as_fd(),
64+
path.as_std_path(),
65+
OFlags::CLOEXEC | OFlags::RDONLY,
66+
rustix::fs::Mode::empty(),
67+
ResolveFlags::IN_ROOT,
68+
)
69+
.context("Unable to openat")?
70+
.into();
71+
72+
let mut file_contents = String::new();
73+
file.read_to_string(&mut file_contents)
74+
.context("Unable to read file contents")?;
75+
76+
let file_ini = tini::Ini::from_string(&file_contents).context("Parse to ini")?;
77+
let file_extension = Utf8Path::new(file_name).extension();
78+
let bound_image = match file_extension {
79+
Some("image") => parse_image_file(file_name, &file_ini),
80+
Some("container") => parse_container_file(file_name, &file_ini),
81+
_ => anyhow::bail!("Invalid file extension: {file_name}"),
82+
}?;
83+
84+
bound_images.push(bound_image);
85+
}
86+
87+
Ok(bound_images)
88+
}
89+
90+
#[context("parse image file {file_name}")]
91+
fn parse_image_file(file_name: &str, file_contents: &tini::Ini) -> Result<BoundImage> {
92+
let image: String = file_contents
93+
.get("Image", "Image")
94+
.ok_or_else(|| anyhow::anyhow!("Missing Image field in {file_name}"))?;
95+
96+
//TODO: auth_files have some semi-complicated edge cases that we need to handle,
97+
// so for now let's bail out if we see one since the existence of an authfile
98+
// will most likely result in a failure to pull the image
99+
let auth_file: Option<String> = file_contents.get("Image", "AuthFile");
100+
if !auth_file.is_none() {
101+
anyhow::bail!("AuthFile is not supported by bound bootc images");
102+
}
103+
104+
let bound_image = BoundImage::new(image.to_string(), None)?;
105+
Ok(bound_image)
106+
}
107+
108+
#[context("parse container file {file_name}")]
109+
fn parse_container_file(file_name: &str, file_contents: &tini::Ini) -> Result<BoundImage> {
110+
let image: String = file_contents
111+
.get("Container", "Image")
112+
.ok_or_else(|| anyhow::anyhow!("Missing Image field in {file_name}"))?;
113+
114+
let bound_image = BoundImage::new(image.to_string(), None)?;
115+
Ok(bound_image)
116+
}
117+
118+
#[context("pull bound images")]
119+
fn pull_images(_deployment_root: &Dir, bound_images: Vec<BoundImage>) -> Result<()> {
120+
//TODO: do this in parallel
121+
for bound_image in bound_images {
122+
let mut task = Task::new("Pulling bound image", "/usr/bin/podman")
123+
.arg("pull")
124+
.arg(&bound_image.image);
125+
if let Some(auth_file) = &bound_image.auth_file {
126+
task = task.arg("--authfile").arg(auth_file);
127+
}
128+
task.run()?;
129+
}
130+
131+
Ok(())
132+
}
133+
134+
#[derive(PartialEq, Eq, PartialOrd)]
135+
struct BoundImage {
136+
image: String,
137+
auth_file: Option<String>,
138+
}
139+
140+
impl BoundImage {
141+
fn new(image: String, auth_file: Option<String>) -> Result<BoundImage> {
142+
validate_spec_value(&image).context("Invalid image value")?;
143+
144+
if let Some(auth_file) = &auth_file {
145+
validate_spec_value(auth_file).context("Invalid auth_file value")?;
146+
}
147+
148+
Ok(BoundImage { image, auth_file })
149+
}
150+
}
151+
152+
impl Ord for BoundImage {
153+
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
154+
self.image.cmp(&other.image)
155+
}
156+
}
157+
158+
fn validate_spec_value(value: &String) -> Result<()> {
159+
let mut number_of_percents = 0;
160+
for char in value.chars() {
161+
if char == '%' {
162+
number_of_percents += 1;
163+
} else if number_of_percents % 2 != 0 {
164+
anyhow::bail!("Systemd specifiers are not supported by bound bootc images: {value}");
165+
} else {
166+
number_of_percents = 0;
167+
}
168+
}
169+
170+
Ok(())
171+
}
172+
173+
#[cfg(test)]
174+
mod tests {
175+
use super::*;
176+
use cap_std_ext::cap_std;
177+
use std::io::Write;
178+
179+
#[test]
180+
fn test_parse_spec_dir() -> Result<()> {
181+
const CONTAINER_IMAGE_DIR: &'static str = "usr/share/containers/systemd";
182+
183+
// Empty dir should return an empty vector
184+
let td = &cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
185+
let images = parse_spec_dir(td, &BOUND_IMAGE_DIR).unwrap();
186+
assert_eq!(images.len(), 0);
187+
188+
td.create_dir_all(BOUND_IMAGE_DIR).unwrap();
189+
td.create_dir_all(CONTAINER_IMAGE_DIR).unwrap();
190+
let images = parse_spec_dir(td, &BOUND_IMAGE_DIR).unwrap();
191+
assert_eq!(images.len(), 0);
192+
193+
// Should return BoundImages
194+
let mut foo_file = td
195+
.create(format!("{CONTAINER_IMAGE_DIR}/foo.image"))
196+
.unwrap();
197+
foo_file.write_all(b"[Image]\n").unwrap();
198+
foo_file.write_all(b"Image=quay.io/foo/foo:latest").unwrap();
199+
td.symlink_contents(
200+
format!("/{CONTAINER_IMAGE_DIR}/foo.image"),
201+
format!("{BOUND_IMAGE_DIR}/foo.image"),
202+
)
203+
.unwrap();
204+
205+
let mut bar_file = td
206+
.create(format!("{CONTAINER_IMAGE_DIR}/bar.image"))
207+
.unwrap();
208+
bar_file.write_all(b"[Image]\n").unwrap();
209+
bar_file.write_all(b"Image=quay.io/bar/bar:latest").unwrap();
210+
td.symlink_contents(
211+
format!("/{CONTAINER_IMAGE_DIR}/bar.image"),
212+
format!("{BOUND_IMAGE_DIR}/bar.image"),
213+
)
214+
.unwrap();
215+
216+
let mut images = parse_spec_dir(td, &BOUND_IMAGE_DIR).unwrap();
217+
images.sort();
218+
assert_eq!(images.len(), 2);
219+
assert_eq!(images[0].image, "quay.io/bar/bar:latest");
220+
assert_eq!(images[1].image, "quay.io/foo/foo:latest");
221+
222+
// Invalid symlink should return an error
223+
td.symlink("./blah", format!("{BOUND_IMAGE_DIR}/blah.image"))
224+
.unwrap();
225+
assert!(parse_spec_dir(td, &BOUND_IMAGE_DIR).is_err());
226+
227+
// Invalid image contents should return an error
228+
let mut error_file = td.create("error.image").unwrap();
229+
error_file.write_all(b"[Image]\n").unwrap();
230+
td.symlink_contents("/error.image", format!("{BOUND_IMAGE_DIR}/error.image"))
231+
.unwrap();
232+
assert!(parse_spec_dir(td, &BOUND_IMAGE_DIR).is_err());
233+
234+
Ok(())
235+
}
236+
237+
#[test]
238+
fn test_validate_spec_value() -> Result<()> {
239+
//should not return an error with no % characters
240+
let value = String::from("[Image]\nImage=quay.io/foo/foo:latest");
241+
validate_spec_value(&value).unwrap();
242+
243+
//should return error when % is NOT followed by another %
244+
let value = String::from("[Image]\nImage=quay.io/foo/%foo:latest");
245+
assert!(validate_spec_value(&value).is_err());
246+
247+
//should not return an error when % is followed by another %
248+
let value = String::from("[Image]\nImage=quay.io/foo/%%foo:latest");
249+
validate_spec_value(&value).unwrap();
250+
251+
//should not return an error when %% is followed by a specifier
252+
let value = String::from("[Image]\nImage=quay.io/foo/%%%foo:latest");
253+
assert!(validate_spec_value(&value).is_err());
254+
255+
Ok(())
256+
}
257+
258+
#[test]
259+
fn test_parse_image_file() -> Result<()> {
260+
//should return BoundImage when no auth_file is present
261+
let file_contents =
262+
tini::Ini::from_string("[Image]\nImage=quay.io/foo/foo:latest").unwrap();
263+
let bound_image = parse_image_file("foo.image", &file_contents).unwrap();
264+
assert_eq!(bound_image.image, "quay.io/foo/foo:latest");
265+
assert_eq!(bound_image.auth_file, None);
266+
267+
//should error when auth_file is present
268+
let file_contents = tini::Ini::from_string(
269+
"[Image]\nImage=quay.io/foo/foo:latest\nAuthFile=/etc/containers/auth.json",
270+
)
271+
.unwrap();
272+
assert!(parse_image_file("foo.image", &file_contents).is_err());
273+
274+
//should return error when missing image field
275+
let file_contents = tini::Ini::from_string("[Image]\n").unwrap();
276+
assert!(parse_image_file("foo.image", &file_contents).is_err());
277+
278+
Ok(())
279+
}
280+
281+
#[test]
282+
fn test_parse_container_file() -> Result<()> {
283+
//should return BoundImage
284+
let file_contents =
285+
tini::Ini::from_string("[Container]\nImage=quay.io/foo/foo:latest").unwrap();
286+
let bound_image = parse_container_file("foo.container", &file_contents).unwrap();
287+
assert_eq!(bound_image.image, "quay.io/foo/foo:latest");
288+
assert_eq!(bound_image.auth_file, None);
289+
290+
//should return error when missing image field
291+
let file_contents = tini::Ini::from_string("[Container]\n").unwrap();
292+
assert!(parse_container_file("foo.container", &file_contents).is_err());
293+
294+
Ok(())
295+
}
296+
}

lib/src/deploy.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,14 +383,17 @@ pub(crate) async fn stage(
383383
) -> Result<()> {
384384
let merge_deployment = sysroot.merge_deployment(Some(stateroot));
385385
let origin = origin_from_imageref(spec.image)?;
386-
crate::deploy::deploy(
386+
let deployment = crate::deploy::deploy(
387387
sysroot,
388388
merge_deployment.as_ref(),
389389
stateroot,
390390
image,
391391
&origin,
392392
)
393393
.await?;
394+
395+
crate::boundimage::pull_bound_images(sysroot, &deployment)?;
396+
394397
crate::deploy::cleanup(sysroot).await?;
395398
println!("Queued for next boot: {:#}", spec.image);
396399
if let Some(version) = image.version.as_deref() {

lib/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#![allow(clippy::needless_borrow)]
1818
#![allow(clippy::needless_borrows_for_generic_args)]
1919

20+
mod boundimage;
2021
pub mod cli;
2122
pub(crate) mod deploy;
2223
pub(crate) mod generator;

0 commit comments

Comments
 (0)