1
0
mirror of https://github.com/containers/podman.git synced 2026-02-05 06:45:31 +01:00

Add podman machine os upgrade command

Implements automatic OS upgrade functionality for Podman machines that requires no user input beyond running the command. The upgrade logic automatically determines the appropriate upgrade path using a three-way comparison between client version, machine version, and OCI registry:

* When the client version is older than the machine version, no action is taken and an error is returned.
* When the client version matches the machine version, the OCI registry is queried to check for in-band updates by comparing image digests.  This handles minor, patch level, and updates oci image use cases.
* When the client version is newer than the machine version, the machine is upgraded to match the client's major.minor version.
* No manual image selection or version specification required.

The command supports dry-run mode and JSON (only) output format for automation.

Signed-off-by: Brent Baude <bbaude@redhat.com>
This commit is contained in:
Brent Baude
2025-10-14 10:10:03 -05:00
parent dc8d2c13fd
commit aba2df7517
9 changed files with 665 additions and 4 deletions

View File

@@ -0,0 +1,98 @@
//go:build amd64 || arm64
package os
import (
"context"
"errors"
"github.com/blang/semver/v4"
"github.com/containers/podman/v6/cmd/podman/common"
"github.com/containers/podman/v6/cmd/podman/machine"
"github.com/containers/podman/v6/cmd/podman/registry"
"github.com/containers/podman/v6/cmd/podman/validate"
"github.com/containers/podman/v6/pkg/machine/os"
"github.com/containers/podman/v6/version"
"github.com/spf13/cobra"
"go.podman.io/common/pkg/completion"
)
var upgradeCmd = &cobra.Command{
Use: "upgrade [options] [NAME]",
Short: "Upgrade machine os",
Long: "Upgrade the machine operating system to a newer version",
PersistentPreRunE: validate.NoOp,
Args: cobra.MaximumNArgs(1),
RunE: upgrade,
ValidArgsFunction: common.AutocompleteImages,
Example: `podman machine os upgrade`,
}
type upgradeOpts struct {
dryRun bool
format string
hostVersion string
restart bool
}
var opts upgradeOpts
func init() {
registry.Commands = append(registry.Commands, registry.CliCommand{
Command: upgradeCmd,
Parent: machine.OSCmd,
})
flags := upgradeCmd.Flags()
dryRunFlagName := "dry-run"
flags.BoolVarP(&opts.dryRun, dryRunFlagName, "n", false, "Only check if an upgrade is available")
hostVersionFlagName := "host-version"
flags.StringVar(&opts.hostVersion, hostVersionFlagName, "", "Podman host version (advanced use only)")
_ = upgradeCmd.RegisterFlagCompletionFunc(hostVersionFlagName, completion.AutocompleteNone)
_ = flags.MarkHidden(hostVersionFlagName)
formatFlagName := "format"
flags.StringVarP(&opts.format, formatFlagName, "f", "", "suppress output except for specified format. Implies -n")
_ = upgradeCmd.RegisterFlagCompletionFunc(formatFlagName, completion.AutocompleteNone)
restartFlagName := "restart"
flags.BoolVar(&opts.restart, restartFlagName, false, "Restart VM to upgrade")
}
func upgrade(_ *cobra.Command, args []string) error {
var vmName string
if len(args) == 1 {
vmName = args[0]
}
managerOpts := ManagerOpts{
VMName: vmName,
CLIArgs: args,
Restart: opts.restart,
}
osManager, err := NewOSManager(managerOpts)
if err != nil {
return err
}
upgradeOpts := os.UpgradeOptions{ClientVersion: version.Version, DryRun: opts.dryRun}
if opts.hostVersion != "" {
callerVersion, err := semver.ParseTolerant(opts.hostVersion)
if err != nil {
return err
}
upgradeOpts.MachineVersion = callerVersion
}
if kind := opts.format; len(opts.format) > 0 {
// For now, we only support one output format value of `json`
// But in the future, we may add additional formats as needed
if kind != "json" {
return errors.New("only value of `json` is supported")
}
upgradeOpts.Format = kind
}
if opts.restart && (len(upgradeOpts.Format) > 0 || opts.dryRun) {
return errors.New("--restart cannot be used with --dry-run or --format")
}
return osManager.Upgrade(context.Background(), upgradeOpts)
}

View File

@@ -0,0 +1,91 @@
% podman-machine-os-upgrade 1
## NAME
podman\-machine\-os\-upgrade - Upgrade a Podman Machine's OS
## SYNOPSIS
**podman machine os upgrade** [*options*] [vm]
## DESCRIPTION
Upgrade the OS of a Podman Machine
Automatically perform an upgrade of the Podman Machine OS according to the logic below.
To apply a custom image or an image with a specific digest, use **podman machine os apply** instead.
The default machine name is `podman-machine-default`. If a machine name is not specified as an argument,
then the OS changes will be applied to `podman-machine-default`.
The machine must be started for this command to be run.
### UPGRADE LOGIC
The upgrade function compares the client version against the machine version using semantic versioning (major.minor
only, ignoring patch levels). When versions match, it also queries the online registry to check for image updates:
**Client version older than machine version (downgrade):**
Returns an error. Downgrading is not supported to prevent incompatibilities. Update your Podman client to match or exceed the machine version.
**Client version equals machine version (in-band update):**
Checks for updates to the same version stream by comparing the local OS image digest against the registry's digest. This handles two scenarios:
- Patch version updates (e.g., 6.0.1 → 6.0.2)
- OS image refreshes with the same Podman version (e.g., 6.0.1 → newer OS build with 6.0.1)
If an in-band update exists, the machine is updated to the new image using its digest reference. If no update is available, the function reports that the system is current.
**Client version newer than machine version (major/minor upgrade):**
Upgrades the machine OS to match the client's major.minor version. The upgrade targets a new version tag constructed from the client's major and minor version numbers (e.g., upgrading from 6.0.x to 6.1 when the client is version 6.1.0).
## OPTIONS
#### **--dry-run**, **-n**
Only perform a dry-run of checking for the upgrade. No content is downloaded or applied. This option
cannot be used with --restart
#### **--format**, **-f**
Define a structured output format. The only valid value for this is `json`. Using this option
imples a dry-run. This option cannot be used with --restart.
#### **--help**
Print usage statement.
#### **--restart**
Restart VM after applying changes.
## EXAMPLES
Update the default Podman machine to the latest in-band version or update the machine to
match the client Podman version.
```
$ podman machine os upgrade
```
Same as above but specifying a specific machine.
```
$ podman machine os upgrade mymachine
```
Check if an update is available but do not download or apply the upgrade.
```
$ podman machine os upgrade -n
```
Check if an update is available but the response will be in JSON format. Note this
exposes a boolean field that indicates if an update is available.
Note: using a format option implies a dry-run.
```
$ podman machine os upgrade -f json
```
## SEE ALSO
**[podman(1)](podman.1.md)**, **[podman-machine(1)](podman-machine.1.md)**, **[podman-machine-os(1)](podman-machine-os.1.md)**
## HISTORY
January 2026, Originally compiled by Brent Baude <bbaude@redhat.com>

View File

@@ -11,12 +11,13 @@ podman\-machine\-os - Manage a Podman virtual machine's OS
## SUBCOMMANDS
| Command | Man Page | Description |
|---------|--------------------------------------------------------------|----------------------------------------------|
| apply | [podman-machine-os-apply(1)](podman-machine-os-apply.1.md) | Apply an OCI image to a Podman Machine's OS |
| Command | Man Page | Description |
|---------|--------------------------------------------------------------|---------------------------------------------|
| apply | [podman-machine-os-apply(1)](podman-machine-os-apply.1.md) | Apply an OCI image to a Podman Machine's OS |
| upgrade | [podman-machine-os-upgrade(1)](podman-machine-os-upgrade.1.md) | Upgrade a Podman Machine's OS |
## SEE ALSO
**[podman(1)](podman.1.md)**, **[podman-machine(1)](podman-machine.1.md)**, **[podman-machine-os-apply(1)](podman-machine-os-apply.1.md)**
**[podman(1)](podman.1.md)**, **[podman-machine(1)](podman-machine.1.md)**, **[podman-machine-os-apply(1)](podman-machine-os-apply.1.md)**, **[podman-machine-os-upgrade(1)](podman-machine-os-upgrade.1.md)**
## HISTORY
February 2023, Originally compiled by Ashley Cui <acui@redhat.com>

View File

@@ -92,6 +92,7 @@ my %Format_Option_Is_Special = map { $_ => 1 } (
'diff', 'container diff', 'image diff', # only supports "json"
'generate systemd', # " " " "
'mount', 'container mount', 'image mount', # " " " "
'machine os upgrade', # " " " "
'push', 'image push', 'manifest push', # oci | v2s*
'save', 'image save', # image formats (oci-*, ...)
'inspect', # ambiguous (container/image)

152
pkg/machine/os/bootc.go Normal file
View File

@@ -0,0 +1,152 @@
package os
import (
"encoding/json"
"errors"
"os/exec"
"strings"
"time"
"github.com/blang/semver/v4"
"github.com/opencontainers/go-digest"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/sirupsen/logrus"
"go.podman.io/image/v5/docker/reference"
"go.podman.io/image/v5/manifest"
)
// BootcHost represents the top-level bootc status structure
type BootcHost struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Metadata bootcMetadata `json:"metadata"`
Spec bootcSpec `json:"spec"`
Status bootcStatus `json:"status"`
}
// bootcMetadata contains metadata about the host
type bootcMetadata struct {
Name string `json:"name"`
}
// bootcSpec contains the specification for the bootc host
type bootcSpec struct {
BootOrder string `json:"bootOrder"`
Image imageRef `json:"image"`
}
// imageRef represents a container image reference
type imageRef struct {
Image string `json:"image"`
Transport string `json:"transport"`
}
// bootcStatus contains the current status of the bootc host
type bootcStatus struct {
Booted *bootEntry `json:"booted"`
Rollback *bootEntry `json:"rollback"`
Staged *bootEntry `json:"staged"`
RollbackQueued bool `json:"rollbackQueued"`
Type string `json:"type"`
}
// bootEntry represents a boot entry (booted, rollback, or staged)
type bootEntry struct {
CachedUpdate *imageStatus `json:"cachedUpdate"`
Image imageStatus `json:"image"`
Incompatible bool `json:"incompatible"`
Ostree ostreeInfo `json:"ostree"`
Pinned bool `json:"pinned"`
SoftRebootCapable bool `json:"softRebootCapable"`
Store string `json:"store"`
}
// imageStatus contains detailed information about a container image
type imageStatus struct {
Architecture string `json:"architecture"`
Image imageRefWithSig `json:"image"`
ImageDigest string `json:"imageDigest"`
Timestamp time.Time `json:"timestamp"`
Version string `json:"version"`
}
// imageRefWithSig is an image reference that may include a signature
type imageRefWithSig struct {
Image string `json:"image"`
Transport string `json:"transport"`
Signature *signature `json:"signature,omitempty"`
}
// signature contains signature information for an image
type signature struct {
OstreeRemote string `json:"ostreeRemote"`
}
// ostreeInfo contains OSTree-specific information
type ostreeInfo struct {
Checksum string `json:"checksum"`
DeploySerial int `json:"deploySerial"`
Stateroot string `json:"stateroot"`
}
// GetBootedImageRef returns the booted image reference (status -> booted -> image -> image)
// Returns an empty string if booted is nil
func (b *BootcHost) GetBootedImageRef() string {
if b.Status.Booted == nil {
return ""
}
return b.Status.Booted.Image.Image.Image
}
// GetBootedOSTreeChecksum returns the booted OSTree checksum
// Returns an empty string if booted is nil
func (b *BootcHost) GetBootedOSTreeChecksum() string {
if b.Status.Booted == nil {
return ""
}
return b.Status.Booted.Ostree.Checksum
}
func (b *BootcHost) getLocalOSOCIInfo() (reference.Named, *semver.Version, error) {
ref, err := reference.ParseNormalizedNamed(b.GetBootedImageRef())
if err != nil {
return nil, nil, err
}
named, ok := ref.(reference.NamedTagged)
if !ok {
return nil, nil, errors.New("failed to parse ostree origin")
}
tagAsVersion, err := semver.ParseTolerant(named.Tag())
return named, &tagAsVersion, err
}
func (b *BootcHost) getLocalOsImageDigest() (digest.Digest, error) {
args := []string{"container", "image", "metadata", "--repo", "/ostree/repo", "docker://" + b.GetBootedImageRef()}
logrus.Debugf("executing : ostree %s", strings.Join(args, " "))
cmd := exec.Command("ostree", args...)
out, err := cmd.Output()
if err != nil {
return "", err
}
localOs, err := manifest.FromBlob(out, v1.MediaTypeImageManifest)
if err != nil {
return "", err
}
return localOs.ConfigInfo().Digest, nil
}
// newBootcHost creates a new BootcHost by executing 'sudo bootc status --format json'
// and unmarshaling the output
func newBootcHost() (*BootcHost, error) {
cmd := exec.Command("sudo", "bootc", "status", "--format", "json")
output, err := cmd.Output()
if err != nil {
return nil, err
}
var host BootcHost
if err := json.Unmarshal(output, &host); err != nil {
return nil, err
}
return &host, nil
}

View File

@@ -2,13 +2,47 @@
package os
import (
"context"
"github.com/blang/semver/v4"
"github.com/opencontainers/go-digest"
)
// Manager is the interface for operations on a Podman machine's OS
type Manager interface {
// Apply machine OS changes from an OCI image.
Apply(image string, opts ApplyOptions) error
// Upgrade the machine OS
Upgrade(ctx context.Context, opts UpgradeOptions) error
}
// ApplyOptions are the options for applying an image into a Podman machine VM
type ApplyOptions struct {
Image string
}
type UpgradeOptions struct {
MachineVersion semver.Version
DryRun bool
Format string
ClientVersion semver.Version
}
type Host struct {
Version semver.Version `json:"version"`
}
type Machine struct {
CurrentHash digest.Digest `json:"current_hash"`
NewHash digest.Digest `json:"new_hash"`
Version semver.Version `json:"version"`
InBandUpgradeAvailable bool `json:"inband_update_available"`
}
// UpgradeOutput is an output struct and is only exported so json tags work
// correctly
type UpgradeOutput struct {
Host Host `json:"host"`
Machine Machine `json:"machine"`
}

View File

@@ -3,6 +3,7 @@
package os
import (
"context"
"fmt"
"github.com/containers/podman/v6/pkg/machine"
@@ -39,3 +40,31 @@ func (m *MachineOS) Apply(image string, _ ApplyOptions) error {
}
return nil
}
func (m *MachineOS) Upgrade(_ context.Context, opts UpgradeOptions) error {
isDryRun := opts.DryRun
if len(opts.Format) > 0 {
isDryRun = true
}
args := []string{"podman", "machine", "os", "upgrade", "--host-version=" + opts.ClientVersion.String()}
if opts.DryRun {
args = append(args, "-n")
}
if opts.Format != "" {
args = append(args, "-f", opts.Format)
}
if err := machine.LocalhostSSHShellForceTerm(m.VM.SSH.RemoteUsername, m.VM.SSH.IdentityPath, m.VMName, m.VM.SSH.Port, args); err != nil {
return err
}
if m.Restart && !isDryRun {
var off bool
if err := shim.Stop(m.VM, m.Provider, off); err != nil {
return err
}
if err := shim.Start(m.VM, m.Provider, machine.StartOptions{NoInfo: true}, &off); err != nil {
return err
}
fmt.Printf("Machine %q restarted successfully\n", m.VMName)
}
return nil
}

View File

@@ -3,8 +3,20 @@
package os
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"github.com/blang/semver/v4"
"github.com/opencontainers/go-digest"
"github.com/sirupsen/logrus"
"go.podman.io/image/v5/docker"
"go.podman.io/image/v5/docker/reference"
"go.podman.io/image/v5/image"
"go.podman.io/image/v5/manifest"
"go.podman.io/image/v5/types"
)
// OSTree deals with operations on ostree based os's
@@ -21,3 +33,155 @@ func (dist *OSTree) Apply(image string, _ ApplyOptions) error {
cmd.Stderr = os.Stderr
return cmd.Run()
}
func (dist *OSTree) Upgrade(ctx context.Context, opts UpgradeOptions) error {
var (
updateMessage string
args []string
)
clientInfo := Host{
Version: opts.ClientVersion,
}
machineInfo := Machine{
Version: opts.MachineVersion,
}
bootcStatus, err := newBootcHost()
if err != nil {
return err
}
originNamed, originVersion, err := bootcStatus.getLocalOSOCIInfo()
if err != nil {
return err
}
// compareMajorMinor returns 0 if A == B, -1 if A < B, and 1 if A > B
switch compareMajorMinor(opts.ClientVersion, opts.MachineVersion) {
case 1:
// if caller version < machine version, return an error because this is bad
// this error message looks odd bc the client version %s is a machineversion, but that
// is because of how this command is called through ssh.
return fmt.Errorf("client version %s is older than your machine version %s: upgrade your client", opts.MachineVersion, opts.ClientVersion)
case 0:
// If caller version == machine version AND there is actually an update
// on the registry, then update "in band"
if compareMajorMinor(*originVersion, opts.ClientVersion) != 0 {
return fmt.Errorf("version mismatch between podman version (%s) and host os (%s)", originVersion.String(), opts.ClientVersion.String())
}
localDigest, err := bootcStatus.getLocalOsImageDigest()
if err != nil {
return err
}
// check if an in band update exists. this covers two scenarios
// 5.7.1 -> 5.7.2
// 5.7.1 -> newer OS image with 5.7.1
registryDigest, updateExists, err := checkInBandUpgrade(ctx, originNamed, localDigest)
if err != nil {
return err
}
machineInfo.CurrentHash = localDigest
machineInfo.NewHash = registryDigest
if !updateExists {
// If more formats are ever added, we maybe break this out to a
// func called print() which does a switch on type and marshalls
if opts.Format == "json" {
return printJSON(UpgradeOutput{
Host: clientInfo,
Machine: machineInfo,
})
}
return nil
}
machineInfo.InBandUpgradeAvailable = true
updateMessage = fmt.Sprintf("Updating OS from %s to %s\n", localDigest.String(), registryDigest.String())
args = []string{"bootc", "upgrade"}
default:
// if caller version > machine version, then update to the caller version
newVersion := fmt.Sprintf("%d.%d", opts.MachineVersion.Major, opts.MachineVersion.Minor)
updateReference := fmt.Sprintf("%s:%s", originNamed.Name(), newVersion)
updateMessage = fmt.Sprintf("Updating OS from version %d.%d to %s\n", opts.ClientVersion.Major, opts.ClientVersion.Minor, newVersion)
args = []string{"bootc", "switch", updateReference}
}
if len(opts.Format) > 0 {
return printJSON(UpgradeOutput{
Host: clientInfo,
Machine: machineInfo,
})
}
// if you change conditions above this, pay attention to this
// next line because you might need to wrap it in a conditional
fmt.Print(updateMessage)
if opts.DryRun {
return nil
}
cmd := exec.Command("sudo", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// compareMajorMinor returns 0 if A == B, -1 if A < B, and 1 if A > B
func compareMajorMinor(versionA, versionB semver.Version) int {
// ignore patch versions for comparison
versionA.Patch = 0
versionB.Patch = 0
versionA.Pre = nil
versionB.Pre = nil
// https://pkg.go.dev/github.com/blang/semver/v4#Version.Compare
return versionA.Compare(versionB)
}
// checkInBandUpgrade takes a named and the digest of the image that made the operating system. we then check if the image
// on the registry is different.
func checkInBandUpgrade(ctx context.Context, named reference.Named, localHostDigest digest.Digest) (digest.Digest, bool, error) {
sysCtx := types.SystemContext{}
logrus.Debugf("Checking if %s has upgrade available", named.Name())
// Lookup the image from the os
ir, err := docker.NewReference(named)
if err != nil {
return "", false, err
}
src, err := ir.NewImageSource(ctx, &sysCtx)
if err != nil {
return "", false, err
}
defer src.Close()
rawManifest, manType, err := image.UnparsedInstance(src, nil).Manifest(ctx)
if err != nil {
return "", false, err
}
list, err := manifest.ListFromBlob(rawManifest, manType)
if err != nil {
return "", false, err
}
// Now get the blob for the image
d, err := list.ChooseInstance(&sysCtx)
if err != nil {
return "", false, err
}
imageManifest, imageManType, err := image.UnparsedInstance(src, &d).Manifest(ctx)
if err != nil {
return "", false, err
}
registryImageManifest, err := manifest.FromBlob(imageManifest, imageManType)
if err != nil {
return "", false, err
}
return registryImageManifest.ConfigInfo().Digest, registryImageManifest.ConfigInfo().Digest != localHostDigest, nil
}
func printJSON(out UpgradeOutput) error {
b, err := json.Marshal(out)
if err != nil {
return err
}
fmt.Println(string(b))
return nil
}

View File

@@ -0,0 +1,91 @@
package os
import (
"testing"
"github.com/blang/semver/v4"
)
func Test_compareMajorMinor(t *testing.T) {
type args struct {
versionA semver.Version
versionB semver.Version
}
tests := []struct {
name string
args args
want int
}{
{
name: "equal major and minor versions and different patch",
args: args{
versionA: semver.MustParse("1.2.3"),
versionB: semver.MustParse("1.2.5"),
},
want: 0,
},
{
name: "A major version less than B",
args: args{
versionA: semver.MustParse("1.5.0"),
versionB: semver.MustParse("2.5.0"),
},
want: -1,
},
{
name: "A major version greater than B",
args: args{
versionA: semver.MustParse("3.2.0"),
versionB: semver.MustParse("2.9.0"),
},
want: 1,
},
{
name: "A minor version less than B (same major)",
args: args{
versionA: semver.MustParse("1.2.0"),
versionB: semver.MustParse("1.5.0"),
},
want: -1,
},
{
name: "A minor version greater than B (same major)",
args: args{
versionA: semver.MustParse("1.8.0"),
versionB: semver.MustParse("1.3.0"),
},
want: 1,
},
{
name: "completely equal versions",
args: args{
versionA: semver.MustParse("1.2.3"),
versionB: semver.MustParse("1.2.3"),
},
want: 0,
},
{
name: "zero versions",
args: args{
versionA: semver.MustParse("0.0.0"),
versionB: semver.MustParse("0.0.1"),
},
want: 0,
},
{
name: "A is zero, B is not",
args: args{
versionA: semver.MustParse("0.0.0"),
versionB: semver.MustParse("1.0.0"),
},
want: -1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := compareMajorMinor(tt.args.versionA, tt.args.versionB); got != tt.want {
t.Errorf("compareMajorMinor() = %v, want %v", got, tt.want)
}
})
}
}