-
Notifications
You must be signed in to change notification settings - Fork 187
cli: Add tag-aware upgrade operations #2094
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,12 +29,15 @@ use indoc::indoc; | |
| use ostree::gio; | ||
| use ostree_container::store::PrepareResult; | ||
| use ostree_ext::container as ostree_container; | ||
| use ostree_ext::container::Transport; | ||
| use ostree_ext::oci_spec; | ||
|
|
||
| use ostree_ext::keyfileext::KeyFileExt; | ||
| use ostree_ext::ostree; | ||
| use ostree_ext::sysroot::SysrootLock; | ||
| use schemars::schema_for; | ||
| use serde::{Deserialize, Serialize}; | ||
| use serde_json::Value; | ||
|
|
||
| use crate::bootc_composefs::delete::delete_composefs_deployment; | ||
| use crate::bootc_composefs::gc::composefs_gc; | ||
|
|
@@ -123,6 +126,19 @@ 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<String>, | ||
|
|
||
| /// Show available tags when using --check. | ||
| /// | ||
| /// Lists all available tags from the image repository. Only works with --check. | ||
| #[clap(long, requires = "check")] | ||
| pub(crate) show_tags: bool, | ||
|
|
||
| #[clap(flatten)] | ||
| pub(crate) progress: ProgressOptions, | ||
| } | ||
|
|
@@ -1047,7 +1063,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(derive_image_with_tag(image, 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 | ||
|
|
@@ -1099,15 +1127,15 @@ async fn upgrade( | |
| } | ||
|
|
||
| if opts.check { | ||
| let imgref = imgref.clone().into(); | ||
| let mut imp = crate::deploy::new_importer(repo, &imgref).await?; | ||
| let ostree_imgref = imgref.clone().into(); | ||
| let mut imp = crate::deploy::new_importer(repo, &ostree_imgref).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}"); | ||
| } | ||
|
|
@@ -1120,6 +1148,21 @@ async fn upgrade( | |
| } | ||
| } | ||
| } | ||
|
|
||
| // Show available tags if requested | ||
| if opts.show_tags { | ||
| match fetch_available_tags(imgref).await { | ||
| Ok(tags) => { | ||
| println!("\nAvailable tags:"); | ||
| for tag in tags { | ||
| println!(" {}", tag); | ||
| } | ||
| } | ||
| Err(e) => { | ||
| tracing::warn!("Failed to fetch available tags: {}", e); | ||
| } | ||
| } | ||
| } | ||
| } else { | ||
| // Auto-detect whether to use unified storage based on image presence in bootc storage | ||
| let use_unified = crate::deploy::image_exists_in_unified_storage(storage, imgref).await?; | ||
|
|
@@ -1220,6 +1263,87 @@ async fn upgrade( | |
| Ok(()) | ||
| } | ||
|
|
||
| /// Derive a new image reference by replacing the tag of the current image | ||
| fn derive_image_with_tag(current: &ImageReference, new_tag: &str) -> Result<ImageReference> { | ||
| let transport = current.transport()?; | ||
|
|
||
| // For registry transport, parse and use with_tag API | ||
| let new_image = match transport { | ||
| Transport::Registry => { | ||
| let reference: oci_spec::distribution::Reference = current.image.parse()?; | ||
| let new_ref = oci_spec::distribution::Reference::with_tag( | ||
| reference.registry().to_string(), | ||
| reference.repository().to_string(), | ||
| new_tag.to_string(), | ||
| ); | ||
| new_ref.to_string() | ||
| } | ||
| // For other transports, simple string replacement | ||
| // This handles containers-storage:, oci:, etc. | ||
| _ => { | ||
| // First strip any digest (@sha256:..., @sha512:..., etc.) | ||
| let image_without_digest = current.image.split('@').next().unwrap_or(¤t.image); | ||
|
|
||
| // Then split on last occurrence of ':' 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: current.transport.clone(), | ||
| signature: current.signature.clone(), | ||
| }) | ||
| } | ||
|
|
||
| /// Fetch available tags for an image using skopeo inspect | ||
| async fn fetch_available_tags(imgref: &ImageReference) -> Result<Vec<String>> { | ||
| // Only works for registry transport | ||
| if imgref.transport()? != Transport::Registry { | ||
| anyhow::bail!( | ||
| "Tag listing only works with registry images, current transport is '{}'", | ||
| imgref.transport | ||
| ); | ||
| } | ||
|
|
||
| let image_spec = format!("docker://{}", imgref.image); | ||
|
|
||
| // Use skopeo inspect to get repository tags | ||
| let output = tokio::process::Command::new("skopeo") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One problem is this won't use the ostree/bootc pull secret by default; we have a whole containers-image-proxy abstraction that we need to use in general for this. Though the next problem here is that code doesn't expose an abstraction for this...but should.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You marked this resolved but I don't think it is right? IOW if the image is behind a pull secret then this will fail. Look at the containers-image-proxy code that we use. We need to pass the ostree auth file to skopeo here.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bigger picture can we just drop this list-tags functionality for now and instead suggest to users to directly run I know it's less ergonomic and this whole thing is just about ergonomics, but hopefully the My worry is basically again:
Maybe we could add (I know the authfile thing is also quite annoying...see also containers/image#2600 ) |
||
| .arg("inspect") | ||
| .arg(&image_spec) | ||
| .output() | ||
| .await | ||
| .context("Failed to execute skopeo inspect (is skopeo installed?)")?; | ||
|
|
||
| if !output.status.success() { | ||
| let stderr = String::from_utf8_lossy(&output.stderr); | ||
| anyhow::bail!("Failed to inspect image: {}", stderr); | ||
| } | ||
|
|
||
| // Parse JSON output | ||
| let json: Value = serde_json::from_slice(&output.stdout) | ||
| .context("Failed to parse skopeo inspect JSON output")?; | ||
|
|
||
| // Extract RepoTags array | ||
| let tags = json | ||
| .get("RepoTags") | ||
| .and_then(|v| v.as_array()) | ||
| .ok_or_else(|| anyhow::anyhow!("No RepoTags field in skopeo inspect output"))?; | ||
|
|
||
| let tag_strings: Vec<String> = tags | ||
| .iter() | ||
| .filter_map(|v| v.as_str().map(|s| s.to_string())) | ||
| .collect(); | ||
|
|
||
| Ok(tag_strings) | ||
| } | ||
|
|
||
| pub(crate) fn imgref_for_switch(opts: &SwitchOpts) -> Result<ImageReference> { | ||
| let transport = ostree_container::Transport::try_from(opts.transport.as_str())?; | ||
| let imgref = ostree_container::ImageReference { | ||
|
|
@@ -2203,6 +2327,106 @@ 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); | ||
| assert!(!opts.show_tags); | ||
| } | ||
| _ => panic!("Expected Upgrade variant"), | ||
| } | ||
|
|
||
| // Test --show-tags requires --check | ||
| let result = Opt::try_parse_from(["bootc", "upgrade", "--show-tags"]); | ||
| assert!(result.is_err()); | ||
|
|
||
| // Test --show-tags works with --check | ||
| let o = Opt::try_parse_from(["bootc", "upgrade", "--check", "--show-tags"]).unwrap(); | ||
| match o { | ||
| Opt::Upgrade(opts) => { | ||
| assert!(opts.check); | ||
| assert!(opts.show_tags); | ||
| } | ||
| _ => panic!("Expected Upgrade variant"), | ||
| } | ||
|
|
||
| // Test --show-tags with --tag and --check | ||
| let o = Opt::try_parse_from([ | ||
| "bootc", | ||
| "upgrade", | ||
| "--tag", | ||
| "v1.1", | ||
| "--check", | ||
| "--show-tags", | ||
| ]) | ||
| .unwrap(); | ||
| match o { | ||
| Opt::Upgrade(opts) => { | ||
| assert_eq!(opts.tag, Some("v1.1".to_string())); | ||
| assert!(opts.check); | ||
| assert!(opts.show_tags); | ||
| } | ||
| _ => panic!("Expected Upgrade variant"), | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_derive_image_with_tag() { | ||
gursewak1997 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // 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 = derive_image_with_tag(¤t, "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 = derive_image_with_tag(¤t_with_digest, "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: "myapp:v1.0".to_string(), | ||
| transport: "containers-storage".to_string(), | ||
| signature: None, | ||
| }; | ||
| let result = derive_image_with_tag(&containers_storage, "v1.1").unwrap(); | ||
| assert_eq!(result.image, "myapp:v1.1"); | ||
| assert_eq!(result.transport, "containers-storage"); | ||
|
|
||
| // Test digest stripping for non-registry transport | ||
| let containers_storage_with_digest = ImageReference { | ||
| image: | ||
| "myapp:v1.0@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" | ||
| .to_string(), | ||
| transport: "containers-storage".to_string(), | ||
| signature: None, | ||
| }; | ||
| let result = derive_image_with_tag(&containers_storage_with_digest, "v2.0").unwrap(); | ||
| assert_eq!(result.image, "myapp:v2.0"); | ||
| assert_eq!(result.transport, "containers-storage"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_generate_completion_scripts_contain_commands() { | ||
| use clap_complete::aot::{Shell, generate}; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm I think this should probably be a method on
ImageReferenceinstead of being all the way at the top in the CLI code.That said I am pretty sure for
containers-storagewe can also create aReferenceinstead of reimplementing parsing.The complicated one is
ocibecause it's filesystem paths and:creates ambiguity; there's some complex code IIRC in containers/container-libs around this in Go which we may need to replicate. I think this came up elsewhere.