@@ -29,6 +29,8 @@ use indoc::indoc;
2929use ostree:: gio;
3030use ostree_container:: store:: PrepareResult ;
3131use ostree_ext:: container as ostree_container;
32+ use ostree_ext:: container:: Transport ;
33+ use ostree_ext:: oci_spec;
3234
3335use ostree_ext:: keyfileext:: KeyFileExt ;
3436use ostree_ext:: ostree;
@@ -123,6 +125,17 @@ pub(crate) struct UpgradeOpts {
123125 #[ clap( long, conflicts_with_all = [ "check" , "download_only" ] ) ]
124126 pub ( crate ) from_downloaded : bool ,
125127
128+ /// Upgrade to a different tag of the currently booted image.
129+ ///
130+ /// This derives the target image by replacing the tag portion of the current
131+ /// booted image reference. Only works when booted from a registry image.
132+ #[ clap( long) ]
133+ pub ( crate ) tag : Option < String > ,
134+
135+ /// List available tags for the currently booted image repository.
136+ #[ clap( long, conflicts_with_all = [ "apply" , "download_only" , "from_downloaded" ] ) ]
137+ pub ( crate ) list_tags : bool ,
138+
126139 #[ clap( flatten) ]
127140 pub ( crate ) progress : ProgressOptions ,
128141}
@@ -1047,7 +1060,26 @@ async fn upgrade(
10471060 let repo = & booted_ostree. repo ( ) ;
10481061
10491062 let host = crate :: status:: get_status ( booted_ostree) ?. 1 ;
1050- let imgref = host. spec . image . as_ref ( ) ;
1063+
1064+ // Handle --list-tags: show available tags and exit
1065+ if opts. list_tags {
1066+ let current_image = host. spec . image
1067+ . as_ref ( )
1068+ . ok_or_else ( || anyhow:: anyhow!( "No image source specified in booted deployment" ) ) ?;
1069+ return list_tags_for_current_image ( current_image) . await ;
1070+ }
1071+
1072+ // Handle --tag: derive target from current image + new tag
1073+ let derived_image = if let Some ( ref tag) = opts. tag {
1074+ let current_image = host. spec . image
1075+ . as_ref ( )
1076+ . ok_or_else ( || anyhow:: anyhow!( "No image source specified in booted deployment" ) ) ?;
1077+ Some ( derive_image_with_tag ( current_image, tag) ?)
1078+ } else {
1079+ None
1080+ } ;
1081+
1082+ let imgref = derived_image. as_ref ( ) . or_else ( || host. spec . image . as_ref ( ) ) ;
10511083 let prog: ProgressWriter = opts. progress . try_into ( ) ?;
10521084
10531085 // If there's no specified image, let's be nice and check if the booted system is using rpm-ostree
@@ -1220,6 +1252,70 @@ async fn upgrade(
12201252 Ok ( ( ) )
12211253}
12221254
1255+ /// Derive a new image reference by replacing the tag of the current image
1256+ fn derive_image_with_tag ( current : & ImageReference , new_tag : & str ) -> Result < ImageReference > {
1257+ // Only works for registry transport
1258+ if current. transport ( ) ? != Transport :: Registry {
1259+ anyhow:: bail!(
1260+ "The --tag option only works with registry images, current transport is '{}'" ,
1261+ current. transport
1262+ ) ;
1263+ }
1264+
1265+ // Parse the current image reference
1266+ let reference: oci_spec:: distribution:: Reference = current. image . parse ( ) ?;
1267+
1268+ // Build new image reference with the new tag (stripping any digest)
1269+ let registry = reference. registry ( ) ;
1270+ let repository = reference. repository ( ) ;
1271+ let new_image = format ! ( "{}/{}:{}" , registry, repository, new_tag) ;
1272+
1273+ Ok ( ImageReference {
1274+ image : new_image,
1275+ transport : current. transport . clone ( ) ,
1276+ signature : current. signature . clone ( ) ,
1277+ } )
1278+ }
1279+
1280+ /// List available tags for the current image repository
1281+ async fn list_tags_for_current_image ( current : & ImageReference ) -> Result < ( ) > {
1282+ // Only works for registry transport
1283+ if current. transport ( ) ? != Transport :: Registry {
1284+ anyhow:: bail!(
1285+ "The --list-tags option only works with registry images, current transport is '{}'" ,
1286+ current. transport
1287+ ) ;
1288+ }
1289+
1290+ // Parse the image to extract repository (without tag/digest)
1291+ let reference: oci_spec:: distribution:: Reference = current. image . parse ( ) ?;
1292+
1293+ // Construct repository name: registry + repository
1294+ let registry = reference. registry ( ) ;
1295+ let repository = reference. repository ( ) ;
1296+ let repo_name = format ! ( "{}/{}" , registry, repository) ;
1297+
1298+ let repo = format ! ( "docker://{}" , repo_name) ;
1299+
1300+ // Use skopeo to list tags
1301+ let output = tokio:: process:: Command :: new ( "skopeo" )
1302+ . arg ( "list-tags" )
1303+ . arg ( & repo)
1304+ . output ( )
1305+ . await
1306+ . context ( "Failed to execute skopeo" ) ?;
1307+
1308+ if !output. status . success ( ) {
1309+ let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
1310+ anyhow:: bail!( "Failed to list tags: {}" , stderr) ;
1311+ }
1312+
1313+ let stdout = String :: from_utf8_lossy ( & output. stdout ) ;
1314+ println ! ( "{}" , stdout) ;
1315+
1316+ Ok ( ( ) )
1317+ }
1318+
12231319pub ( crate ) fn imgref_for_switch ( opts : & SwitchOpts ) -> Result < ImageReference > {
12241320 let transport = ostree_container:: Transport :: try_from ( opts. transport . as_str ( ) ) ?;
12251321 let imgref = ostree_container:: ImageReference {
@@ -2203,6 +2299,78 @@ mod tests {
22032299 assert_eq ! ( args. as_slice( ) , [ "container" , "image" , "pull" ] ) ;
22042300 }
22052301
2302+ #[ test]
2303+ fn test_parse_upgrade_options ( ) {
2304+ // Test upgrade with --tag
2305+ let o = Opt :: try_parse_from ( [ "bootc" , "upgrade" , "--tag" , "v1.1" ] ) . unwrap ( ) ;
2306+ match o {
2307+ Opt :: Upgrade ( opts) => {
2308+ assert_eq ! ( opts. tag, Some ( "v1.1" . to_string( ) ) ) ;
2309+ assert ! ( !opts. list_tags) ;
2310+ }
2311+ _ => panic ! ( "Expected Upgrade variant" ) ,
2312+ }
2313+
2314+ // Test upgrade with --list-tags
2315+ let o = Opt :: try_parse_from ( [ "bootc" , "upgrade" , "--list-tags" ] ) . unwrap ( ) ;
2316+ match o {
2317+ Opt :: Upgrade ( opts) => {
2318+ assert ! ( opts. tag. is_none( ) ) ;
2319+ assert ! ( opts. list_tags) ;
2320+ }
2321+ _ => panic ! ( "Expected Upgrade variant" ) ,
2322+ }
2323+
2324+ // Test that --list-tags and --apply are mutually exclusive
2325+ let result = Opt :: try_parse_from ( [ "bootc" , "upgrade" , "--list-tags" , "--apply" ] ) ;
2326+ assert ! ( result. is_err( ) ) ;
2327+
2328+ // Test that --list-tags and --download-only are mutually exclusive
2329+ let result = Opt :: try_parse_from ( [ "bootc" , "upgrade" , "--list-tags" , "--download-only" ] ) ;
2330+ assert ! ( result. is_err( ) ) ;
2331+
2332+ // Test that --tag works with --check (should compose naturally)
2333+ let o = Opt :: try_parse_from ( [ "bootc" , "upgrade" , "--tag" , "v1.1" , "--check" ] ) . unwrap ( ) ;
2334+ match o {
2335+ Opt :: Upgrade ( opts) => {
2336+ assert_eq ! ( opts. tag, Some ( "v1.1" . to_string( ) ) ) ;
2337+ assert ! ( opts. check) ;
2338+ }
2339+ _ => panic ! ( "Expected Upgrade variant" ) ,
2340+ }
2341+ }
2342+
2343+ #[ test]
2344+ fn test_derive_image_with_tag ( ) {
2345+ // Test basic tag replacement
2346+ let current = ImageReference {
2347+ image : "quay.io/example/myapp:v1.0" . to_string ( ) ,
2348+ transport : "registry" . to_string ( ) ,
2349+ signature : None ,
2350+ } ;
2351+ let result = derive_image_with_tag ( & current, "v1.1" ) . unwrap ( ) ;
2352+ assert_eq ! ( result. image, "quay.io/example/myapp:v1.1" ) ;
2353+ assert_eq ! ( result. transport, "registry" ) ;
2354+
2355+ // Test tag replacement with digest (digest should be stripped)
2356+ let current_with_digest = ImageReference {
2357+ image : "quay.io/example/myapp:v1.0@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" . to_string ( ) ,
2358+ transport : "registry" . to_string ( ) ,
2359+ signature : None ,
2360+ } ;
2361+ let result = derive_image_with_tag ( & current_with_digest, "v2.0" ) . unwrap ( ) ;
2362+ assert_eq ! ( result. image, "quay.io/example/myapp:v2.0" ) ;
2363+
2364+ // Test that non-registry transport fails
2365+ let non_registry = ImageReference {
2366+ image : "/path/to/oci" . to_string ( ) ,
2367+ transport : "oci" . to_string ( ) ,
2368+ signature : None ,
2369+ } ;
2370+ let result = derive_image_with_tag ( & non_registry, "v1.1" ) ;
2371+ assert ! ( result. is_err( ) ) ;
2372+ }
2373+
22062374 #[ test]
22072375 fn test_generate_completion_scripts_contain_commands ( ) {
22082376 use clap_complete:: aot:: { Shell , generate} ;
0 commit comments