Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Pages
id: pages
uses: actions/configure-pages@v5
uses: actions/configure-pages@v6
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
Expand All @@ -42,4 +42,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
19 changes: 14 additions & 5 deletions crates/lib/src/bootc_composefs/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,18 @@ pub(crate) async fn upgrade_composefs(
.await
.context("Getting composefs deployment status")?;

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(image.with_tag(tag)?)
} else {
None
};

let do_upgrade_opts = DoUpgradeOpts {
soft_reboot: opts.soft_reboot,
apply: opts.apply,
Expand Down Expand Up @@ -391,11 +403,8 @@ pub(crate) async fn upgrade_composefs(
.await;
}

let mut booted_imgref = host
.spec
.image
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No image source specified"))?;
let imgref = derived_image.as_ref().or(current_image);
let mut booted_imgref = imgref.ok_or_else(|| anyhow::anyhow!("No image source specified"))?;

let repo = &*composefs.repo;

Expand Down
112 changes: 104 additions & 8 deletions crates/lib/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,13 @@ 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>,

#[clap(flatten)]
pub(crate) progress: ProgressOptions,
}
Expand Down Expand Up @@ -1047,7 +1054,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(image.with_tag(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 All @@ -1063,15 +1082,16 @@ async fn upgrade(
}
}

let spec = RequiredHostSpec::from_spec(&host.spec)?;
let imgref = imgref.ok_or_else(|| anyhow::anyhow!("No image source specified"))?;
// Use the derived image reference (if --tag was specified) instead of the spec's image
let spec = RequiredHostSpec { image: imgref };
let booted_image = host
.status
.booted
.as_ref()
.map(|b| b.query_image(repo))
.transpose()?
.flatten();
let imgref = imgref.ok_or_else(|| anyhow::anyhow!("No image source specified"))?;
// Find the currently queued digest, if any before we pull
let staged = host.status.staged.as_ref();
let staged_image = staged.as_ref().and_then(|s| s.image.as_ref());
Expand Down Expand Up @@ -1099,16 +1119,17 @@ async fn upgrade(
}

if opts.check {
let imgref = imgref.clone().into();
let ostree_imgref = imgref.clone().into();
let mut imp =
crate::deploy::new_importer(repo, &imgref, Some(&booted_ostree.deployment)).await?;
crate::deploy::new_importer(repo, &ostree_imgref, Some(&booted_ostree.deployment))
.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 Down Expand Up @@ -1236,7 +1257,6 @@ async fn upgrade(

Ok(())
}

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 @@ -2245,6 +2265,82 @@ 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);
}
_ => panic!("Expected Upgrade variant"),
}
}

#[test]
fn test_image_reference_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 = current.with_tag("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 = current_with_digest.with_tag("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: "localhost/myapp:v1.0".to_string(),
transport: "containers-storage".to_string(),
signature: None,
};
let result = containers_storage.with_tag("v1.1").unwrap();
assert_eq!(result.image, "localhost/myapp:v1.1");
assert_eq!(result.transport, "containers-storage");

// Test digest stripping for non-registry transport
let containers_storage_with_digest = ImageReference {
image:
"localhost/myapp:v1.0@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
.to_string(),
transport: "containers-storage".to_string(),
signature: None,
};
let result = containers_storage_with_digest.with_tag("v2.0").unwrap();
assert_eq!(result.image, "localhost/myapp:v2.0");
assert_eq!(result.transport, "containers-storage");

// Test image without tag (edge case)
let no_tag = ImageReference {
image: "localhost/myapp".to_string(),
transport: "containers-storage".to_string(),
signature: None,
};
let result = no_tag.with_tag("v1.0").unwrap();
assert_eq!(result.image, "localhost/myapp:v1.0");
assert_eq!(result.transport, "containers-storage");
}

#[test]
fn test_generate_completion_scripts_contain_commands() {
use clap_complete::aot::{Shell, generate};
Expand Down
36 changes: 36 additions & 0 deletions crates/lib/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,42 @@ impl ImageReference {
Ok(format!("{}:{}", self.transport, self.image))
}
}

/// Derive a new image reference by replacing the tag.
///
/// For transports with parseable image references (registry, containers-storage),
/// uses the OCI Reference API to properly handle tag replacement.
/// For other transports (oci, etc.), falls back to string manipulation.
pub fn with_tag(&self, new_tag: &str) -> Result<Self> {
// Try to parse as an OCI Reference (works for registry and containers-storage)
let new_image = if let Ok(reference) = self.image.parse::<Reference>() {
// Use the proper OCI API to replace the tag
let new_ref = Reference::with_tag(
reference.registry().to_string(),
reference.repository().to_string(),
new_tag.to_string(),
);
new_ref.to_string()
} else {
// For other transports like oci: with filesystem paths,
// strip any digest first, then replace tag via string manipulation
let image_without_digest = self.image.split('@').next().unwrap_or(&self.image);

// Split on last ':' 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: self.transport.clone(),
signature: self.signature.clone(),
})
}
}

/// The status of the booted image
Expand Down
16 changes: 16 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,10 @@ 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

<!-- END GENERATED OPTIONS -->

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

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 @@ -230,4 +230,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