Skip to content

Commit c866bba

Browse files
authored
Merge pull request #659 from ckyrouac/pre-fetch-switch
Retrieve bound images when staging new image
2 parents f74eab9 + 71febde commit c866bba

File tree

5 files changed

+304
-2
lines changed

5 files changed

+304
-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" ] }
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
indoc = "2.0.5"

lib/src/boundimage.rs

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
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: &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_some() {
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)]
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+
fn validate_spec_value(value: &String) -> Result<()> {
153+
let mut number_of_percents = 0;
154+
for char in value.chars() {
155+
if char == '%' {
156+
number_of_percents += 1;
157+
} else if number_of_percents % 2 != 0 {
158+
anyhow::bail!("Systemd specifiers are not supported by bound bootc images: {value}");
159+
} else {
160+
number_of_percents = 0;
161+
}
162+
}
163+
164+
Ok(())
165+
}
166+
167+
#[cfg(test)]
168+
mod tests {
169+
use super::*;
170+
use cap_std_ext::cap_std;
171+
use std::io::Write;
172+
173+
#[test]
174+
fn test_parse_spec_dir() -> Result<()> {
175+
const CONTAINER_IMAGE_DIR: &'static str = "usr/share/containers/systemd";
176+
177+
// Empty dir should return an empty vector
178+
let td = &cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
179+
let images = parse_spec_dir(td, &BOUND_IMAGE_DIR).unwrap();
180+
assert_eq!(images.len(), 0);
181+
182+
td.create_dir_all(BOUND_IMAGE_DIR).unwrap();
183+
td.create_dir_all(CONTAINER_IMAGE_DIR).unwrap();
184+
let images = parse_spec_dir(td, &BOUND_IMAGE_DIR).unwrap();
185+
assert_eq!(images.len(), 0);
186+
187+
// Should return BoundImages
188+
let mut foo_file = td
189+
.create(format!("{CONTAINER_IMAGE_DIR}/foo.image"))
190+
.unwrap();
191+
foo_file.write_all(b"[Image]\n").unwrap();
192+
foo_file.write_all(b"Image=quay.io/foo/foo:latest").unwrap();
193+
td.symlink_contents(
194+
format!("/{CONTAINER_IMAGE_DIR}/foo.image"),
195+
format!("{BOUND_IMAGE_DIR}/foo.image"),
196+
)
197+
.unwrap();
198+
199+
let mut bar_file = td
200+
.create(format!("{CONTAINER_IMAGE_DIR}/bar.image"))
201+
.unwrap();
202+
bar_file.write_all(b"[Image]\n").unwrap();
203+
bar_file.write_all(b"Image=quay.io/bar/bar:latest").unwrap();
204+
td.symlink_contents(
205+
format!("/{CONTAINER_IMAGE_DIR}/bar.image"),
206+
format!("{BOUND_IMAGE_DIR}/bar.image"),
207+
)
208+
.unwrap();
209+
210+
let mut images = parse_spec_dir(td, &BOUND_IMAGE_DIR).unwrap();
211+
images.sort_by(|a, b| a.image.as_str().cmp(&b.image.as_str()));
212+
assert_eq!(images.len(), 2);
213+
assert_eq!(images[0].image, "quay.io/bar/bar:latest");
214+
assert_eq!(images[1].image, "quay.io/foo/foo:latest");
215+
216+
// Invalid symlink should return an error
217+
td.symlink("./blah", format!("{BOUND_IMAGE_DIR}/blah.image"))
218+
.unwrap();
219+
assert!(parse_spec_dir(td, &BOUND_IMAGE_DIR).is_err());
220+
221+
// Invalid image contents should return an error
222+
let mut error_file = td.create("error.image").unwrap();
223+
error_file.write_all(b"[Image]\n").unwrap();
224+
td.symlink_contents("/error.image", format!("{BOUND_IMAGE_DIR}/error.image"))
225+
.unwrap();
226+
assert!(parse_spec_dir(td, &BOUND_IMAGE_DIR).is_err());
227+
228+
Ok(())
229+
}
230+
231+
#[test]
232+
fn test_validate_spec_value() -> Result<()> {
233+
//should not return an error with no % characters
234+
let value = String::from("[Image]\nImage=quay.io/foo/foo:latest");
235+
validate_spec_value(&value).unwrap();
236+
237+
//should return error when % is NOT followed by another %
238+
let value = String::from("[Image]\nImage=quay.io/foo/%foo:latest");
239+
assert!(validate_spec_value(&value).is_err());
240+
241+
//should not return an error when % is followed by another %
242+
let value = String::from("[Image]\nImage=quay.io/foo/%%foo:latest");
243+
validate_spec_value(&value).unwrap();
244+
245+
//should not return an error when %% is followed by a specifier
246+
let value = String::from("[Image]\nImage=quay.io/foo/%%%foo:latest");
247+
assert!(validate_spec_value(&value).is_err());
248+
249+
Ok(())
250+
}
251+
252+
#[test]
253+
fn test_parse_image_file() -> Result<()> {
254+
//should return BoundImage when no auth_file is present
255+
let file_contents =
256+
tini::Ini::from_string("[Image]\nImage=quay.io/foo/foo:latest").unwrap();
257+
let bound_image = parse_image_file("foo.image", &file_contents).unwrap();
258+
assert_eq!(bound_image.image, "quay.io/foo/foo:latest");
259+
assert_eq!(bound_image.auth_file, None);
260+
261+
//should error when auth_file is present
262+
let file_contents = tini::Ini::from_string(
263+
"[Image]\nImage=quay.io/foo/foo:latest\nAuthFile=/etc/containers/auth.json",
264+
)
265+
.unwrap();
266+
assert!(parse_image_file("foo.image", &file_contents).is_err());
267+
268+
//should return error when missing image field
269+
let file_contents = tini::Ini::from_string("[Image]\n").unwrap();
270+
assert!(parse_image_file("foo.image", &file_contents).is_err());
271+
272+
Ok(())
273+
}
274+
275+
#[test]
276+
fn test_parse_container_file() -> Result<()> {
277+
//should return BoundImage
278+
let file_contents =
279+
tini::Ini::from_string("[Container]\nImage=quay.io/foo/foo:latest").unwrap();
280+
let bound_image = parse_container_file("foo.container", &file_contents).unwrap();
281+
assert_eq!(bound_image.image, "quay.io/foo/foo:latest");
282+
assert_eq!(bound_image.auth_file, None);
283+
284+
//should return error when missing image field
285+
let file_contents = tini::Ini::from_string("[Container]\n").unwrap();
286+
assert!(parse_container_file("foo.container", &file_contents).is_err());
287+
288+
Ok(())
289+
}
290+
}

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)