diff --git a/cmd/podman/machine/os/upgrade.go b/cmd/podman/machine/os/upgrade.go new file mode 100644 index 0000000000..b13524fd92 --- /dev/null +++ b/cmd/podman/machine/os/upgrade.go @@ -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) +} diff --git a/docs/source/markdown/podman-machine-os-upgrade.1.md b/docs/source/markdown/podman-machine-os-upgrade.1.md new file mode 100644 index 0000000000..a3911a66a3 --- /dev/null +++ b/docs/source/markdown/podman-machine-os-upgrade.1.md @@ -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 diff --git a/docs/source/markdown/podman-machine-os.1.md b/docs/source/markdown/podman-machine-os.1.md index 5c69092c5f..7e78a49149 100644 --- a/docs/source/markdown/podman-machine-os.1.md +++ b/docs/source/markdown/podman-machine-os.1.md @@ -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 diff --git a/hack/xref-helpmsgs-manpages b/hack/xref-helpmsgs-manpages index c38b9b87b5..40cc32509d 100755 --- a/hack/xref-helpmsgs-manpages +++ b/hack/xref-helpmsgs-manpages @@ -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) diff --git a/pkg/machine/os/bootc.go b/pkg/machine/os/bootc.go new file mode 100644 index 0000000000..be1bb26744 --- /dev/null +++ b/pkg/machine/os/bootc.go @@ -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 +} diff --git a/pkg/machine/os/config.go b/pkg/machine/os/config.go index a3630fcc45..18a2ef459b 100644 --- a/pkg/machine/os/config.go +++ b/pkg/machine/os/config.go @@ -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"` +} diff --git a/pkg/machine/os/machine_os.go b/pkg/machine/os/machine_os.go index 7a7e28edd1..0ee9aae92c 100644 --- a/pkg/machine/os/machine_os.go +++ b/pkg/machine/os/machine_os.go @@ -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 +} diff --git a/pkg/machine/os/ostree.go b/pkg/machine/os/ostree.go index ecc180d896..37d33f50a2 100644 --- a/pkg/machine/os/ostree.go +++ b/pkg/machine/os/ostree.go @@ -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 +} diff --git a/pkg/machine/os/ostree_test.go b/pkg/machine/os/ostree_test.go new file mode 100644 index 0000000000..cb9b948fe2 --- /dev/null +++ b/pkg/machine/os/ostree_test.go @@ -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) + } + }) + } +}