Skip to content

Commit 7520fa4

Browse files
authored
Merge pull request #741 from cgwalters/init-container-storage-prep3
A few prep commits for #724
2 parents e25e928 + 81556a4 commit 7520fa4

File tree

4 files changed

+126
-13
lines changed

4 files changed

+126
-13
lines changed

cli/Cargo.toml

+2-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ license = "MIT OR Apache-2.0"
66
repository = "https://github.com/containers/bootc"
77
readme = "README.md"
88
publish = false
9-
rust-version = "1.63.0"
9+
# For now don't bump this above what is currently shipped in RHEL9.
10+
rust-version = "1.75.0"
1011
default-run = "bootc"
1112

1213
# See https://github.com/coreos/cargo-vendor-filterer

lib/Cargo.toml

+3-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ name = "bootc-lib"
66
readme = "README.md"
77
repository = "https://github.com/containers/bootc"
88
version = "0.1.14"
9+
# For now don't bump this above what is currently shipped in RHEL9;
10+
# also keep in sync with the version in cli.
911
rust-version = "1.75.0"
1012
build = "build.rs"
1113

@@ -30,7 +32,7 @@ liboverdrop = "0.1.0"
3032
libsystemd = "0.7"
3133
openssl = "^0.10.64"
3234
regex = "1.10.4"
33-
rustix = { "version" = "0.38.34", features = ["thread", "fs", "system", "process"] }
35+
rustix = { "version" = "0.38.34", features = ["thread", "fs", "system", "process", "mount"] }
3436
schemars = { version = "0.8.17", features = ["chrono"] }
3537
serde = { workspace = true, features = ["derive"] }
3638
serde_ignored = "0.1.10"

lib/src/utils.rs

+110-11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use std::future::Future;
2-
use std::io::Write;
2+
use std::io::{Read, Seek, Write};
33
use std::os::fd::BorrowedFd;
44
use std::process::Command;
55
use std::time::Duration;
@@ -15,17 +15,79 @@ pub(crate) trait CommandRunExt {
1515
fn run(&mut self) -> Result<()>;
1616
}
1717

18+
/// Helpers intended for [`std::process::ExitStatus`].
19+
pub(crate) trait ExitStatusExt {
20+
/// If the exit status signals it was not successful, return an error.
21+
/// Note that we intentionally *don't* include the command string
22+
/// in the output; we leave it to the caller to add that if they want,
23+
/// as it may be verbose.
24+
fn check_status(&mut self, stderr: std::fs::File) -> Result<()>;
25+
}
26+
27+
/// Parse the last chunk (e.g. 1024 bytes) from the provided file,
28+
/// ensure it's UTF-8, and return that value. This function is infallible;
29+
/// if the file cannot be read for some reason, a copy of a static string
30+
/// is returned.
31+
fn last_utf8_content_from_file(mut f: std::fs::File) -> String {
32+
// u16 since we truncate to just the trailing bytes here
33+
// to avoid pathological error messages
34+
const MAX_STDERR_BYTES: u16 = 1024;
35+
let size = f
36+
.metadata()
37+
.map_err(|e| {
38+
tracing::warn!("failed to fstat: {e}");
39+
})
40+
.map(|m| m.len().try_into().unwrap_or(u16::MAX))
41+
.unwrap_or(0);
42+
let size = size.min(MAX_STDERR_BYTES);
43+
let seek_offset = -(size as i32);
44+
let mut stderr_buf = Vec::with_capacity(size.into());
45+
// We should never fail to seek()+read() really, but let's be conservative
46+
let r = match f
47+
.seek(std::io::SeekFrom::End(seek_offset.into()))
48+
.and_then(|_| f.read_to_end(&mut stderr_buf))
49+
{
50+
Ok(_) => String::from_utf8_lossy(&stderr_buf),
51+
Err(e) => {
52+
tracing::warn!("failed seek+read: {e}");
53+
"<failed to read stderr>".into()
54+
}
55+
};
56+
(&*r).to_owned()
57+
}
58+
59+
impl ExitStatusExt for std::process::ExitStatus {
60+
fn check_status(&mut self, stderr: std::fs::File) -> Result<()> {
61+
let stderr_buf = last_utf8_content_from_file(stderr);
62+
if self.success() {
63+
return Ok(());
64+
}
65+
anyhow::bail!(format!("Subprocess failed: {self:?}\n{stderr_buf}"))
66+
}
67+
}
68+
1869
impl CommandRunExt for Command {
1970
/// Synchronously execute the child, and return an error if the child exited unsuccessfully.
2071
fn run(&mut self) -> Result<()> {
21-
let st = self.status()?;
22-
if !st.success() {
23-
// Note that we intentionally *don't* include the command string
24-
// in the output; we leave it to the caller to add that if they want,
25-
// as it may be verbose.
26-
anyhow::bail!(format!("Subprocess failed: {st:?}"))
27-
}
28-
Ok(())
72+
let stderr = tempfile::tempfile()?;
73+
self.stderr(stderr.try_clone()?);
74+
self.status()?.check_status(stderr)
75+
}
76+
}
77+
78+
/// Helpers intended for [`tokio::process::Command`].
79+
#[allow(dead_code)]
80+
pub(crate) trait AsyncCommandRunExt {
81+
async fn run(&mut self) -> Result<()>;
82+
}
83+
84+
impl AsyncCommandRunExt for tokio::process::Command {
85+
/// Asynchronously execute the child, and return an error if the child exited unsuccessfully.
86+
///
87+
async fn run(&mut self) -> Result<()> {
88+
let stderr = tempfile::tempfile()?;
89+
self.stderr(stderr.try_clone()?);
90+
self.status().await?.check_status(stderr)
2991
}
3092
}
3193

@@ -132,14 +194,14 @@ pub(crate) fn medium_visibility_warning(s: &str) {
132194
/// with an automatic spinner to show that we're not blocked.
133195
/// Note that generally the called function should not output
134196
/// anything to stdout as this will interfere with the spinner.
135-
pub(crate) async fn async_task_with_spinner<F, T>(msg: &'static str, f: F) -> T
197+
pub(crate) async fn async_task_with_spinner<F, T>(msg: &str, f: F) -> T
136198
where
137199
F: Future<Output = T>,
138200
{
139201
let pb = indicatif::ProgressBar::new_spinner();
140202
let style = indicatif::ProgressStyle::default_bar();
141203
pb.set_style(style.template("{spinner} {msg}").unwrap());
142-
pb.set_message(msg);
204+
pb.set_message(msg.to_string());
143205
pb.enable_steady_tick(Duration::from_millis(150));
144206
// We need to handle the case where we aren't connected to
145207
// a tty, so indicatif would show nothing by default.
@@ -212,6 +274,43 @@ fn test_sigpolicy_from_opts() {
212274

213275
#[test]
214276
fn command_run_ext() {
277+
// The basics
215278
Command::new("true").run().unwrap();
216279
assert!(Command::new("false").run().is_err());
280+
281+
// Verify we capture stderr
282+
let e = Command::new("/bin/sh")
283+
.args(["-c", "echo expected-this-oops-message 1>&2; exit 1"])
284+
.run()
285+
.err()
286+
.unwrap();
287+
similar_asserts::assert_eq!(
288+
e.to_string(),
289+
"Subprocess failed: ExitStatus(unix_wait_status(256))\nexpected-this-oops-message\n"
290+
);
291+
292+
// Ignoring invalid UTF-8
293+
let e = Command::new("/bin/sh")
294+
.args([
295+
"-c",
296+
r"echo -e 'expected\xf5\x80\x80\x80\x80-foo\xc0bar\xc0\xc0' 1>&2; exit 1",
297+
])
298+
.run()
299+
.err()
300+
.unwrap();
301+
similar_asserts::assert_eq!(
302+
e.to_string(),
303+
"Subprocess failed: ExitStatus(unix_wait_status(256))\nexpected�����-foo�bar��\n"
304+
);
305+
}
306+
307+
#[tokio::test]
308+
async fn async_command_run_ext() {
309+
use tokio::process::Command as AsyncCommand;
310+
let mut success = AsyncCommand::new("true");
311+
let mut fail = AsyncCommand::new("false");
312+
// Run these in parallel just because we can
313+
let (success, fail) = tokio::join!(success.run(), fail.run(),);
314+
success.unwrap();
315+
assert!(fail.is_err());
217316
}

tests-integration/src/install.rs

+11
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use std::path::Path;
22
use std::{os::fd::AsRawFd, path::PathBuf};
33

44
use anyhow::Result;
5+
use camino::Utf8Path;
56
use cap_std_ext::cap_std;
67
use cap_std_ext::cap_std::fs::Dir;
78
use fn_error_context::context;
@@ -53,6 +54,12 @@ fn find_deployment_root() -> Result<Dir> {
5354
anyhow::bail!("Failed to find deployment root")
5455
}
5556

57+
// Hook relatively cheap post-install tests here
58+
fn generic_post_install_verification() -> Result<()> {
59+
assert!(Utf8Path::new("/ostree/repo").try_exists()?);
60+
Ok(())
61+
}
62+
5663
#[context("Install tests")]
5764
pub(crate) fn run_alongside(image: &str, mut testargs: libtest_mimic::Arguments) -> Result<()> {
5865
// Force all of these tests to be serial because they mutate global state
@@ -88,6 +95,8 @@ pub(crate) fn run_alongside(image: &str, mut testargs: libtest_mimic::Arguments)
8895
std::fs::write(&tmp_keys, b"ssh-ed25519 ABC0123 [email protected]")?;
8996
cmd!(sh, "sudo {BASE_ARGS...} {target_args...} -v {tmp_keys}:/test_authorized_keys {image} bootc install to-filesystem {generic_inst_args...} --acknowledge-destructive --karg=foo=bar --replace=alongside --root-ssh-authorized-keys=/test_authorized_keys /target").run()?;
9097

98+
generic_post_install_verification()?;
99+
91100
// Test kargs injected via CLI
92101
cmd!(
93102
sh,
@@ -120,6 +129,7 @@ pub(crate) fn run_alongside(image: &str, mut testargs: libtest_mimic::Arguments)
120129
let sh = &xshell::Shell::new()?;
121130
reset_root(sh)?;
122131
cmd!(sh, "sudo {BASE_ARGS...} {target_args...} {image} bootc install to-existing-root --acknowledge-destructive {generic_inst_args...}").run()?;
132+
generic_post_install_verification()?;
123133
let root = &Dir::open_ambient_dir("/ostree", cap_std::ambient_authority()).unwrap();
124134
let mut path = PathBuf::from(".");
125135
crate::selinux::verify_selinux_recurse(root, &mut path, false)?;
@@ -131,6 +141,7 @@ pub(crate) fn run_alongside(image: &str, mut testargs: libtest_mimic::Arguments)
131141
let empty = sh.create_temp_dir()?;
132142
let empty = empty.path().to_str().unwrap();
133143
cmd!(sh, "sudo {BASE_ARGS...} {target_args...} -v {empty}:/usr/lib/bootc/install {image} bootc install to-existing-root {generic_inst_args...}").run()?;
144+
generic_post_install_verification()?;
134145
Ok(())
135146
}),
136147
];

0 commit comments

Comments
 (0)