diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 6766671f1..b02791af1 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -123,6 +123,13 @@ pub(crate) struct UpgradeOpts { #[clap(long, conflicts_with_all = ["check", "download_only"])] pub(crate) from_downloaded: bool, + /// Upgrade to a different tag of the currently booted image. + /// + /// This derives the target image by replacing the tag portion of the current + /// booted image reference. + #[clap(long)] + pub(crate) tag: Option, + #[clap(flatten)] pub(crate) progress: ProgressOptions, } @@ -1047,7 +1054,19 @@ async fn upgrade( let repo = &booted_ostree.repo(); let host = crate::status::get_status(booted_ostree)?.1; - let imgref = host.spec.image.as_ref(); + let current_image = host.spec.image.as_ref(); + + // Handle --tag: derive target from current image + new tag + let derived_image = if let Some(ref tag) = opts.tag { + let image = current_image.ok_or_else(|| { + anyhow::anyhow!("--tag requires a booted image with a specified source") + })?; + Some(image.with_tag(tag)?) + } else { + None + }; + + let imgref = derived_image.as_ref().or(current_image); let prog: ProgressWriter = opts.progress.try_into()?; // If there's no specified image, let's be nice and check if the booted system is using rpm-ostree @@ -1063,7 +1082,9 @@ async fn upgrade( } } - let spec = RequiredHostSpec::from_spec(&host.spec)?; + let imgref = imgref.ok_or_else(|| anyhow::anyhow!("No image source specified"))?; + // Use the derived image reference (if --tag was specified) instead of the spec's image + let spec = RequiredHostSpec { image: imgref }; let booted_image = host .status .booted @@ -1071,7 +1092,6 @@ async fn upgrade( .map(|b| b.query_image(repo)) .transpose()? .flatten(); - let imgref = imgref.ok_or_else(|| anyhow::anyhow!("No image source specified"))?; // Find the currently queued digest, if any before we pull let staged = host.status.staged.as_ref(); let staged_image = staged.as_ref().and_then(|s| s.image.as_ref()); @@ -1099,16 +1119,17 @@ async fn upgrade( } if opts.check { - let imgref = imgref.clone().into(); + let ostree_imgref = imgref.clone().into(); let mut imp = - crate::deploy::new_importer(repo, &imgref, Some(&booted_ostree.deployment)).await?; + crate::deploy::new_importer(repo, &ostree_imgref, Some(&booted_ostree.deployment)) + .await?; match imp.prepare().await? { PrepareResult::AlreadyPresent(_) => { - println!("No changes in: {imgref:#}"); + println!("No changes in: {ostree_imgref:#}"); } PrepareResult::Ready(r) => { crate::deploy::check_bootc_label(&r.config); - println!("Update available for: {imgref:#}"); + println!("Update available for: {ostree_imgref:#}"); if let Some(version) = r.version() { println!(" Version: {version}"); } @@ -1236,7 +1257,6 @@ async fn upgrade( Ok(()) } - pub(crate) fn imgref_for_switch(opts: &SwitchOpts) -> Result { let transport = ostree_container::Transport::try_from(opts.transport.as_str())?; let imgref = ostree_container::ImageReference { @@ -2245,6 +2265,82 @@ mod tests { assert_eq!(args.as_slice(), ["container", "image", "pull"]); } + #[test] + fn test_parse_upgrade_options() { + // Test upgrade with --tag + let o = Opt::try_parse_from(["bootc", "upgrade", "--tag", "v1.1"]).unwrap(); + match o { + Opt::Upgrade(opts) => { + assert_eq!(opts.tag, Some("v1.1".to_string())); + } + _ => panic!("Expected Upgrade variant"), + } + + // Test that --tag works with --check (should compose naturally) + let o = Opt::try_parse_from(["bootc", "upgrade", "--tag", "v1.1", "--check"]).unwrap(); + match o { + Opt::Upgrade(opts) => { + assert_eq!(opts.tag, Some("v1.1".to_string())); + assert!(opts.check); + } + _ => panic!("Expected Upgrade variant"), + } + } + + #[test] + fn test_image_reference_with_tag() { + // Test basic tag replacement for registry transport + let current = ImageReference { + image: "quay.io/example/myapp:v1.0".to_string(), + transport: "registry".to_string(), + signature: None, + }; + let result = current.with_tag("v1.1").unwrap(); + assert_eq!(result.image, "quay.io/example/myapp:v1.1"); + assert_eq!(result.transport, "registry"); + + // Test tag replacement with digest (digest should be stripped for registry) + let current_with_digest = ImageReference { + image: "quay.io/example/myapp:v1.0@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890".to_string(), + transport: "registry".to_string(), + signature: None, + }; + let result = current_with_digest.with_tag("v2.0").unwrap(); + assert_eq!(result.image, "quay.io/example/myapp:v2.0"); + + // Test that non-registry transport works (containers-storage) + let containers_storage = ImageReference { + image: "localhost/myapp:v1.0".to_string(), + transport: "containers-storage".to_string(), + signature: None, + }; + let result = containers_storage.with_tag("v1.1").unwrap(); + assert_eq!(result.image, "localhost/myapp:v1.1"); + assert_eq!(result.transport, "containers-storage"); + + // Test digest stripping for non-registry transport + let containers_storage_with_digest = ImageReference { + image: + "localhost/myapp:v1.0@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + .to_string(), + transport: "containers-storage".to_string(), + signature: None, + }; + let result = containers_storage_with_digest.with_tag("v2.0").unwrap(); + assert_eq!(result.image, "localhost/myapp:v2.0"); + assert_eq!(result.transport, "containers-storage"); + + // Test image without tag (edge case) + let no_tag = ImageReference { + image: "localhost/myapp".to_string(), + transport: "containers-storage".to_string(), + signature: None, + }; + let result = no_tag.with_tag("v1.0").unwrap(); + assert_eq!(result.image, "localhost/myapp:v1.0"); + assert_eq!(result.transport, "containers-storage"); + } + #[test] fn test_generate_completion_scripts_contain_commands() { use clap_complete::aot::{Shell, generate}; diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index bf920413f..15428d451 100644 --- a/crates/lib/src/spec.rs +++ b/crates/lib/src/spec.rs @@ -151,6 +151,42 @@ impl ImageReference { Ok(format!("{}:{}", self.transport, self.image)) } } + + /// Derive a new image reference by replacing the tag. + /// + /// For transports with parseable image references (registry, containers-storage), + /// uses the OCI Reference API to properly handle tag replacement. + /// For other transports (oci, etc.), falls back to string manipulation. + pub fn with_tag(&self, new_tag: &str) -> Result { + // Try to parse as an OCI Reference (works for registry and containers-storage) + let new_image = if let Ok(reference) = self.image.parse::() { + // Use the proper OCI API to replace the tag + let new_ref = Reference::with_tag( + reference.registry().to_string(), + reference.repository().to_string(), + new_tag.to_string(), + ); + new_ref.to_string() + } else { + // For other transports like oci: with filesystem paths, + // strip any digest first, then replace tag via string manipulation + let image_without_digest = self.image.split('@').next().unwrap_or(&self.image); + + // Split on last ':' to separate image:tag + let image_part = image_without_digest + .rsplit_once(':') + .map(|(base, _tag)| base) + .unwrap_or(image_without_digest); + + format!("{}:{}", image_part, new_tag) + }; + + Ok(ImageReference { + image: new_image, + transport: self.transport.clone(), + signature: self.signature.clone(), + }) + } } /// The status of the booted image diff --git a/docs/src/man/bootc-upgrade.8.md b/docs/src/man/bootc-upgrade.8.md index df5f0297e..b1b3f3b69 100644 --- a/docs/src/man/bootc-upgrade.8.md +++ b/docs/src/man/bootc-upgrade.8.md @@ -69,6 +69,10 @@ Soft reboot allows faster system restart by avoiding full hardware reboot when p Apply a staged deployment that was previously downloaded with --download-only +**--tag**=*TAG* + + Upgrade to a different tag of the currently booted image + # EXAMPLES @@ -85,6 +89,18 @@ Upgrade with soft reboot if possible: bootc upgrade --apply --soft-reboot=auto +Upgrade to a different tag: + + bootc upgrade --tag v1.2 + +Check if a specific tag has updates before applying: + + bootc upgrade --tag prod --check + +Upgrade to a tag and immediately apply: + + bootc upgrade --tag v2.0 --apply + # SEE ALSO **bootc**(8), **bootc-switch**(8), **bootc-status**(8), **bootc-rollback**(8) diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index ff0f69a91..46c58eb55 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -230,4 +230,11 @@ execute: how: fmf test: - /tmt/tests/tests/test-38-install-bootloader-none + +/plan-39-upgrade-tag: + summary: Test bootc upgrade --tag functionality with containers-storage + discover: + how: fmf + test: + - /tmt/tests/tests/test-39-upgrade-tag # END GENERATED PLANS diff --git a/tmt/tests/booted/test-upgrade-tag.nu b/tmt/tests/booted/test-upgrade-tag.nu new file mode 100644 index 000000000..c3b165143 --- /dev/null +++ b/tmt/tests/booted/test-upgrade-tag.nu @@ -0,0 +1,104 @@ +# number: 39 +# tmt: +# summary: Test bootc upgrade --tag functionality with containers-storage +# duration: 30m +# +# This test verifies: +# - bootc upgrade --tag switches to different tags of the same image +# - bootc upgrade --check --tag verifies tag availability +# Test using containers-storage transport to avoid registry dependency +use std assert +use tap.nu + +# This code runs on *each* boot +bootc status +let st = bootc status --json | from json +let booted = $st.status.booted.image + +# Run on the first boot +def initial_build [] { + tap begin "upgrade --tag test" + + let td = mktemp -d + cd $td + + # Copy bootc image to local storage + bootc image copy-to-storage + + # Build v1 image + "FROM localhost/bootc +RUN echo v1 content > /usr/share/bootc-tag-test.txt +" | save Dockerfile + podman build -t localhost/bootc-tag-test:v1 . + + # Verify v1 content + let v = podman run --rm localhost/bootc-tag-test:v1 cat /usr/share/bootc-tag-test.txt | str trim + assert equal $v "v1 content" + + # Switch to v1 + bootc switch --transport containers-storage localhost/bootc-tag-test:v1 + + # Build v2 image (different content) - use --force to overwrite Dockerfile + "FROM localhost/bootc +RUN echo v2 content > /usr/share/bootc-tag-test.txt +" | save --force Dockerfile + podman build -t localhost/bootc-tag-test:v2 . + + # Verify v2 content + let v = podman run --rm localhost/bootc-tag-test:v2 cat /usr/share/bootc-tag-test.txt | str trim + assert equal $v "v2 content" + + tmt-reboot +} + +# Second boot: verify we're on v1, then upgrade to v2 using --tag +def second_boot [] { + print "verifying second boot (v1)" + + # Should be on v1 + assert equal $booted.image.transport containers-storage + assert equal $booted.image.image "localhost/bootc-tag-test:v1" + + # Verify v1 content + let t = open /usr/share/bootc-tag-test.txt | str trim + assert equal $t "v1 content" + + # Test upgrade --check --tag v2 + let check_output = bootc upgrade --check --tag v2 + print $"Check output: ($check_output)" + + # Now upgrade to v2 using --tag + bootc upgrade --tag v2 + + # Verify we staged an update + let st = bootc status --json | from json + assert ($st.status.staged != null) + let staged = $st.status.staged.image + assert equal $staged.image "localhost/bootc-tag-test:v2" + + tmt-reboot +} + +# Third boot: verify we're on v2 +def third_boot [] { + print "verifying third boot (v2)" + + # Should be on v2 now + assert equal $booted.image.transport containers-storage + assert equal $booted.image.image "localhost/bootc-tag-test:v2" + + # Verify v2 content + let t = open /usr/share/bootc-tag-test.txt | str trim + assert equal $t "v2 content" + + tap ok +} + +def main [] { + match $env.TMT_REBOOT_COUNT? { + null | "0" => initial_build, + "1" => second_boot, + "2" => third_boot, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index da3b8f26b..b26b3ad9c 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -131,3 +131,8 @@ summary: Test bootc install with --bootloader=none duration: 30m test: nu booted/test-install-bootloader-none.nu + +/test-39-upgrade-tag: + summary: Test bootc upgrade --tag functionality with containers-storage + duration: 30m + test: nu booted/test-upgrade-tag.nu