@@ -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,27 @@ 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+ let current_image = host. spec . image . as_ref ( ) ;
1064+
1065+ // Handle --list-tags: show available tags and exit
1066+ if opts. list_tags {
1067+ let image = current_image. ok_or_else ( || {
1068+ anyhow:: anyhow!( "--list-tags requires a booted image with a specified source" )
1069+ } ) ?;
1070+ return list_tags_for_current_image ( image) . await ;
1071+ }
1072+
1073+ // Handle --tag: derive target from current image + new tag
1074+ let derived_image = if let Some ( ref tag) = opts. tag {
1075+ let image = current_image. ok_or_else ( || {
1076+ anyhow:: anyhow!( "--tag requires a booted image with a specified source" )
1077+ } ) ?;
1078+ Some ( derive_image_with_tag ( image, tag) ?)
1079+ } else {
1080+ None
1081+ } ;
1082+
1083+ let imgref = derived_image. as_ref ( ) . or ( current_image) ;
10511084 let prog: ProgressWriter = opts. progress . try_into ( ) ?;
10521085
10531086 // If there's no specified image, let's be nice and check if the booted system is using rpm-ostree
@@ -1220,6 +1253,70 @@ async fn upgrade(
12201253 Ok ( ( ) )
12211254}
12221255
1256+ /// Derive a new image reference by replacing the tag of the current image
1257+ fn derive_image_with_tag ( current : & ImageReference , new_tag : & str ) -> Result < ImageReference > {
1258+ // Only works for registry transport
1259+ if current. transport ( ) ? != Transport :: Registry {
1260+ anyhow:: bail!(
1261+ "The --tag option only works with registry images, current transport is '{}'" ,
1262+ current. transport
1263+ ) ;
1264+ }
1265+
1266+ // Parse the current image reference
1267+ let reference: oci_spec:: distribution:: Reference = current. image . parse ( ) ?;
1268+
1269+ // Build new image reference with the new tag (stripping any digest)
1270+ let registry = reference. registry ( ) ;
1271+ let repository = reference. repository ( ) ;
1272+ let new_image = format ! ( "{}/{}:{}" , registry, repository, new_tag) ;
1273+
1274+ Ok ( ImageReference {
1275+ image : new_image,
1276+ transport : current. transport . clone ( ) ,
1277+ signature : current. signature . clone ( ) ,
1278+ } )
1279+ }
1280+
1281+ /// List available tags for the current image repository
1282+ async fn list_tags_for_current_image ( current : & ImageReference ) -> Result < ( ) > {
1283+ // Only works for registry transport
1284+ if current. transport ( ) ? != Transport :: Registry {
1285+ anyhow:: bail!(
1286+ "The --list-tags option only works with registry images, current transport is '{}'" ,
1287+ current. transport
1288+ ) ;
1289+ }
1290+
1291+ // Parse the image to extract repository (without tag/digest)
1292+ let reference: oci_spec:: distribution:: Reference = current. image . parse ( ) ?;
1293+
1294+ // Construct repository name: registry + repository
1295+ let registry = reference. registry ( ) ;
1296+ let repository = reference. repository ( ) ;
1297+ let repo_name = format ! ( "{}/{}" , registry, repository) ;
1298+
1299+ let repo = format ! ( "docker://{}" , repo_name) ;
1300+
1301+ // Use skopeo to list tags
1302+ let output = tokio:: process:: Command :: new ( "skopeo" )
1303+ . arg ( "list-tags" )
1304+ . arg ( & repo)
1305+ . output ( )
1306+ . await
1307+ . context ( "Failed to execute skopeo" ) ?;
1308+
1309+ if !output. status . success ( ) {
1310+ let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
1311+ anyhow:: bail!( "Failed to list tags: {}" , stderr) ;
1312+ }
1313+
1314+ let stdout = String :: from_utf8_lossy ( & output. stdout ) ;
1315+ println ! ( "{}" , stdout) ;
1316+
1317+ Ok ( ( ) )
1318+ }
1319+
12231320pub ( crate ) fn imgref_for_switch ( opts : & SwitchOpts ) -> Result < ImageReference > {
12241321 let transport = ostree_container:: Transport :: try_from ( opts. transport . as_str ( ) ) ?;
12251322 let imgref = ostree_container:: ImageReference {
@@ -2203,6 +2300,78 @@ mod tests {
22032300 assert_eq ! ( args. as_slice( ) , [ "container" , "image" , "pull" ] ) ;
22042301 }
22052302
2303+ #[ test]
2304+ fn test_parse_upgrade_options ( ) {
2305+ // Test upgrade with --tag
2306+ let o = Opt :: try_parse_from ( [ "bootc" , "upgrade" , "--tag" , "v1.1" ] ) . unwrap ( ) ;
2307+ match o {
2308+ Opt :: Upgrade ( opts) => {
2309+ assert_eq ! ( opts. tag, Some ( "v1.1" . to_string( ) ) ) ;
2310+ assert ! ( !opts. list_tags) ;
2311+ }
2312+ _ => panic ! ( "Expected Upgrade variant" ) ,
2313+ }
2314+
2315+ // Test upgrade with --list-tags
2316+ let o = Opt :: try_parse_from ( [ "bootc" , "upgrade" , "--list-tags" ] ) . unwrap ( ) ;
2317+ match o {
2318+ Opt :: Upgrade ( opts) => {
2319+ assert ! ( opts. tag. is_none( ) ) ;
2320+ assert ! ( opts. list_tags) ;
2321+ }
2322+ _ => panic ! ( "Expected Upgrade variant" ) ,
2323+ }
2324+
2325+ // Test that --list-tags and --apply are mutually exclusive
2326+ let result = Opt :: try_parse_from ( [ "bootc" , "upgrade" , "--list-tags" , "--apply" ] ) ;
2327+ assert ! ( result. is_err( ) ) ;
2328+
2329+ // Test that --list-tags and --download-only are mutually exclusive
2330+ let result = Opt :: try_parse_from ( [ "bootc" , "upgrade" , "--list-tags" , "--download-only" ] ) ;
2331+ assert ! ( result. is_err( ) ) ;
2332+
2333+ // Test that --tag works with --check (should compose naturally)
2334+ let o = Opt :: try_parse_from ( [ "bootc" , "upgrade" , "--tag" , "v1.1" , "--check" ] ) . unwrap ( ) ;
2335+ match o {
2336+ Opt :: Upgrade ( opts) => {
2337+ assert_eq ! ( opts. tag, Some ( "v1.1" . to_string( ) ) ) ;
2338+ assert ! ( opts. check) ;
2339+ }
2340+ _ => panic ! ( "Expected Upgrade variant" ) ,
2341+ }
2342+ }
2343+
2344+ #[ test]
2345+ fn test_derive_image_with_tag ( ) {
2346+ // Test basic tag replacement
2347+ let current = ImageReference {
2348+ image : "quay.io/example/myapp:v1.0" . to_string ( ) ,
2349+ transport : "registry" . to_string ( ) ,
2350+ signature : None ,
2351+ } ;
2352+ let result = derive_image_with_tag ( & current, "v1.1" ) . unwrap ( ) ;
2353+ assert_eq ! ( result. image, "quay.io/example/myapp:v1.1" ) ;
2354+ assert_eq ! ( result. transport, "registry" ) ;
2355+
2356+ // Test tag replacement with digest (digest should be stripped)
2357+ let current_with_digest = ImageReference {
2358+ image : "quay.io/example/myapp:v1.0@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" . to_string ( ) ,
2359+ transport : "registry" . to_string ( ) ,
2360+ signature : None ,
2361+ } ;
2362+ let result = derive_image_with_tag ( & current_with_digest, "v2.0" ) . unwrap ( ) ;
2363+ assert_eq ! ( result. image, "quay.io/example/myapp:v2.0" ) ;
2364+
2365+ // Test that non-registry transport fails
2366+ let non_registry = ImageReference {
2367+ image : "/path/to/oci" . to_string ( ) ,
2368+ transport : "oci" . to_string ( ) ,
2369+ signature : None ,
2370+ } ;
2371+ let result = derive_image_with_tag ( & non_registry, "v1.1" ) ;
2372+ assert ! ( result. is_err( ) ) ;
2373+ }
2374+
22062375 #[ test]
22072376 fn test_generate_completion_scripts_contain_commands ( ) {
22082377 use clap_complete:: aot:: { Shell , generate} ;
0 commit comments