From 3cdb8530b2d18da0680dab100efa7749cfaf2ae4 Mon Sep 17 00:00:00 2001 From: Cartrius Phipps Date: Thu, 24 Jul 2025 13:52:21 -0700 Subject: [PATCH] feat: Add finch vm update-os command Signed-off-by: Cartrius Phipps --- cmd/finch/virtual_machine.go | 1 + cmd/finch/virtual_machine_darwin.go | 2 + cmd/finch/virtual_machine_test.go | 6 +- cmd/finch/virtual_machine_update_os.go | 115 ++++ cmd/finch/virtual_machine_update_os_test.go | 534 ++++++++++++++++++ cmd/finch/virtual_machine_windows.go | 2 + pkg/update/os.go | 514 ++++++++++++++++++ pkg/update/os_test.go | 565 ++++++++++++++++++++ 8 files changed, 1736 insertions(+), 3 deletions(-) create mode 100644 cmd/finch/virtual_machine_update_os.go create mode 100644 cmd/finch/virtual_machine_update_os_test.go create mode 100644 pkg/update/os.go create mode 100644 pkg/update/os_test.go diff --git a/cmd/finch/virtual_machine.go b/cmd/finch/virtual_machine.go index 0dd30384e..9d4fd48ac 100644 --- a/cmd/finch/virtual_machine.go +++ b/cmd/finch/virtual_machine.go @@ -103,5 +103,6 @@ func virtualMachineCommands( fp, fs, disk.NewUserDataDiskManager(ncc, ecc, &afero.OsFs{}, fp, finchRootPath, fc, logger), + finchRootPath, ) } diff --git a/cmd/finch/virtual_machine_darwin.go b/cmd/finch/virtual_machine_darwin.go index 72bb8de62..f96270b0f 100644 --- a/cmd/finch/virtual_machine_darwin.go +++ b/cmd/finch/virtual_machine_darwin.go @@ -42,6 +42,7 @@ func newVirtualMachineCommand( fp path.Finch, fs afero.Fs, diskManager disk.UserDataDiskManager, + finchRootPath string, ) *cobra.Command { virtualMachineCommand := &cobra.Command{ Use: virtualMachineRootCmd, @@ -57,6 +58,7 @@ func newVirtualMachineCommand( fp.LimaSSHPrivateKeyPath(), diskManager), newSettingsVMCommand(logger, lca, fs, os.Stdout), newDiskVMCommand(limaCmdCreator, logger), + newUpdateOSVMCommand(logger, finchRootPath), ) return virtualMachineCommand diff --git a/cmd/finch/virtual_machine_test.go b/cmd/finch/virtual_machine_test.go index fbb1d9531..c04443aeb 100644 --- a/cmd/finch/virtual_machine_test.go +++ b/cmd/finch/virtual_machine_test.go @@ -20,13 +20,13 @@ import ( func TestVirtualMachineCommand(t *testing.T) { t.Parallel() - cmd := newVirtualMachineCommand(nil, nil, nil, nil, nil, "", nil, nil) + cmd := newVirtualMachineCommand(nil, nil, nil, nil, nil, "", nil, nil, "") assert.Equal(t, cmd.Use, virtualMachineRootCmd) // check the number of subcommand for vm - expectedCmds := 6 + expectedCmds := 7 if runtime.GOOS == "darwin" { - expectedCmds = 7 // Darwin includes disk commands + expectedCmds = 8 // Darwin includes disk commands } assert.Equal(t, len(cmd.Commands()), expectedCmds) } diff --git a/cmd/finch/virtual_machine_update_os.go b/cmd/finch/virtual_machine_update_os.go new file mode 100644 index 000000000..78a4cb91b --- /dev/null +++ b/cmd/finch/virtual_machine_update_os.go @@ -0,0 +1,115 @@ +//go:build darwin || windows + +package main + +import ( + "fmt" + "runtime" + + "github.com/spf13/cobra" + + "github.com/runfinch/finch/pkg/flog" + "github.com/runfinch/finch/pkg/update" +) + +func newUpdateOSVMCommand(logger flog.Logger, finchRootPath string) *cobra.Command { + updateOSCommand := &cobra.Command{ + Use: "update-os", + Short: "Check for virtual machine OS updates", + Long: "Check for available OS updates to the Finch virtual machine. Use --install to download and install updates.", + RunE: newUpdateOSVMAction(logger, finchRootPath).runAdapter, + } + updateOSCommand.Flags().BoolP("install", "i", false, "Download and install the update if available") + return updateOSCommand +} + +type updateOSVMAction struct { + logger flog.Logger + finchRootPath string +} + +func newUpdateOSVMAction(logger flog.Logger, finchRootPath string) *updateOSVMAction { + return &updateOSVMAction{ + logger: logger, + finchRootPath: finchRootPath, + } +} + +func (uva *updateOSVMAction) runAdapter(cmd *cobra.Command, _ []string) error { + install, _ := cmd.Flags().GetBool("install") + return uva.run(!install) +} + +func (uva *updateOSVMAction) run(checkOnly bool) error { + var finchPath string + switch runtime.GOOS { + case "darwin": + finchPath = "/Applications/Finch" + case "windows": + finchPath = "C:\\Program Files\\Finch" + } + + updater := update.NewOSUpdater(finchPath) + + if checkOnly { + return uva.checkForUpdates(updater) + } + + return uva.applyUpdate(updater) +} + +type osUpdater interface { + CheckForUpdates() (*update.Status, error) + ApplyUpdate() error +} + +func (uva *updateOSVMAction) checkForUpdates(updater osUpdater) error { + uva.logger.Info("Checking for OS updates...") + + status, err := updater.CheckForUpdates() + if err != nil { + return fmt.Errorf("failed to check for updates: %w", err) + } + + if status.UpdateAvailable { + uva.logger.Infof("OS update available: %s to %s", status.CurrentVersion, status.LatestVersion) + uva.logger.Info("Run 'finch vm update-os --install' to apply the update") + } else { + if status.CurrentVersion != "" { + uva.logger.Infof("OS is up to date: %s", status.CurrentVersion) + } else { + uva.logger.Info("No OS version found. Run 'finch vm init' first.") + } + } + + return nil +} + +func (uva *updateOSVMAction) applyUpdate(updater osUpdater) error { + uva.logger.Info("Checking for OS updates...") + + status, err := updater.CheckForUpdates() + if err != nil { + return fmt.Errorf("failed to check for updates: %w", err) + } + + if !status.UpdateAvailable { + if status.CurrentVersion != "" { + uva.logger.Infof("OS is already up to date: %s", status.CurrentVersion) + } else { + uva.logger.Info("No OS version found. Run 'finch vm init' first.") + } + return nil + } + + uva.logger.Infof("Updating OS from %s to %s...", status.CurrentVersion, status.LatestVersion) + uva.logger.Warnln("This will stop and recreate the VM. All containers and data will be lost.") + + err = updater.ApplyUpdate() + if err != nil { + return fmt.Errorf("failed to apply OS update: %w", err) + } + + uva.logger.Info("OS update completed successfully") + return nil +} diff --git a/cmd/finch/virtual_machine_update_os_test.go b/cmd/finch/virtual_machine_update_os_test.go new file mode 100644 index 000000000..05ce00ae3 --- /dev/null +++ b/cmd/finch/virtual_machine_update_os_test.go @@ -0,0 +1,534 @@ +//go:build darwin || windows + +package main + +import ( + "errors" + "fmt" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "github.com/runfinch/finch/pkg/flog" + "github.com/runfinch/finch/pkg/mocks" + "github.com/runfinch/finch/pkg/update" +) + +func TestNewUpdateOSVMCommand(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + logger := mocks.NewLogger(ctrl) + + cmd := newUpdateOSVMCommand(logger, "/test/path") + assert.Equal(t, "update-os", cmd.Name()) + assert.Equal(t, "Check for virtual machine OS updates", cmd.Short) + assert.Contains(t, cmd.Long, "Check for available OS updates to the Finch virtual machine") + + // Verify the install flag exists + installFlag := cmd.Flags().Lookup("install") + assert.NotNil(t, installFlag) + assert.Equal(t, "i", installFlag.Shorthand) + assert.Equal(t, "false", installFlag.DefValue) +} + +func TestNewUpdateOSVMAction(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + logger := mocks.NewLogger(ctrl) + + action := newUpdateOSVMAction(logger, "/test/path") + assert.NotNil(t, action) + assert.Equal(t, logger, action.logger) + assert.Equal(t, "/test/path", action.finchRootPath) +} + +func TestUpdateOSVMAction_runAdapter(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + installFlag bool + checkOnly bool + }{ + { + name: "check only mode", + installFlag: false, + checkOnly: true, + }, + { + name: "install mode", + installFlag: true, + checkOnly: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{} + cmd.Flags().BoolP("install", "i", tc.installFlag, "") + + // Test that runAdapter extracts the flag correctly + install, _ := cmd.Flags().GetBool("install") + assert.Equal(t, tc.installFlag, install) + assert.Equal(t, tc.checkOnly, !install) + }) + } +} + +func TestUpdateOSVMAction_runAdapter_execution(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + installFlag bool + updateStatus *update.Status + checkErr error + applyErr error + }{ + { + name: "check only execution", + installFlag: false, + updateStatus: &update.Status{ + UpdateAvailable: true, + CurrentVersion: "v1.0.0", + LatestVersion: "v1.1.0", + }, + }, + { + name: "install execution", + installFlag: true, + updateStatus: &update.Status{ + UpdateAvailable: true, + CurrentVersion: "v1.0.0", + LatestVersion: "v1.1.0", + }, + }, + { + name: "network error", + installFlag: false, + checkErr: errors.New("network error"), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + logger := mocks.NewLogger(ctrl) + + // Expect all possible logger calls + logger.EXPECT().Info(gomock.Any()).AnyTimes() + logger.EXPECT().Infof(gomock.Any(), gomock.Any()).AnyTimes() + logger.EXPECT().Infof(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + logger.EXPECT().Warnln(gomock.Any()).AnyTimes() + + cmd := &cobra.Command{} + cmd.Flags().BoolP("install", "i", tc.installFlag, "") + + // Create a custom action that uses our mock updater + action := &testUpdateOSVMAction{ + logger: logger, + finchRootPath: "/test/path", + mockUpdater: &mockUpdaterForTest{ + status: tc.updateStatus, + err: tc.checkErr, + applyErr: tc.applyErr, + }, + } + + // Test the runAdapter method + _ = action.runAdapter(cmd, []string{}) + }) + } +} + +// testUpdateOSVMAction is a test version that uses a mock updater. +type testUpdateOSVMAction struct { + logger flog.Logger + finchRootPath string + mockUpdater *mockUpdaterForTest +} + +func (uva *testUpdateOSVMAction) runAdapter(cmd *cobra.Command, _ []string) error { + install, _ := cmd.Flags().GetBool("install") + return uva.run(!install) +} + +func (uva *testUpdateOSVMAction) run(checkOnly bool) error { + if checkOnly { + return uva.checkForUpdates(uva.mockUpdater) + } + return uva.applyUpdate(uva.mockUpdater) +} + +func (uva *testUpdateOSVMAction) checkForUpdates(updater osUpdater) error { + uva.logger.Info("Checking for OS updates...") + + status, err := updater.CheckForUpdates() + if err != nil { + return fmt.Errorf("failed to check for updates: %w", err) + } + + if status.UpdateAvailable { + uva.logger.Infof("OS update available: %s to %s", status.CurrentVersion, status.LatestVersion) + uva.logger.Info("Run 'finch vm update-os --install' to apply the update") + } else { + if status.CurrentVersion != "" { + uva.logger.Infof("OS is up to date: %s", status.CurrentVersion) + } else { + uva.logger.Info("No OS version found. Run 'finch vm init' first.") + } + } + + return nil +} + +func (uva *testUpdateOSVMAction) applyUpdate(updater osUpdater) error { + uva.logger.Info("Checking for OS updates...") + + status, err := updater.CheckForUpdates() + if err != nil { + return fmt.Errorf("failed to check for updates: %w", err) + } + + if !status.UpdateAvailable { + if status.CurrentVersion != "" { + uva.logger.Infof("OS is already up to date: %s", status.CurrentVersion) + } else { + uva.logger.Info("No OS version found. Run 'finch vm init' first.") + } + return nil + } + + uva.logger.Infof("Updating OS from %s to %s...", status.CurrentVersion, status.LatestVersion) + uva.logger.Warnln("This will stop and recreate the VM. All containers and data will be lost.") + + err = updater.ApplyUpdate() + if err != nil { + return fmt.Errorf("failed to apply OS update: %w", err) + } + + uva.logger.Info("OS update completed successfully") + return nil +} + +func TestUpdateOSVMAction_run(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + checkOnly bool + updateStatus *update.Status + checkErr error + applyErr error + expectedLogCalls func(*mocks.Logger) + }{ + { + name: "check only - update available", + checkOnly: true, + updateStatus: &update.Status{ + UpdateAvailable: true, + CurrentVersion: "v1.0.0", + LatestVersion: "v1.1.0", + }, + expectedLogCalls: func(logger *mocks.Logger) { + logger.EXPECT().Info("Checking for OS updates...") + logger.EXPECT().Infof("OS update available: %s to %s", "v1.0.0", "v1.1.0") + logger.EXPECT().Info("Run 'finch vm update-os --install' to apply the update") + }, + }, + { + name: "check only - no update", + checkOnly: true, + updateStatus: &update.Status{ + UpdateAvailable: false, + CurrentVersion: "v1.1.0", + LatestVersion: "v1.1.0", + }, + expectedLogCalls: func(logger *mocks.Logger) { + logger.EXPECT().Info("Checking for OS updates...") + logger.EXPECT().Infof("OS is up to date: %s", "v1.1.0") + }, + }, + { + name: "install mode - update available", + checkOnly: false, + updateStatus: &update.Status{ + UpdateAvailable: true, + CurrentVersion: "v1.0.0", + LatestVersion: "v1.1.0", + }, + expectedLogCalls: func(logger *mocks.Logger) { + logger.EXPECT().Info("Checking for OS updates...") + logger.EXPECT().Infof("Updating OS from %s to %s...", "v1.0.0", "v1.1.0") + logger.EXPECT().Warnln("This will stop and recreate the VM. All containers and data will be lost.") + logger.EXPECT().Info("OS update completed successfully") + }, + }, + { + name: "install mode - no update", + checkOnly: false, + updateStatus: &update.Status{ + UpdateAvailable: false, + CurrentVersion: "v1.1.0", + LatestVersion: "v1.1.0", + }, + expectedLogCalls: func(logger *mocks.Logger) { + logger.EXPECT().Info("Checking for OS updates...") + logger.EXPECT().Infof("OS is already up to date: %s", "v1.1.0") + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + logger := mocks.NewLogger(ctrl) + + tc.expectedLogCalls(logger) + + action := &updateOSVMAction{ + logger: logger, + finchRootPath: "/test/path", + } + + mockUpdater := &mockUpdaterForTest{ + status: tc.updateStatus, + applyErr: tc.applyErr, + } + + var err error + if tc.checkOnly { + err = action.checkForUpdates(mockUpdater) + } else { + err = action.applyUpdate(mockUpdater) + } + + assert.NoError(t, err) + }) + } +} + +func TestUpdateOSVMAction_checkForUpdates(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + updateStatus *update.Status + updaterErr error + expectedLogCalls func(*mocks.Logger) + expectedErr error + }{ + { + name: "update available", + updateStatus: &update.Status{ + UpdateAvailable: true, + CurrentVersion: "v1.0.0", + LatestVersion: "v1.1.0", + }, + expectedLogCalls: func(logger *mocks.Logger) { + logger.EXPECT().Info("Checking for OS updates...") + logger.EXPECT().Infof("OS update available: %s to %s", "v1.0.0", "v1.1.0") + logger.EXPECT().Info("Run 'finch vm update-os --install' to apply the update") + }, + expectedErr: nil, + }, + { + name: "no update available with version", + updateStatus: &update.Status{ + UpdateAvailable: false, + CurrentVersion: "v1.1.0", + LatestVersion: "v1.1.0", + }, + expectedLogCalls: func(logger *mocks.Logger) { + logger.EXPECT().Info("Checking for OS updates...") + logger.EXPECT().Infof("OS is up to date: %s", "v1.1.0") + }, + expectedErr: nil, + }, + { + name: "no version found", + updateStatus: &update.Status{ + UpdateAvailable: false, + CurrentVersion: "", + LatestVersion: "v1.1.0", + }, + expectedLogCalls: func(logger *mocks.Logger) { + logger.EXPECT().Info("Checking for OS updates...") + logger.EXPECT().Info("No OS version found. Run 'finch vm init' first.") + }, + expectedErr: nil, + }, + { + name: "check for updates fails", + updateStatus: nil, + updaterErr: errors.New("network error"), + expectedLogCalls: func(logger *mocks.Logger) { + logger.EXPECT().Info("Checking for OS updates...") + }, + expectedErr: errors.New("failed to check for updates: network error"), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + logger := mocks.NewLogger(ctrl) + + tc.expectedLogCalls(logger) + + action := &updateOSVMAction{ + logger: logger, + finchRootPath: "/test/path", + } + + // Create a mock updater that returns our test data + mockUpdater := &mockUpdaterForTest{ + status: tc.updateStatus, + err: tc.updaterErr, + } + + err := action.checkForUpdates(mockUpdater) + if tc.expectedErr != nil { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErr.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestUpdateOSVMAction_applyUpdate(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + updateStatus *update.Status + checkErr error + applyErr error + expectedLogCalls func(*mocks.Logger) + expectedErr error + }{ + { + name: "successful update", + updateStatus: &update.Status{ + UpdateAvailable: true, + CurrentVersion: "v1.0.0", + LatestVersion: "v1.1.0", + }, + expectedLogCalls: func(logger *mocks.Logger) { + logger.EXPECT().Info("Checking for OS updates...") + logger.EXPECT().Infof("Updating OS from %s to %s...", "v1.0.0", "v1.1.0") + logger.EXPECT().Warnln("This will stop and recreate the VM. All containers and data will be lost.") + logger.EXPECT().Info("OS update completed successfully") + }, + expectedErr: nil, + }, + { + name: "no update available with version", + updateStatus: &update.Status{ + UpdateAvailable: false, + CurrentVersion: "v1.1.0", + LatestVersion: "v1.1.0", + }, + expectedLogCalls: func(logger *mocks.Logger) { + logger.EXPECT().Info("Checking for OS updates...") + logger.EXPECT().Infof("OS is already up to date: %s", "v1.1.0") + }, + expectedErr: nil, + }, + { + name: "no version found", + updateStatus: &update.Status{ + UpdateAvailable: false, + CurrentVersion: "", + LatestVersion: "v1.1.0", + }, + expectedLogCalls: func(logger *mocks.Logger) { + logger.EXPECT().Info("Checking for OS updates...") + logger.EXPECT().Info("No OS version found. Run 'finch vm init' first.") + }, + expectedErr: nil, + }, + { + name: "check for updates fails", + checkErr: errors.New("network error"), + expectedLogCalls: func(logger *mocks.Logger) { + logger.EXPECT().Info("Checking for OS updates...") + }, + expectedErr: errors.New("failed to check for updates: network error"), + }, + { + name: "apply update fails", + updateStatus: &update.Status{ + UpdateAvailable: true, + CurrentVersion: "v1.0.0", + LatestVersion: "v1.1.0", + }, + applyErr: errors.New("download failed"), + expectedLogCalls: func(logger *mocks.Logger) { + logger.EXPECT().Info("Checking for OS updates...") + logger.EXPECT().Infof("Updating OS from %s to %s...", "v1.0.0", "v1.1.0") + logger.EXPECT().Warnln("This will stop and recreate the VM. All containers and data will be lost.") + }, + expectedErr: errors.New("failed to apply OS update: download failed"), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + logger := mocks.NewLogger(ctrl) + + tc.expectedLogCalls(logger) + + action := &updateOSVMAction{ + logger: logger, + finchRootPath: "/test/path", + } + + mockUpdater := &mockUpdaterForTest{ + status: tc.updateStatus, + err: tc.checkErr, + applyErr: tc.applyErr, + } + + err := action.applyUpdate(mockUpdater) + if tc.expectedErr != nil { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErr.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} + +// mockUpdaterForTest is a simple mock for testing. +type mockUpdaterForTest struct { + status *update.Status + err error + applyErr error +} + +func (m *mockUpdaterForTest) CheckForUpdates() (*update.Status, error) { + return m.status, m.err +} + +func (m *mockUpdaterForTest) ApplyUpdate() error { + return m.applyErr +} diff --git a/cmd/finch/virtual_machine_windows.go b/cmd/finch/virtual_machine_windows.go index 601f6ed9d..2231b5c73 100644 --- a/cmd/finch/virtual_machine_windows.go +++ b/cmd/finch/virtual_machine_windows.go @@ -28,6 +28,7 @@ func newVirtualMachineCommand( fp path.Finch, fs afero.Fs, diskManager disk.UserDataDiskManager, + finchRootPath string, ) *cobra.Command { virtualMachineCommand := &cobra.Command{ Use: virtualMachineRootCmd, @@ -42,6 +43,7 @@ func newVirtualMachineCommand( newInitVMCommand(limaCmdCreator, logger, optionalDepGroups, lca, nca, fp.BaseYamlFilePath(), fs, fp.LimaSSHPrivateKeyPath(), diskManager), newSettingsVMCommand(logger, lca, fs, os.Stdout), + newUpdateOSVMCommand(logger, finchRootPath), ) return virtualMachineCommand diff --git a/pkg/update/os.go b/pkg/update/os.go new file mode 100644 index 000000000..220f89d23 --- /dev/null +++ b/pkg/update/os.go @@ -0,0 +1,514 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package update manages the download and installation process for users to update +// Finch to the newest release and for VM OS image updates +package update + +import ( + "crypto/sha512" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "gopkg.in/yaml.v3" +) + +const ( + // DependencyCloudFrontURL is the base URL for dependency downloads. + DependencyCloudFrontURL = "https://deps.runfinch.com" + // BaseOSURL is the base URL for OS artifacts manifest. + BaseOSURL = "https://artifact.runfinch.com" + "/manifest/latest-os-artifacts.json" + // RootfsURL is the base URL for rootfs artifacts manifest. + RootfsURL = "https://artifact.runfinch.com" + "/manifest/latest-rootfs-artifacts.json" +) + +// Status represents the status of an OS update check. +type Status struct { + UpdateAvailable bool `json:"updateAvailable"` + CurrentVersion string `json:"currentVersion"` + LatestVersion string `json:"latestVersion"` +} + +// OSImage represents an OS image configuration. +type OSImage struct { + Location string `yaml:"location"` + Arch string `yaml:"arch"` + Digest string `yaml:"digest"` +} + +// FinchYAML represents the structure of the finch.yaml file. +type FinchYAML struct { + Images []OSImage `yaml:"images"` +} + +// OSManifest represents the OS manifest file structure. +type OSManifest struct { + AARCH64 struct { + Filename string `json:"filename"` + SHA512Sum string `json:"sha512sum"` + } `json:"aarch64"` + X8664 struct { + Filename string `json:"filename"` + SHA512Sum string `json:"sha512sum"` + } `json:"x86_64"` +} + +// RootfsManifest represents the rootfs manifest file structure. +type RootfsManifest struct { + Filename string `json:"filename"` + SHA512Sum string `json:"sha512sum"` +} + +// OSUpdater handles OS update operations. +type OSUpdater struct { + ProjectRoot string + OutputDir string + FinchYAMLPath string + HTTPClient *http.Client +} + +// NewOSUpdater creates a new OSUpdater instance. +func NewOSUpdater(projectRoot string) *OSUpdater { + return &OSUpdater{ + ProjectRoot: projectRoot, + OutputDir: filepath.Join(projectRoot, "os"), + FinchYAMLPath: filepath.Join(projectRoot, "os", "finch.yaml"), + HTTPClient: http.DefaultClient, + } +} + +// CheckForUpdates checks if OS updates are available. +func (u *OSUpdater) CheckForUpdates() (*Status, error) { + return u.CheckForUpdatesWithOS(runtime.GOOS, runtime.GOARCH) +} + +// CheckForUpdatesWithOS checks if OS updates are available with specified OS and architecture. +func (u *OSUpdater) CheckForUpdatesWithOS(goos, goarch string) (*Status, error) { + // Get current OS version from finch.yaml + currentVersion, err := u.getCurrentOSVersion() + if err != nil { + return nil, fmt.Errorf("failed to get current OS version: %w", err) + } + + // Get the latest OS version based on platform and architecture + var latestFilename string + + if goos == "windows" { + rootfsManifest, err := u.getRootfsManifest() + if err != nil { + return nil, fmt.Errorf("failed to get rootfs manifest: %w", err) + } + latestFilename = rootfsManifest.Filename + } else { + osManifest, err := u.getOSManifest() + if err != nil { + return nil, fmt.Errorf("failed to get OS manifest: %w", err) + } + + if goarch == "arm64" { + latestFilename = osManifest.AARCH64.Filename + } else { + latestFilename = osManifest.X8664.Filename + } + } + + // Compare versions + updateAvailable := currentVersion != latestFilename && latestFilename != "" + + return &Status{ + UpdateAvailable: updateAvailable, + CurrentVersion: currentVersion, + LatestVersion: latestFilename, + }, nil +} + +// ApplyUpdate downloads and applies the OS update. +func (u *OSUpdater) ApplyUpdate() error { + return u.applyUpdateWithBinary(runtime.GOOS, runtime.GOARCH, u.getFinchBinaryPath()) +} + +// ApplyUpdateWithOS downloads and applies the OS update with specified OS and architecture. +func (u *OSUpdater) ApplyUpdateWithOS(goos, goarch string) error { + return u.applyUpdateWithBinary(goos, goarch, u.getFinchBinaryPathForOS(goos)) +} + +// getFinchBinaryPath returns the platform-specific path to the finch binary. +func (u *OSUpdater) getFinchBinaryPath() string { + return u.getFinchBinaryPathForOS(runtime.GOOS) +} + +// getFinchBinaryPathForOS returns the platform-specific path to the finch binary for a given OS. +func (u *OSUpdater) getFinchBinaryPathForOS(goos string) string { + switch goos { + case "darwin": + return "/Applications/Finch/bin/finch" + case "windows": + return "C:\\Program Files\\Finch\\bin\\finch.exe" + default: + return "finch" + } +} + +// applyUpdateWithBinary downloads and applies the OS update using a custom binary path. +func (u *OSUpdater) applyUpdateWithBinary(goos, goarch, finchBinPath string) error { + status, err := u.CheckForUpdatesWithOS(goos, goarch) + if err != nil { + return fmt.Errorf("failed to check for updates: %w", err) + } + + if !status.UpdateAvailable { + return fmt.Errorf("no OS updates available to apply") + } + + var artifactFilename, artifactShasum, arch string + + if goos == "windows" { + rootfsManifest, err := u.getRootfsManifest() + if err != nil { + return fmt.Errorf("failed to get rootfs manifest: %w", err) + } + artifactFilename = rootfsManifest.Filename + artifactShasum = rootfsManifest.SHA512Sum + arch = "x86_64" + } else { + osManifest, err := u.getOSManifest() + if err != nil { + return fmt.Errorf("failed to get OS manifest: %w", err) + } + + if goarch == "arm64" { + artifactFilename = osManifest.AARCH64.Filename + artifactShasum = osManifest.AARCH64.SHA512Sum + arch = "aarch64" + } else { + artifactFilename = osManifest.X8664.Filename + artifactShasum = osManifest.X8664.SHA512Sum + arch = "x86_64" + } + } + + if artifactShasum == "" { + return fmt.Errorf("failed to find shasum for artifact %s", artifactFilename) + } + + // Create output directory if it doesn't exist + err = os.MkdirAll(u.OutputDir, 0o750) + if err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + // Find old OS images + oldImages, err := u.findOldOSImages(artifactFilename) + if err != nil { + return fmt.Errorf("failed to find old OS images: %w", err) + } + + // Download the OS image + imageLocation := filepath.Join(u.OutputDir, artifactFilename) + err = u.downloadOSImage(DependencyCloudFrontURL, artifactFilename, artifactShasum, imageLocation) + if err != nil { + return fmt.Errorf("failed to download OS image: %w", err) + } + + // Update the finch.yaml file + err = u.updateFinchYAML(imageLocation, arch, "sha512:"+artifactShasum) + if err != nil { + return fmt.Errorf("failed to update finch.yaml: %w", err) + } + + limaDataDir := filepath.Join(u.ProjectRoot, "lima", "data", "finch") + + u.stopAndRemoveVM(finchBinPath) + + // Clean up Lima data directory + if _, err := os.Stat(limaDataDir); err == nil { + err = os.RemoveAll(limaDataDir) + if err != nil { + return fmt.Errorf("failed to clean up Lima data directory: %w", err) + } + } + + // Initialize the VM with the new OS + err = u.initVM(finchBinPath) + if err != nil { + return fmt.Errorf("failed to initialize VM: %w", err) + } + + // Remove old OS images + err = u.removeOldOSImages(oldImages) + if err != nil { + return fmt.Errorf("failed to remove old OS images: %w", err) + } + + return nil +} + +// getCurrentOSVersion gets the current OS version from finch.yaml. +func (u *OSUpdater) getCurrentOSVersion() (string, error) { + // Check if finch.yaml exists + if _, err := os.Stat(u.FinchYAMLPath); os.IsNotExist(err) { + return "", nil // No current version + } + + // Read the finch.yaml file + data, err := os.ReadFile(u.FinchYAMLPath) + if err != nil { + return "", fmt.Errorf("failed to read finch.yaml: %w", err) + } + + // Parse the YAML + var finchYAML FinchYAML + err = yaml.Unmarshal(data, &finchYAML) + if err != nil { + return "", fmt.Errorf("failed to parse finch.yaml: %w", err) + } + + // Check if there are any images + if len(finchYAML.Images) == 0 { + return "", nil + } + + // Get the current OS version + currentVersion := filepath.Base(finchYAML.Images[0].Location) + return currentVersion, nil +} + +// getOSManifest retrieves the OS manifest file in s3 through Cloudfront. +func (u *OSUpdater) getOSManifest() (*OSManifest, error) { + resp, err := u.HTTPClient.Get(BaseOSURL) + if err != nil { + return nil, fmt.Errorf("failed to get OS manifest: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get OS manifest: status code %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read OS manifest: %w", err) + } + + var manifest OSManifest + err = json.Unmarshal(body, &manifest) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal OS manifest: %w", err) + } + + return &manifest, nil +} + +// getRootfsManifest retrieves the rootfs manifest file. +func (u *OSUpdater) getRootfsManifest() (*RootfsManifest, error) { + resp, err := u.HTTPClient.Get(RootfsURL) + if err != nil { + return nil, fmt.Errorf("failed to get rootfs manifest: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get rootfs manifest: status code %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read rootfs manifest: %w", err) + } + + var manifest RootfsManifest + err = json.Unmarshal(body, &manifest) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal rootfs manifest: %w", err) + } + + return &manifest, nil +} + +// findOldOSImages finds old OS images in the output directory. +func (u *OSUpdater) findOldOSImages(currentImage string) ([]string, error) { + var oldImages []string + + entries, err := os.ReadDir(u.OutputDir) + if err != nil { + if os.IsNotExist(err) { + return oldImages, nil + } + return nil, fmt.Errorf("failed to read output directory: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if (strings.HasSuffix(name, ".qcow2") || strings.HasSuffix(name, ".tar.gz")) && name != currentImage { + oldImages = append(oldImages, filepath.Join(u.OutputDir, name)) + } + } + + return oldImages, nil +} + +// constructDownloadURL constructs the download URL based on the artifact type. +func (u *OSUpdater) constructDownloadURL(baseURL, osBasename string) string { + if strings.Contains(osBasename, "finch-rootfs") { + return fmt.Sprintf("%s/common/x86-64/%s", baseURL, osBasename) + } + return fmt.Sprintf("%s/%s", baseURL, osBasename) +} + +// downloadOSImage downloads the OS image. +func (u *OSUpdater) downloadOSImage(baseURL, osBasename, osDigest, destination string) error { + url := u.constructDownloadURL(baseURL, osBasename) + + // Create a temporary file + tempFile, err := os.CreateTemp("", "finch-os-*.qcow2") + if err != nil { + return fmt.Errorf("failed to create temporary file: %w", err) + } + defer func() { + _ = os.Remove(tempFile.Name()) + }() + defer func() { + _ = tempFile.Close() + }() + + // Download the file + resp, err := u.HTTPClient.Get(url) + if err != nil { + return fmt.Errorf("failed to download OS image: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download OS image: status code %d", resp.StatusCode) + } + + // Create a hash writer + hash := sha512.New() + writer := io.MultiWriter(tempFile, hash) + + // Copy the response body to the file and hash + _, err = io.Copy(writer, resp.Body) + if err != nil { + return fmt.Errorf("failed to write OS image: %w", err) + } + + // Verify the digest + actualDigest := hex.EncodeToString(hash.Sum(nil)) + if actualDigest != osDigest { + return fmt.Errorf("digest mismatch: expected %s, got %s", osDigest, actualDigest) + } + + // Close the temporary file + err = tempFile.Close() + if err != nil { + return fmt.Errorf("failed to close temporary file: %w", err) + } + + // Move the temporary file to the destination + err = os.Rename(tempFile.Name(), destination) + if err != nil { + return fmt.Errorf("failed to move OS image: %w", err) + } + + return nil +} + +// updateFinchYAML updates the finch.yaml file with the new OS image. +func (u *OSUpdater) updateFinchYAML(imageLocation, arch, digest string) error { + // Create a FinchYAML struct + finchYAML := FinchYAML{ + Images: []OSImage{ + { + Location: imageLocation, + Arch: arch, + Digest: digest, + }, + }, + } + + // Read the existing finch.yaml if it exists + if _, err := os.Stat(u.FinchYAMLPath); err == nil { + data, err := os.ReadFile(u.FinchYAMLPath) + if err != nil { + return fmt.Errorf("failed to read finch.yaml: %w", err) + } + + // Parse the YAML + var existingYAML map[string]interface{} + err = yaml.Unmarshal(data, &existingYAML) + if err != nil { + return fmt.Errorf("failed to parse finch.yaml: %w", err) + } + + // Update only the images section + existingYAML["images"] = finchYAML.Images + + // Marshal the updated YAML + data, err = yaml.Marshal(existingYAML) + if err != nil { + return fmt.Errorf("failed to marshal finch.yaml: %w", err) + } + + // Write the updated YAML + return os.WriteFile(u.FinchYAMLPath, data, 0o644) + } + + // If the file doesn't exist, create a new one + data, err := yaml.Marshal(finchYAML) + if err != nil { + return fmt.Errorf("failed to marshal finch.yaml: %w", err) + } + + return os.WriteFile(u.FinchYAMLPath, data, 0o644) +} + +// stopAndRemoveVM stops and removes the VM. +func (u *OSUpdater) stopAndRemoveVM(finchBinPath string) { + // Stop the VM + stopCmd := exec.Command(finchBinPath, "vm", "stop") + _ = stopCmd.Run() // Ignore error as VM might not be running + + // Remove the VM + removeCmd := exec.Command(finchBinPath, "vm", "remove", "--force") + _ = removeCmd.Run() // Ignore error as VM might not exist +} + +// initVM initializes the VM. +func (u *OSUpdater) initVM(finchBinPath string) error { + initCmd := exec.Command(finchBinPath, "vm", "init") + err := initCmd.Run() + if err != nil { + return fmt.Errorf("failed to initialize VM: %w", err) + } + + return nil +} + +// removeOldOSImages removes old OS images. +func (u *OSUpdater) removeOldOSImages(oldImages []string) error { + for _, image := range oldImages { + err := os.Remove(image) + if err != nil { + return fmt.Errorf("failed to remove old OS image %s: %w", image, err) + } + } + + return nil +} diff --git a/pkg/update/os_test.go b/pkg/update/os_test.go new file mode 100644 index 000000000..051888ad6 --- /dev/null +++ b/pkg/update/os_test.go @@ -0,0 +1,565 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package update + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +const testDigest = "f43db43b09b8139332e9320c0d7ccfaed8e10a1c04baefa50ad8f6bb23d237957" + + "b6effa6f10783ecf717b4db6aff4938ff48e75e1a858370de046a9be1005872" + +type mockTransport struct { + failOSManifest, failRootfsManifest, failDownload bool + invalidOSJSON, invalidRootfsJSON, serverError bool +} + +func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { + url := req.URL.String() + + if m.failOSManifest && url == BaseOSURL { + return nil, fmt.Errorf("network error") + } + if m.failRootfsManifest && url == RootfsURL { + return nil, fmt.Errorf("network error") + } + if m.failDownload && strings.Contains(url, DependencyCloudFrontURL) { + return nil, fmt.Errorf("download error") + } + if m.serverError { + return &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(strings.NewReader("Server Error"))}, nil + } + + switch url { + case BaseOSURL: + if m.invalidOSJSON { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("invalid"))}, nil + } + manifest := OSManifest{ + AARCH64: struct { + Filename string `json:"filename"` + SHA512Sum string `json:"sha512sum"` + }{"test-aarch64.qcow2", testDigest}, + X8664: struct { + Filename string `json:"filename"` + SHA512Sum string `json:"sha512sum"` + }{"test-x86_64.qcow2", testDigest}, + } + data, _ := json.Marshal(manifest) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(string(data)))}, nil + case RootfsURL: + if m.invalidRootfsJSON { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("invalid"))}, nil + } + manifest := RootfsManifest{Filename: "test-rootfs.tar.zst", SHA512Sum: testDigest} + data, _ := json.Marshal(manifest) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(string(data)))}, nil + default: + if strings.Contains(url, DependencyCloudFrontURL) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("testshasum"))}, nil + } + return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader("Not found"))}, nil + } +} + +func setupMockHTTP(opts ...func(*mockTransport)) *http.Client { + m := &mockTransport{} + for _, opt := range opts { + opt(m) + } + return &http.Client{Transport: m} +} + +func setupTest(t *testing.T, subdirs ...string) (string, func()) { + tempDir, err := os.MkdirTemp("", "osupdate-test") + if err != nil { + t.Fatal(err) + } + for _, subdir := range subdirs { + if err := os.MkdirAll(filepath.Join(tempDir, subdir), 0o750); err != nil { + _ = os.RemoveAll(tempDir) + t.Fatal(err) + } + } + return tempDir, func() { _ = os.RemoveAll(tempDir) } +} + +func createTestUpdater(tempDir string, httpClient *http.Client) *OSUpdater { + outputDir := filepath.Join(tempDir, "_output", "os") + return &OSUpdater{ + ProjectRoot: tempDir, + OutputDir: outputDir, + FinchYAMLPath: filepath.Join(outputDir, "finch.yaml"), + HTTPClient: httpClient, + } +} + +func createFinchYAML(t *testing.T, path, imagePath string) { + finchYAML := FinchYAML{Images: []OSImage{{Location: imagePath, Arch: "x86_64", Digest: "sha512:abcdef"}}} + data, _ := yaml.Marshal(finchYAML) + _ = os.MkdirAll(filepath.Dir(path), 0o750) + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } +} + +func createMockBinary(_ *testing.T, dir string, exitCode int, _ string) string { + var path string + var content string + + if runtime.GOOS == "windows" { + // On Windows host, create a .bat file + path = filepath.Join(dir, "finch.bat") + content = fmt.Sprintf("@echo off\nexit /b %d\n", exitCode) + } else { + // On Unix-like host systems, create a shell script + path = filepath.Join(dir, "finch") + content = fmt.Sprintf("#!/bin/sh\nexit %d\n", exitCode) + } + + _ = os.WriteFile(path, []byte(content), 0o644) + _ = os.Chmod(path, 0o755) // #nosec G302 -- executable permissions required for mock binary + return path +} + +func TestNewOSUpdater(t *testing.T) { + t.Parallel() + updater := NewOSUpdater("/test/root") + if updater.ProjectRoot != "/test/root" { + t.Errorf("Expected ProjectRoot /test/root, got %s", updater.ProjectRoot) + } + expectedSuffix := filepath.Join("", "os") + if !strings.HasSuffix(updater.OutputDir, expectedSuffix) { + t.Errorf("Expected OutputDir to end with %s, got %s", expectedSuffix, updater.OutputDir) + } +} + +func TestGetCurrentOSVersion(t *testing.T) { + t.Parallel() + tests := []struct { + name string + setup func(string, string) + expect string + err bool + }{ + {"missing file", func(p, _ string) { _ = os.Remove(p) }, "", false}, + {"with image", func(p, d string) { createFinchYAML(t, p, filepath.Join(d, "test.qcow2")) }, "test.qcow2", false}, + {"empty images", func(p, _ string) { + data, _ := yaml.Marshal(FinchYAML{Images: []OSImage{}}) + _ = os.WriteFile(p, data, 0o644) + }, "", false}, + {"invalid yaml", func(p, _ string) { _ = os.WriteFile(p, []byte("invalid"), 0o644) }, "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tempDir, cleanup := setupTest(t, "_output/os") + defer cleanup() + + updater := createTestUpdater(tempDir, http.DefaultClient) + tt.setup(updater.FinchYAMLPath, updater.OutputDir) + + result, err := updater.getCurrentOSVersion() + if (err != nil) != tt.err { + t.Errorf("Expected error %v, got %v", tt.err, err) + } + if result != tt.expect { + t.Errorf("Expected %q, got %q", tt.expect, result) + } + }) + } +} + +func TestFindOldOSImages(t *testing.T) { + t.Parallel() + tempDir, cleanup := setupTest(t) + defer cleanup() + + files := []string{"old1.qcow2", "old2.qcow2", "current.qcow2", "other.txt"} + for _, f := range files { + _ = os.WriteFile(filepath.Join(tempDir, f), []byte("test"), 0o644) + } + + updater := &OSUpdater{OutputDir: tempDir} + oldImages, err := updater.findOldOSImages("current.qcow2") + if err != nil { + t.Fatal(err) + } + if len(oldImages) != 2 { + t.Errorf("Expected 2 old images, got %d", len(oldImages)) + } +} + +func TestUpdateFinchYAML(t *testing.T) { + t.Parallel() + tests := []struct { + name string + setup func(string) + err bool + }{ + {"new file", func(p string) { _ = os.Remove(p) }, false}, + {"existing file", func(p string) { + data, _ := yaml.Marshal(map[string]interface{}{"images": []interface{}{}, "extra": "value"}) + _ = os.WriteFile(p, data, 0o644) + }, false}, + {"invalid yaml", func(p string) { _ = os.WriteFile(p, []byte("invalid"), 0o644) }, true}, + {"write error", func(p string) { _ = os.Remove(p); _ = os.Mkdir(p, 0o750) }, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tempDir, cleanup := setupTest(t, "_output/os") + defer cleanup() + + updater := createTestUpdater(tempDir, nil) + tt.setup(updater.FinchYAMLPath) + + err := updater.updateFinchYAML(filepath.Join(updater.OutputDir, "test.qcow2"), "x86_64", "sha512:test") + if (err != nil) != tt.err { + t.Errorf("Expected error %v, got %v", tt.err, err) + } + }) + } +} + +func TestRemoveOldOSImages(t *testing.T) { + t.Parallel() + tests := []struct { + name string + files []string + remove []string + err bool + }{ + {"remove existing", []string{"a.qcow2", "b.qcow2"}, []string{"a.qcow2", "b.qcow2"}, false}, + {"empty list", []string{}, []string{}, false}, + {"missing file", []string{}, []string{"missing.qcow2"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tempDir, cleanup := setupTest(t) + defer cleanup() + + updater := &OSUpdater{OutputDir: tempDir} + + for _, f := range tt.files { + _ = os.WriteFile(filepath.Join(tempDir, f), []byte("test"), 0o644) + } + + var paths []string + for _, f := range tt.remove { + paths = append(paths, filepath.Join(tempDir, f)) + } + + err := updater.removeOldOSImages(paths) + if (err != nil) != tt.err { + t.Errorf("Expected error %v, got %v", tt.err, err) + } + }) + } +} + +func TestGetManifests(t *testing.T) { + t.Parallel() + tests := []struct { + name string + setup func(*mockTransport) + osTest bool + err bool + }{ + {"OS success", func(_ *mockTransport) {}, true, false}, + {"rootfs success", func(_ *mockTransport) {}, false, false}, + {"OS network error", func(m *mockTransport) { m.failOSManifest = true }, true, true}, + {"rootfs network error", func(m *mockTransport) { m.failRootfsManifest = true }, false, true}, + {"server error", func(m *mockTransport) { m.serverError = true }, true, true}, + {"invalid OS JSON", func(m *mockTransport) { m.invalidOSJSON = true }, true, true}, + {"invalid rootfs JSON", func(m *mockTransport) { m.invalidRootfsJSON = true }, false, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + updater := &OSUpdater{HTTPClient: setupMockHTTP(tt.setup)} + + if tt.osTest { + _, err := updater.getOSManifest() + if (err != nil) != tt.err { + t.Errorf("Expected error %v, got %v", tt.err, err) + } + } else { + _, err := updater.getRootfsManifest() + if (err != nil) != tt.err { + t.Errorf("Expected error %v, got %v", tt.err, err) + } + } + }) + } +} + +func TestCheckForUpdates(t *testing.T) { + t.Parallel() + tests := []struct { + name string + goos string + goarch string + setup func(*mockTransport) + expected string + err bool + }{ + {"darwin amd64", "darwin", "amd64", func(_ *mockTransport) {}, "test-x86_64.qcow2", false}, + {"darwin arm64", "darwin", "arm64", func(_ *mockTransport) {}, "test-aarch64.qcow2", false}, + {"windows amd64", "windows", "amd64", func(_ *mockTransport) {}, "test-rootfs.tar.zst", false}, + {"OS error", "darwin", "amd64", func(m *mockTransport) { m.failOSManifest = true }, "", true}, + {"rootfs error", "windows", "amd64", func(m *mockTransport) { m.failRootfsManifest = true }, "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tempDir, cleanup := setupTest(t, "_output/os") + defer cleanup() + + updater := createTestUpdater(tempDir, setupMockHTTP(tt.setup)) + createFinchYAML(t, updater.FinchYAMLPath, filepath.Join(updater.OutputDir, "current.qcow2")) + + status, err := updater.CheckForUpdatesWithOS(tt.goos, tt.goarch) + if (err != nil) != tt.err { + t.Errorf("Expected error %v, got %v", tt.err, err) + } + if !tt.err && status.LatestVersion != tt.expected { + t.Errorf("Expected %s, got %s", tt.expected, status.LatestVersion) + } + }) + } + + t.Run("runtime values", func(t *testing.T) { + tempDir, cleanup := setupTest(t, "_output/os") + defer cleanup() + + updater := createTestUpdater(tempDir, setupMockHTTP()) + createFinchYAML(t, updater.FinchYAMLPath, filepath.Join(updater.OutputDir, "current.qcow2")) + + if _, err := updater.CheckForUpdates(); err != nil { + t.Error(err) + } + }) +} + +func TestVMOperations(t *testing.T) { + t.Parallel() + tests := []struct { + name string + exitCode int + operation string + err bool + }{ + {"stop success", 0, "stop", false}, + {"init success", 0, "init", false}, + {"init failure", 1, "init", true}, + } + + updater := &OSUpdater{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tempDir, cleanup := setupTest(t) + defer cleanup() + + mockPath := createMockBinary(t, tempDir, tt.exitCode, runtime.GOOS) + + var err error + if tt.operation == "stop" { + updater.stopAndRemoveVM(mockPath) + } else { + err = updater.initVM(mockPath) + } + + if (err != nil) != tt.err { + t.Errorf("Expected error %v, got %v", tt.err, err) + } + }) + } +} + +func TestDownloadOSImage(t *testing.T) { + t.Parallel() + tests := []struct { + name string + setup func(*mockTransport) + digest string + dest string + err bool + }{ + {"valid", func(_ *mockTransport) {}, testDigest, "test.qcow2", false}, + {"invalid digest", func(_ *mockTransport) {}, "invalid", "test.qcow2", true}, + {"network error", func(m *mockTransport) { m.failDownload = true }, testDigest, "test.qcow2", true}, + {"server error", func(m *mockTransport) { m.serverError = true }, testDigest, "test.qcow2", true}, + {"invalid dest", func(_ *mockTransport) {}, testDigest, "bad/test.qcow2", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + tempDir, cleanup := setupTest(t) + defer cleanup() + + updater := &OSUpdater{OutputDir: tempDir, HTTPClient: setupMockHTTP(tt.setup)} + dest := filepath.Join(tempDir, tt.dest) + if tt.name == "invalid dest" { + dest = filepath.Join(tempDir, "nonexistent", tt.dest) + } + + err := updater.downloadOSImage(DependencyCloudFrontURL, "test.qcow2", tt.digest, dest) + if (err != nil) != tt.err { + t.Errorf("Expected error %v, got %v", tt.err, err) + } + }) + } +} + +func TestConstructDownloadURL(t *testing.T) { + t.Parallel() + tests := []struct { + name string + baseURL string + osBasename string + expected string + }{ + { + name: "macOS baseOS artifact", + baseURL: "https://deps.runfinch.com", + osBasename: "finch-os-amd64-123456789.qcow2", + expected: "https://deps.runfinch.com/finch-os-amd64-123456789.qcow2", + }, + { + name: "Windows rootfs artifact", + baseURL: "https://deps.runfinch.com", + osBasename: "finch-rootfs-production-amd64-123456789.tar.zst", + expected: "https://deps.runfinch.com/common/x86-64/finch-rootfs-production-amd64-123456789.tar.zst", + }, + { + name: "Another Windows rootfs artifact", + baseURL: "https://deps.runfinch.com", + osBasename: "finch-rootfs-production-amd64-987654321.tar.zst", + expected: "https://deps.runfinch.com/common/x86-64/finch-rootfs-production-amd64-987654321.tar.zst", + }, + { + name: "macOS arm64 baseOS artifact", + baseURL: "https://deps.runfinch.com", + osBasename: "finch-os-arm64-123456789.qcow2", + expected: "https://deps.runfinch.com/finch-os-arm64-123456789.qcow2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + updater := &OSUpdater{} + result := updater.constructDownloadURL(tt.baseURL, tt.osBasename) + if result != tt.expected { + t.Errorf("Expected %s, got %s", tt.expected, result) + } + }) + } +} + +func TestApplyUpdate(t *testing.T) { + t.Parallel() + tests := []struct { + name string + goos string + goarch string + expected string + arch string + }{ + {"darwin-amd64", "darwin", "amd64", "test-x86_64.qcow2", "x86_64"}, + {"darwin-arm64", "darwin", "arm64", "test-aarch64.qcow2", "aarch64"}, + {"windows-amd64", "windows", "amd64", "test-rootfs.tar.zst", "x86_64"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tempDir, cleanup := setupTest(t, "_output/os", "_output/bin") + defer cleanup() + + updater := createTestUpdater(tempDir, setupMockHTTP()) + + // Create mock binary + binDir := filepath.Join(tempDir, "bin") + _ = os.MkdirAll(binDir, 0o750) + mockBinPath := createMockBinary(t, binDir, 0, tt.goos) + + currentPath := filepath.Join(updater.OutputDir, "current.qcow2") + _ = os.WriteFile(currentPath, []byte("content"), 0o644) + createFinchYAML(t, updater.FinchYAMLPath, currentPath) + + // Apply update with custom binary path + if err := updater.applyUpdateWithBinary(tt.goos, tt.goarch, mockBinPath); err != nil { + t.Error(err) + } + + newPath := filepath.Join(updater.OutputDir, tt.expected) + if _, err := os.Stat(newPath); os.IsNotExist(err) { + t.Errorf("New image not found: %s", newPath) + } + if _, err := os.Stat(currentPath); !os.IsNotExist(err) { + t.Error("Old image not removed") + } + }) + } + + errorTests := []struct { + name string + setup func(*testing.T) (*OSUpdater, func()) + }{ + {"no updates", func(t *testing.T) (*OSUpdater, func()) { + tempDir, cleanup := setupTest(t, "_output/os") + updater := createTestUpdater(tempDir, setupMockHTTP()) + currentPath := filepath.Join(updater.OutputDir, "test-x86_64.qcow2") + _ = os.WriteFile(currentPath, []byte("content"), 0o644) + createFinchYAML(t, updater.FinchYAMLPath, currentPath) + return updater, cleanup + }}, + {"manifest error", func(t *testing.T) (*OSUpdater, func()) { + tempDir, cleanup := setupTest(t, "_output/os") + updater := createTestUpdater(tempDir, setupMockHTTP(func(m *mockTransport) { m.failOSManifest = true })) + return updater, cleanup + }}, + {"download error", func(t *testing.T) (*OSUpdater, func()) { + tempDir, cleanup := setupTest(t, "_output/os") + updater := createTestUpdater(tempDir, setupMockHTTP(func(m *mockTransport) { m.failDownload = true })) + currentPath := filepath.Join(updater.OutputDir, "current.qcow2") + _ = os.WriteFile(currentPath, []byte("content"), 0o644) + createFinchYAML(t, updater.FinchYAMLPath, currentPath) + return updater, cleanup + }}, + } + + for _, tt := range errorTests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + updater, cleanup := tt.setup(t) + defer cleanup() + + if err := updater.ApplyUpdateWithOS("darwin", "amd64"); err == nil { + t.Error("Expected error, got nil") + } + }) + } +}