Skip to content

Commit 7d0cfe6

Browse files
gursewak1997cgwalters
authored andcommitted
feat(upgrade): add --tag option for tag-based upgrades
Implements bootc upgrade --tag <TAG> to upgrade to a different tag of the current image without typing the full registry path. Implementation: - Added ImageReference::with_tag() method in spec.rs - Uses OCI Reference API for registry and containers-storage transports - Falls back to string manipulation for oci: and other complex transports - Properly strips digests when replacing tags - Works with all transport types Testing: - Unit tests cover registry, containers-storage, digest handling, and edge cases - Integration test (test-upgrade-tag.nu) uses containers-storage to verify full tag-switching workflow across reboots without registry dependency Documentation updated with --tag examples. Assisted-by: Claude (Sonnet 4.5) Signed-off-by: gursewak1997 <gursmangat@gmail.com>
1 parent 3baea80 commit 7d0cfe6

File tree

7 files changed

+293
-13
lines changed

7 files changed

+293
-13
lines changed

crates/lib/src/bootc_composefs/update.rs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,18 @@ pub(crate) async fn upgrade_composefs(
341341
.await
342342
.context("Getting composefs deployment status")?;
343343

344+
let current_image = host.spec.image.as_ref();
345+
346+
// Handle --tag: derive target from current image + new tag
347+
let derived_image = if let Some(ref tag) = opts.tag {
348+
let image = current_image.ok_or_else(|| {
349+
anyhow::anyhow!("--tag requires a booted image with a specified source")
350+
})?;
351+
Some(image.with_tag(tag)?)
352+
} else {
353+
None
354+
};
355+
344356
let do_upgrade_opts = DoUpgradeOpts {
345357
soft_reboot: opts.soft_reboot,
346358
apply: opts.apply,
@@ -391,11 +403,8 @@ pub(crate) async fn upgrade_composefs(
391403
.await;
392404
}
393405

394-
let mut booted_imgref = host
395-
.spec
396-
.image
397-
.as_ref()
398-
.ok_or_else(|| anyhow::anyhow!("No image source specified"))?;
406+
let imgref = derived_image.as_ref().or(current_image);
407+
let mut booted_imgref = imgref.ok_or_else(|| anyhow::anyhow!("No image source specified"))?;
399408

400409
let repo = &*composefs.repo;
401410

crates/lib/src/cli.rs

Lines changed: 104 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,13 @@ pub(crate) struct UpgradeOpts {
123123
#[clap(long, conflicts_with_all = ["check", "download_only"])]
124124
pub(crate) from_downloaded: bool,
125125

126+
/// Upgrade to a different tag of the currently booted image.
127+
///
128+
/// This derives the target image by replacing the tag portion of the current
129+
/// booted image reference.
130+
#[clap(long)]
131+
pub(crate) tag: Option<String>,
132+
126133
#[clap(flatten)]
127134
pub(crate) progress: ProgressOptions,
128135
}
@@ -1047,7 +1054,19 @@ async fn upgrade(
10471054
let repo = &booted_ostree.repo();
10481055

10491056
let host = crate::status::get_status(booted_ostree)?.1;
1050-
let imgref = host.spec.image.as_ref();
1057+
let current_image = host.spec.image.as_ref();
1058+
1059+
// Handle --tag: derive target from current image + new tag
1060+
let derived_image = if let Some(ref tag) = opts.tag {
1061+
let image = current_image.ok_or_else(|| {
1062+
anyhow::anyhow!("--tag requires a booted image with a specified source")
1063+
})?;
1064+
Some(image.with_tag(tag)?)
1065+
} else {
1066+
None
1067+
};
1068+
1069+
let imgref = derived_image.as_ref().or(current_image);
10511070
let prog: ProgressWriter = opts.progress.try_into()?;
10521071

10531072
// If there's no specified image, let's be nice and check if the booted system is using rpm-ostree
@@ -1063,15 +1082,16 @@ async fn upgrade(
10631082
}
10641083
}
10651084

1066-
let spec = RequiredHostSpec::from_spec(&host.spec)?;
1085+
let imgref = imgref.ok_or_else(|| anyhow::anyhow!("No image source specified"))?;
1086+
// Use the derived image reference (if --tag was specified) instead of the spec's image
1087+
let spec = RequiredHostSpec { image: imgref };
10671088
let booted_image = host
10681089
.status
10691090
.booted
10701091
.as_ref()
10711092
.map(|b| b.query_image(repo))
10721093
.transpose()?
10731094
.flatten();
1074-
let imgref = imgref.ok_or_else(|| anyhow::anyhow!("No image source specified"))?;
10751095
// Find the currently queued digest, if any before we pull
10761096
let staged = host.status.staged.as_ref();
10771097
let staged_image = staged.as_ref().and_then(|s| s.image.as_ref());
@@ -1099,16 +1119,17 @@ async fn upgrade(
10991119
}
11001120

11011121
if opts.check {
1102-
let imgref = imgref.clone().into();
1122+
let ostree_imgref = imgref.clone().into();
11031123
let mut imp =
1104-
crate::deploy::new_importer(repo, &imgref, Some(&booted_ostree.deployment)).await?;
1124+
crate::deploy::new_importer(repo, &ostree_imgref, Some(&booted_ostree.deployment))
1125+
.await?;
11051126
match imp.prepare().await? {
11061127
PrepareResult::AlreadyPresent(_) => {
1107-
println!("No changes in: {imgref:#}");
1128+
println!("No changes in: {ostree_imgref:#}");
11081129
}
11091130
PrepareResult::Ready(r) => {
11101131
crate::deploy::check_bootc_label(&r.config);
1111-
println!("Update available for: {imgref:#}");
1132+
println!("Update available for: {ostree_imgref:#}");
11121133
if let Some(version) = r.version() {
11131134
println!(" Version: {version}");
11141135
}
@@ -1236,7 +1257,6 @@ async fn upgrade(
12361257

12371258
Ok(())
12381259
}
1239-
12401260
pub(crate) fn imgref_for_switch(opts: &SwitchOpts) -> Result<ImageReference> {
12411261
let transport = ostree_container::Transport::try_from(opts.transport.as_str())?;
12421262
let imgref = ostree_container::ImageReference {
@@ -2245,6 +2265,82 @@ mod tests {
22452265
assert_eq!(args.as_slice(), ["container", "image", "pull"]);
22462266
}
22472267

2268+
#[test]
2269+
fn test_parse_upgrade_options() {
2270+
// Test upgrade with --tag
2271+
let o = Opt::try_parse_from(["bootc", "upgrade", "--tag", "v1.1"]).unwrap();
2272+
match o {
2273+
Opt::Upgrade(opts) => {
2274+
assert_eq!(opts.tag, Some("v1.1".to_string()));
2275+
}
2276+
_ => panic!("Expected Upgrade variant"),
2277+
}
2278+
2279+
// Test that --tag works with --check (should compose naturally)
2280+
let o = Opt::try_parse_from(["bootc", "upgrade", "--tag", "v1.1", "--check"]).unwrap();
2281+
match o {
2282+
Opt::Upgrade(opts) => {
2283+
assert_eq!(opts.tag, Some("v1.1".to_string()));
2284+
assert!(opts.check);
2285+
}
2286+
_ => panic!("Expected Upgrade variant"),
2287+
}
2288+
}
2289+
2290+
#[test]
2291+
fn test_image_reference_with_tag() {
2292+
// Test basic tag replacement for registry transport
2293+
let current = ImageReference {
2294+
image: "quay.io/example/myapp:v1.0".to_string(),
2295+
transport: "registry".to_string(),
2296+
signature: None,
2297+
};
2298+
let result = current.with_tag("v1.1").unwrap();
2299+
assert_eq!(result.image, "quay.io/example/myapp:v1.1");
2300+
assert_eq!(result.transport, "registry");
2301+
2302+
// Test tag replacement with digest (digest should be stripped for registry)
2303+
let current_with_digest = ImageReference {
2304+
image: "quay.io/example/myapp:v1.0@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890".to_string(),
2305+
transport: "registry".to_string(),
2306+
signature: None,
2307+
};
2308+
let result = current_with_digest.with_tag("v2.0").unwrap();
2309+
assert_eq!(result.image, "quay.io/example/myapp:v2.0");
2310+
2311+
// Test that non-registry transport works (containers-storage)
2312+
let containers_storage = ImageReference {
2313+
image: "localhost/myapp:v1.0".to_string(),
2314+
transport: "containers-storage".to_string(),
2315+
signature: None,
2316+
};
2317+
let result = containers_storage.with_tag("v1.1").unwrap();
2318+
assert_eq!(result.image, "localhost/myapp:v1.1");
2319+
assert_eq!(result.transport, "containers-storage");
2320+
2321+
// Test digest stripping for non-registry transport
2322+
let containers_storage_with_digest = ImageReference {
2323+
image:
2324+
"localhost/myapp:v1.0@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
2325+
.to_string(),
2326+
transport: "containers-storage".to_string(),
2327+
signature: None,
2328+
};
2329+
let result = containers_storage_with_digest.with_tag("v2.0").unwrap();
2330+
assert_eq!(result.image, "localhost/myapp:v2.0");
2331+
assert_eq!(result.transport, "containers-storage");
2332+
2333+
// Test image without tag (edge case)
2334+
let no_tag = ImageReference {
2335+
image: "localhost/myapp".to_string(),
2336+
transport: "containers-storage".to_string(),
2337+
signature: None,
2338+
};
2339+
let result = no_tag.with_tag("v1.0").unwrap();
2340+
assert_eq!(result.image, "localhost/myapp:v1.0");
2341+
assert_eq!(result.transport, "containers-storage");
2342+
}
2343+
22482344
#[test]
22492345
fn test_generate_completion_scripts_contain_commands() {
22502346
use clap_complete::aot::{Shell, generate};

crates/lib/src/spec.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,42 @@ impl ImageReference {
151151
Ok(format!("{}:{}", self.transport, self.image))
152152
}
153153
}
154+
155+
/// Derive a new image reference by replacing the tag.
156+
///
157+
/// For transports with parseable image references (registry, containers-storage),
158+
/// uses the OCI Reference API to properly handle tag replacement.
159+
/// For other transports (oci, etc.), falls back to string manipulation.
160+
pub fn with_tag(&self, new_tag: &str) -> Result<Self> {
161+
// Try to parse as an OCI Reference (works for registry and containers-storage)
162+
let new_image = if let Ok(reference) = self.image.parse::<Reference>() {
163+
// Use the proper OCI API to replace the tag
164+
let new_ref = Reference::with_tag(
165+
reference.registry().to_string(),
166+
reference.repository().to_string(),
167+
new_tag.to_string(),
168+
);
169+
new_ref.to_string()
170+
} else {
171+
// For other transports like oci: with filesystem paths,
172+
// strip any digest first, then replace tag via string manipulation
173+
let image_without_digest = self.image.split('@').next().unwrap_or(&self.image);
174+
175+
// Split on last ':' to separate image:tag
176+
let image_part = image_without_digest
177+
.rsplit_once(':')
178+
.map(|(base, _tag)| base)
179+
.unwrap_or(image_without_digest);
180+
181+
format!("{}:{}", image_part, new_tag)
182+
};
183+
184+
Ok(ImageReference {
185+
image: new_image,
186+
transport: self.transport.clone(),
187+
signature: self.signature.clone(),
188+
})
189+
}
154190
}
155191

156192
/// The status of the booted image

docs/src/man/bootc-upgrade.8.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ Soft reboot allows faster system restart by avoiding full hardware reboot when p
6969

7070
Apply a staged deployment that was previously downloaded with --download-only
7171

72+
**--tag**=*TAG*
73+
74+
Upgrade to a different tag of the currently booted image
75+
7276
<!-- END GENERATED OPTIONS -->
7377

7478
# EXAMPLES
@@ -85,6 +89,18 @@ Upgrade with soft reboot if possible:
8589

8690
bootc upgrade --apply --soft-reboot=auto
8791

92+
Upgrade to a different tag:
93+
94+
bootc upgrade --tag v1.2
95+
96+
Check if a specific tag has updates before applying:
97+
98+
bootc upgrade --tag prod --check
99+
100+
Upgrade to a tag and immediately apply:
101+
102+
bootc upgrade --tag v2.0 --apply
103+
88104
# SEE ALSO
89105

90106
**bootc**(8), **bootc-switch**(8), **bootc-status**(8), **bootc-rollback**(8)

tmt/plans/integration.fmf

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,4 +230,11 @@ execute:
230230
how: fmf
231231
test:
232232
- /tmt/tests/tests/test-38-install-bootloader-none
233+
234+
/plan-39-upgrade-tag:
235+
summary: Test bootc upgrade --tag functionality with containers-storage
236+
discover:
237+
how: fmf
238+
test:
239+
- /tmt/tests/tests/test-39-upgrade-tag
233240
# END GENERATED PLANS
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# number: 39
2+
# tmt:
3+
# summary: Test bootc upgrade --tag functionality with containers-storage
4+
# duration: 30m
5+
#
6+
# This test verifies:
7+
# - bootc upgrade --tag switches to different tags of the same image
8+
# - bootc upgrade --check --tag verifies tag availability
9+
# Test using containers-storage transport to avoid registry dependency
10+
use std assert
11+
use tap.nu
12+
13+
# This code runs on *each* boot
14+
bootc status
15+
let st = bootc status --json | from json
16+
let booted = $st.status.booted.image
17+
18+
# Run on the first boot
19+
def initial_build [] {
20+
tap begin "upgrade --tag test"
21+
22+
let td = mktemp -d
23+
cd $td
24+
25+
# Copy bootc image to local storage
26+
bootc image copy-to-storage
27+
28+
# Build v1 image
29+
"FROM localhost/bootc
30+
RUN echo v1 content > /usr/share/bootc-tag-test.txt
31+
" | save Dockerfile
32+
podman build -t localhost/bootc-tag-test:v1 .
33+
34+
# Verify v1 content
35+
let v = podman run --rm localhost/bootc-tag-test:v1 cat /usr/share/bootc-tag-test.txt | str trim
36+
assert equal $v "v1 content"
37+
38+
# Switch to v1
39+
bootc switch --transport containers-storage localhost/bootc-tag-test:v1
40+
41+
# Build v2 image (different content) - use --force to overwrite Dockerfile
42+
"FROM localhost/bootc
43+
RUN echo v2 content > /usr/share/bootc-tag-test.txt
44+
" | save --force Dockerfile
45+
podman build -t localhost/bootc-tag-test:v2 .
46+
47+
# Verify v2 content
48+
let v = podman run --rm localhost/bootc-tag-test:v2 cat /usr/share/bootc-tag-test.txt | str trim
49+
assert equal $v "v2 content"
50+
51+
tmt-reboot
52+
}
53+
54+
# Second boot: verify we're on v1, then upgrade to v2 using --tag
55+
def second_boot [] {
56+
print "verifying second boot (v1)"
57+
58+
# Should be on v1
59+
assert equal $booted.image.transport containers-storage
60+
assert equal $booted.image.image "localhost/bootc-tag-test:v1"
61+
62+
# Verify v1 content
63+
let t = open /usr/share/bootc-tag-test.txt | str trim
64+
assert equal $t "v1 content"
65+
66+
# Verify both v1 and v2 images still exist in podman after reboot
67+
let v1_exists = (podman images --format="{{.Repository}}:{{.Tag}}" | lines | any {|img| $img == "localhost/bootc-tag-test:v1"})
68+
let v2_exists = (podman images --format="{{.Repository}}:{{.Tag}}" | lines | any {|img| $img == "localhost/bootc-tag-test:v2"})
69+
print $"v1 exists: ($v1_exists), v2 exists: ($v2_exists)"
70+
assert $v1_exists "v1 image must exist in podman storage"
71+
assert $v2_exists "v2 image must exist in podman storage after reboot"
72+
73+
# Test upgrade --check --tag v2
74+
let check_output = bootc upgrade --check --tag v2
75+
print $"Check output: ($check_output)"
76+
77+
# Now upgrade to v2 using --tag
78+
bootc upgrade --tag v2
79+
80+
# Verify we staged an update
81+
let st = bootc status --json | from json
82+
assert ($st.status.staged != null)
83+
let staged = $st.status.staged.image
84+
assert equal $staged.image.image "localhost/bootc-tag-test:v2"
85+
86+
tmt-reboot
87+
}
88+
89+
# Third boot: verify we're on v2
90+
def third_boot [] {
91+
print "verifying third boot (v2)"
92+
93+
# Should be on v2 now
94+
assert equal $booted.image.transport containers-storage
95+
assert equal $booted.image.image "localhost/bootc-tag-test:v2"
96+
97+
# Verify v2 content
98+
let t = open /usr/share/bootc-tag-test.txt | str trim
99+
assert equal $t "v2 content"
100+
101+
tap ok
102+
}
103+
104+
def main [] {
105+
match $env.TMT_REBOOT_COUNT? {
106+
null | "0" => initial_build,
107+
"1" => second_boot,
108+
"2" => third_boot,
109+
$o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } },
110+
}
111+
}

0 commit comments

Comments
 (0)