Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 229 additions & 5 deletions crates/lib/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}");
}
Expand All @@ -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?;
Expand Down Expand Up @@ -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> {
Copy link
Collaborator

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 ImageReference instead of being all the way at the top in the CLI code.

That said I am pretty sure for containers-storage we can also create a Reference instead of reimplementing parsing.

The complicated one is oci because 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.

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(&current.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")
Copy link
Collaborator

Choose a reason for hiding this comment

The 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.

Copy link
Collaborator

Choose a reason for hiding this comment

The 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.

Copy link
Collaborator

Choose a reason for hiding this comment

The 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 skopeo inspect on their own?

I know it's less ergonomic and this whole thing is just about ergonomics, but hopefully the --tag is enough.

My worry is basically again:

  • We must handle authfiles and such using the ostree/bootc one
  • This feels like a kind of scope creep into general "inspect the remote container" reference but users can already just run skopeo with their own options if that's what they want.

Maybe we could add skopeo inspect --booted for example? But that still doesn't solve the authfile discrepancy.

(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 {
Expand Down Expand Up @@ -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() {
// 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(&current, "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(&current_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};
Expand Down
24 changes: 24 additions & 0 deletions docs/src/man/bootc-upgrade.8.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@ 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

**--show-tags**

Show available tags when using --check

<!-- END GENERATED OPTIONS -->

# EXAMPLES
Expand All @@ -85,6 +93,22 @@ 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

Check for updates and show available tags:

bootc upgrade --check --show-tags

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)
Expand Down
7 changes: 7 additions & 0 deletions tmt/plans/integration.fmf
Original file line number Diff line number Diff line change
Expand Up @@ -222,4 +222,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
Loading
Loading