Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 66 additions & 11 deletions crates/system-reinstall-bootc/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -24,17 +27,53 @@ 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<String>,
// Note if we ever add any other options here,
#[arg(long)]
pub(crate) composefs_backend: bool,
}

impl ReinstallOptsArgs {
pub(crate) fn build(self) -> Result<ReinstallOpts> {
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)
.ok()
.flatten()
.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")? {
Expand All @@ -43,8 +82,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();
Expand Down Expand Up @@ -73,20 +112,36 @@ fn run() -> Result<()> {
let has_clean = podman::bootc_has_clean(&opts.image)?;
spinner.finish_and_clear();

let ssh_key_file = tempfile::NamedTempFile::new()?;
let ssh_key_file_path = ssh_key_file
.path()
.to_str()
.ok_or_else(|| anyhow::anyhow!("unable to create authorized_key temp file"))?;
let mut _ssh_key_tempfile = None;
let mut ssh_key_file_path = None;

if podman::image_has_cloud_init(&opts.image)? {
let host_root_keys = std::path::Path::new("/root/.ssh/authorized_keys");
if host_root_keys.exists() {
println!("Detected cloud-init and host keys. Inheriting keys automatically.");
ssh_key_file_path = Some(host_root_keys.to_string_lossy().into_owned());
} else {
println!("Detected cloud-init. Proceeding without host key inheritance.");
}
} else {
let file = tempfile::NamedTempFile::new()?;
let file_path = file
.path()
.to_str()
.ok_or_else(|| anyhow::anyhow!("unable to create authorized_key temp file"))?;

tracing::trace!("ssh_key_file_path: {}", file_path);

tracing::trace!("ssh_key_file_path: {}", ssh_key_file_path);
prompt::get_ssh_keys(file_path)?;

prompt::get_ssh_keys(ssh_key_file_path)?;
ssh_key_file_path = Some(file_path.to_string());
_ssh_key_tempfile = Some(file);
}

prompt::mount_warning()?;

let mut reinstall_podman_command =
podman::reinstall_command(&opts, ssh_key_file_path, has_clean)?;
podman::reinstall_command(&opts, ssh_key_file_path.as_deref(), has_clean)?;

println!();
println!("Going to run command:");
Expand Down
118 changes: 118 additions & 0 deletions crates/system-reinstall-bootc/src/os_release.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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<R: BufRead>(reader: R) -> Result<Option<String>> {
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;
}

if let Some((key, value)) = line.split_once('=') {
if key.trim() == "BOOTC_IMAGE" {
let value = value.trim();

if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
let unquoted = &value[1..value.len() - 1];
let processed = unquoted
.replace(r#"\""#, "\"")
.replace(r#"\\"#, "\\")
.replace(r#"\$"#, "$")
.replace(r#"\`"#, "`");
last_found = Some(processed);
} else if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
last_found = Some(value[1..value.len() - 1].to_string());
} else {
last_found = Some(value.to_string());
}
}
}
}

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<P: AsRef<Path>>(path: P) -> Result<Option<String>> {
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<String> {
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");
}
}
31 changes: 26 additions & 5 deletions crates/system-reinstall-bootc/src/podman.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,29 @@ pub(crate) fn bootc_has_clean(image: &str) -> Result<bool> {
Ok(stdout_str.contains("--cleanup"))
}

#[context("image_has_cloud_init")]
pub(crate) fn image_has_cloud_init(image: &str) -> Result<bool> {
let result = Command::new("podman")
.args([
"run",
"--rm",
"--entrypoint",
"sh",
image,
"-c",
"command -v cloud-init",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()?;

Ok(result.success())
}

#[context("reinstall_command")]
pub(crate) fn reinstall_command(
opts: &ReinstallOpts,
ssh_key_file: &str,
ssh_key_file: Option<&str>,
has_clean: bool,
) -> Result<Command> {
let mut podman_command_and_args = [
Expand Down Expand Up @@ -88,11 +107,13 @@ pub(crate) fn reinstall_command(
bootc_command_and_args.push("--cleanup".to_string());
}

podman_command_and_args.push("-v".to_string());
podman_command_and_args.push(format!("{ssh_key_file}:{ROOT_KEY_MOUNT_POINT}"));
if let Some(ssh_key_file) = ssh_key_file {
podman_command_and_args.push("-v".to_string());
podman_command_and_args.push(format!("{ssh_key_file}:{ROOT_KEY_MOUNT_POINT}"));

bootc_command_and_args.push("--root-ssh-authorized-keys".to_string());
bootc_command_and_args.push(ROOT_KEY_MOUNT_POINT.to_string());
bootc_command_and_args.push("--root-ssh-authorized-keys".to_string());
bootc_command_and_args.push(ROOT_KEY_MOUNT_POINT.to_string());
}

let all_args = [
podman_command_and_args,
Expand Down
2 changes: 1 addition & 1 deletion hack/provision-derived.sh
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ cat <<KARGEOF >> /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
Expand Down
6 changes: 5 additions & 1 deletion hack/provision-packit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 7 additions & 1 deletion hack/system-reinstall-bootc.exp
Original file line number Diff line number Diff line change
Expand Up @@ -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\\\]" {
Expand Down
10 changes: 8 additions & 2 deletions tmt/plans/integration.fmf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Comment on lines +28 to +39
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These two shell steps are redundant and inefficient. Both steps perform the same setup and run provision-packit.sh, which includes a time-consuming podman build operation. Since provision-packit.sh is already designed to handle both the explicit argument and the os-release fallback, you should combine these into a single step to avoid rebuilding the image and repeating the setup in CI.

    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
      - echo 'BOOTC_IMAGE=localhost/bootc' | tee -a /usr/lib/os-release
      - ./provision-packit.sh
    when: running_env != image_mode

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought it was easier to just build the image twice than try to do a cleanup step for testing the BOOTC_IMAGE path on reinstall.

# tmt-reboot and reboot do not work in this case
# reboot in ansible is the only way to reboot in tmt prepare
- how: ansible
Expand Down
Loading