diff --git a/Cargo.lock b/Cargo.lock index 2f1340ffa..1b8533a6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2950,6 +2950,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "shlex", "tempfile", "tracing", "uzers", diff --git a/crates/system-reinstall-bootc/Cargo.toml b/crates/system-reinstall-bootc/Cargo.toml index 627823443..f3bbf5ffa 100644 --- a/crates/system-reinstall-bootc/Cargo.toml +++ b/crates/system-reinstall-bootc/Cargo.toml @@ -28,6 +28,7 @@ log = { workspace = true } rustix = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +shlex = { workspace = true } tempfile = { workspace = true } tracing = { workspace = true } uzers = { workspace = true } diff --git a/crates/system-reinstall-bootc/src/main.rs b/crates/system-reinstall-bootc/src/main.rs index d4626456c..c90976554 100644 --- a/crates/system-reinstall-bootc/src/main.rs +++ b/crates/system-reinstall-bootc/src/main.rs @@ -1,7 +1,7 @@ //! The main entrypoint for the bootc system reinstallation CLI use anyhow::{Context, Result, ensure}; -use bootc_utils::CommandRunExt; +use bootc_utils::{CommandRunExt, ResultExt}; use clap::Parser; use fn_error_context::context; use rustix::process::getuid; @@ -10,11 +10,14 @@ use std::time::Duration; mod btrfs; mod config; mod lvm; +mod os_release; mod podman; mod prompt; pub(crate) mod users; const ROOT_KEY_MOUNT_POINT: &str = "/bootc_authorized_ssh_keys/root"; +const ETC_OS_RELEASE: &str = "/etc/os-release"; +const USR_LIB_OS_RELEASE: &str = "/usr/lib/os-release"; /// Reinstall the system using the provided bootc container. /// @@ -24,17 +27,52 @@ const ROOT_KEY_MOUNT_POINT: &str = "/bootc_authorized_ssh_keys/root"; /// If the environment variable BOOTC_REINSTALL_CONFIG is set, it must be a YAML /// file with a single member `bootc_image` that specifies the image to install. /// This will take precedence over the CLI. -#[derive(clap::Parser)] pub(crate) struct ReinstallOpts { /// The bootc image to install pub(crate) image: String, // Note if we ever add any other options here, + pub(crate) composefs_backend: bool, +} + +#[derive(clap::Parser)] +pub(crate) struct ReinstallOptsArgs { + /// The bootc image to install + pub(crate) image: Option, + // Note if we ever add any other options here, #[arg(long)] pub(crate) composefs_backend: bool, } +impl ReinstallOptsArgs { + pub(crate) fn build(self) -> Result { + let image = if let Some(image) = self.image { + image + } else { + [ETC_OS_RELEASE, USR_LIB_OS_RELEASE] + .iter() + .find_map(|path| { + os_release::get_bootc_image_from_file(path) + .log_err_default() + .filter(|s| !s.is_empty()) + }) + .ok_or_else(|| { + anyhow::anyhow!( + "No image provided. Specify an image or set BOOTC_IMAGE in os-release." + ) + })? + }; + + Ok(ReinstallOpts { + image, + composefs_backend: self.composefs_backend, + }) + } +} + #[context("run")] fn run() -> Result<()> { + let args = ReinstallOptsArgs::parse(); + // We historically supported an environment variable providing a config to override the image, so // keep supporting that. I'm considering deprecating that though. let opts = if let Some(config) = config::ReinstallConfig::load().context("loading config")? { @@ -43,8 +81,8 @@ fn run() -> Result<()> { composefs_backend: config.composefs_backend, } } else { - // Otherwise an image is required. - ReinstallOpts::parse() + // Otherwise an image is specified via the CLI or fallback to the os-release + args.build()? }; bootc_utils::initialize_tracing(); diff --git a/crates/system-reinstall-bootc/src/os_release.rs b/crates/system-reinstall-bootc/src/os_release.rs new file mode 100644 index 000000000..3bcad64a5 --- /dev/null +++ b/crates/system-reinstall-bootc/src/os_release.rs @@ -0,0 +1,108 @@ +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::Path; + +use anyhow::{Context, Result}; + +/// Searches for the BOOTC_IMAGE key in a given os-release file. +/// Follows standard os-release(5) quoting rules. +fn parse_bootc_image_from_reader(reader: R) -> Result> { + let mut last_found = None; + + for line in reader.lines() { + let line = line?; + let line = line.trim(); + + if line.is_empty() || line.starts_with('#') { + continue; + } + + let Some((key, value)) = line.split_once('=') else { + continue; + }; + + if key.trim() == "BOOTC_IMAGE" { + if let Some(unquoted) = shlex::split(value).and_then(|mut values| values.pop()) { + last_found = Some(unquoted); + } + } + } + + Ok(last_found) +} + +/// Reads the provided os-release file and returns the BOOTC_IMAGE value if found. +pub(crate) fn get_bootc_image_from_file>(path: P) -> Result> { + let file = File::open(path.as_ref()).with_context(|| format!("Opening {:?}", path.as_ref()))?; + let reader = BufReader::new(file); + parse_bootc_image_from_reader(reader) +} + +#[cfg(test)] +mod tests { + use super::*; + use indoc::indoc; + use std::io::Cursor; + + fn parse_str(content: &str) -> Option { + let reader = Cursor::new(content); + parse_bootc_image_from_reader(reader).unwrap() + } + + #[test] + fn test_parse_os_release_standard() { + let content = indoc! { " + NAME=Fedora + BOOTC_IMAGE=quay.io/example/image:latest + VERSION=39 + " }; + assert_eq!(parse_str(content).unwrap(), "quay.io/example/image:latest"); + } + + #[test] + fn test_parse_os_release_double_quotes() { + let content = "BOOTC_IMAGE=\"quay.io/example/image:latest\""; + assert_eq!(parse_str(content).unwrap(), "quay.io/example/image:latest"); + } + + #[test] + fn test_parse_os_release_single_quotes() { + let content = "BOOTC_IMAGE='quay.io/example/image:latest'"; + assert_eq!(parse_str(content).unwrap(), "quay.io/example/image:latest"); + } + + #[test] + fn test_parse_os_release_escaped() { + let content = indoc! { r#" + BOOTC_IMAGE="quay.io/img/with\"quote" + "# }; + assert_eq!(parse_str(content).unwrap(), "quay.io/img/with\"quote"); + } + + #[test] + fn test_parse_os_release_missing() { + let content = indoc! { " + NAME=Fedora + VERSION=39 + " }; + assert!(parse_str(content).is_none()); + } + + #[test] + fn test_parse_os_release_comments_and_spaces() { + let content = indoc! { " + # comment + BOOTC_IMAGE= \"quay.io/img\" + " }; + assert_eq!(parse_str(content).unwrap(), "quay.io/img"); + } + + #[test] + fn test_parse_os_release_last_wins() { + let content = indoc! { " + BOOTC_IMAGE=quay.io/old/image + BOOTC_IMAGE=quay.io/new/image + " }; + assert_eq!(parse_str(content).unwrap(), "quay.io/new/image"); + } +} diff --git a/hack/packages.txt b/hack/packages.txt index 8a1f51b51..7efc03f53 100644 --- a/hack/packages.txt +++ b/hack/packages.txt @@ -1,6 +1,5 @@ # Needed by tmt rsync -cloud-init /usr/bin/flock /usr/bin/awk # Needed by tmt avc check diff --git a/hack/provision-derived.sh b/hack/provision-derived.sh index f4b4ddb3b..5df085f66 100755 --- a/hack/provision-derived.sh +++ b/hack/provision-derived.sh @@ -55,7 +55,7 @@ cat <> /usr/lib/bootc/kargs.d/20-console.toml kargs = ["console=ttyS0,115200n8"] KARGEOF if test $cloudinit = 1; then - dnf -y install cloud-init + dnf -y install --allowerasing cloud-init ln -s ../cloud-init.target /usr/lib/systemd/system/default.target.wants # Allow root SSH login for testing with bcvk/tmt mkdir -p /etc/cloud/cloud.cfg.d diff --git a/hack/provision-packit.sh b/hack/provision-packit.sh index 9ec9a144a..6688947ac 100755 --- a/hack/provision-packit.sh +++ b/hack/provision-packit.sh @@ -93,4 +93,8 @@ podman pull -q --retry 5 --retry-delay 5s quay.io/curl/curl:latest quay.io/curl/ # Run system-reinstall-bootc # TODO make it more scriptable instead of expect + send -./system-reinstall-bootc.exp +if grep -q "^BOOTC_IMAGE=" /etc/os-release /usr/lib/os-release 2>/dev/null; then + ./system-reinstall-bootc.exp +else + ./system-reinstall-bootc.exp localhost/bootc +fi diff --git a/hack/system-reinstall-bootc.exp b/hack/system-reinstall-bootc.exp index 54effbd74..832c74e94 100755 --- a/hack/system-reinstall-bootc.exp +++ b/hack/system-reinstall-bootc.exp @@ -3,7 +3,13 @@ # Set a timeout set timeout 600 -spawn system-reinstall-bootc localhost/bootc +set image [lindex $argv 0] + +if { $image != "" } { + spawn system-reinstall-bootc $image +} else { + spawn system-reinstall-bootc +} expect { "Then you can login as * using those keys. \\\[Y/n\\\]" { diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 8a4850974..f79aa8210 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -8,7 +8,7 @@ prepare: # Run on package mode VM running on Packit and Gating # order 9x means run it at the last job of prepare - how: install - order: 97 + order: 96 package: - podman - skopeo @@ -25,12 +25,18 @@ prepare: - e2fsprogs when: running_env != image_mode - how: shell - order: 98 + order: 97 script: - mkdir -p bootc && cp /var/share/test-artifacts/*.src.rpm bootc - cd bootc && rpm2cpio *.src.rpm | cpio -idmv && rm -f *-vendor.tar.zstd && zstd -d *.tar.zstd && tar -xvf *.tar -C . --strip-components=1 && ls -al - pwd && ls -al && cd bootc/hack && ./provision-packit.sh when: running_env != image_mode + - how: shell + order: 98 + script: + - echo 'BOOTC_IMAGE=localhost/bootc' | tee -a /usr/lib/os-release + - pwd && ls -al && cd bootc/hack && ./provision-packit.sh + when: running_env != image_mode # tmt-reboot and reboot do not work in this case # reboot in ansible is the only way to reboot in tmt prepare - how: ansible