diff --git a/cmd/arduino-app-cli/daemon/daemon.go b/cmd/arduino-app-cli/daemon/daemon.go index eeac105c5..32fc7f50e 100644 --- a/cmd/arduino-app-cli/daemon/daemon.go +++ b/cmd/arduino-app-cli/daemon/daemon.go @@ -102,7 +102,7 @@ func httpHandler(ctx context.Context, cfg config.Configuration, daemonPort, vers version, update.NewManager( apt.New(), - arduino.NewArduinoPlatformUpdater(), + arduino.NewArduinoPlatformUpdater(cfg.VersionConstraint), ), servicelocator.GetProvisioner(), servicelocator.GetStaticStore(), diff --git a/cmd/arduino-app-cli/system/system.go b/cmd/arduino-app-cli/system/system.go index c23d5317e..7cf531303 100644 --- a/cmd/arduino-app-cli/system/system.go +++ b/cmd/arduino-app-cli/system/system.go @@ -22,6 +22,7 @@ import ( "github.com/docker/cli/cli/command" "github.com/spf13/cobra" + semver "go.bug.st/relaxed-semver" "github.com/arduino/arduino-app-cli/cmd/arduino-app-cli/internal/servicelocator" "github.com/arduino/arduino-app-cli/cmd/feedback" @@ -42,7 +43,7 @@ func NewSystemCmd(cfg config.Configuration) *cobra.Command { } cmd.AddCommand(newDownloadImageCmd(cfg)) - cmd.AddCommand(newUpdateCmd()) + cmd.AddCommand(newUpdateCmd(cfg)) cmd.AddCommand(newCleanUpCmd(cfg, servicelocator.GetDockerClient())) cmd.AddCommand(newNetworkModeCmd()) cmd.AddCommand(newKeyboardSetCmd()) @@ -64,7 +65,7 @@ func newDownloadImageCmd(cfg config.Configuration) *cobra.Command { return cmd } -func newUpdateCmd() *cobra.Command { +func newUpdateCmd(cfg config.Configuration) *cobra.Command { var onlyArduino bool var forceYes bool cmd := &cobra.Command{ @@ -74,7 +75,7 @@ func newUpdateCmd() *cobra.Command { RunE: func(cmd *cobra.Command, _ []string) error { filterFunc := getFilterFunc(onlyArduino) - updater := getUpdater() + updater := getUpdater(cfg.VersionConstraint) pkgs, err := updater.ListUpgradablePackages(cmd.Context(), filterFunc) if err != nil { @@ -135,10 +136,10 @@ func newUpdateCmd() *cobra.Command { return cmd } -func getUpdater() *update.Manager { +func getUpdater(versionConstraint semver.Constraint) *update.Manager { return update.NewManager( apt.New(), - arduino.NewArduinoPlatformUpdater(), + arduino.NewArduinoPlatformUpdater(versionConstraint), ) } diff --git a/internal/api/api.go b/internal/api/api.go index 65c667712..469df57a2 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -63,7 +63,7 @@ func NewHTTPRouter( mux.Handle("PUT /v1/properties/{key}", handlers.HandlePropertyUpsert(cfg)) mux.Handle("DELETE /v1/properties/{key}", handlers.HandlePropertyDelete(cfg)) - mux.Handle("GET /v1/system/update/check", handlers.HandleCheckUpgradable(updater)) + mux.Handle("GET /v1/system/update/check", handlers.HandleCheckUpgradable(cfg, updater)) mux.Handle("GET /v1/system/update/events", handlers.HandleUpdateEvents(updater)) mux.Handle("PUT /v1/system/update/apply", handlers.HandleUpdateApply(updater)) mux.Handle("GET /v1/system/resources", handlers.HandleSystemResources()) diff --git a/internal/api/handlers/update.go b/internal/api/handlers/update.go index 49ec1b294..bd490d650 100644 --- a/internal/api/handlers/update.go +++ b/internal/api/handlers/update.go @@ -22,11 +22,12 @@ import ( "log/slog" "github.com/arduino/arduino-app-cli/internal/api/models" + "github.com/arduino/arduino-app-cli/internal/orchestrator/config" "github.com/arduino/arduino-app-cli/internal/render" "github.com/arduino/arduino-app-cli/internal/update" ) -func HandleCheckUpgradable(updater *update.Manager) http.HandlerFunc { +func HandleCheckUpgradable(cfg config.Configuration, updater *update.Manager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { queryParams := r.URL.Query() diff --git a/internal/orchestrator/config/config.go b/internal/orchestrator/config/config.go index 16ce6a98d..d7a61f5b2 100644 --- a/internal/orchestrator/config/config.go +++ b/internal/orchestrator/config/config.go @@ -16,6 +16,7 @@ package config import ( + "cmp" "fmt" "log/slog" "net/url" @@ -25,6 +26,7 @@ import ( "strings" "github.com/arduino/go-paths-helper" + semver "go.bug.st/relaxed-semver" ) // runnerVersion do not edit, this is generate with `task generate:assets` @@ -40,6 +42,7 @@ type Configuration struct { RunnerVersion string AllowRoot bool LibrariesAPIURL *url.URL + VersionConstraint semver.Constraint } func NewFromEnv() (Configuration, error) { @@ -106,6 +109,14 @@ func NewFromEnv() (Configuration, error) { return Configuration{}, fmt.Errorf("invalid LIBRARIES_API_URL: %w", err) } + constraintStr := cmp.Or(os.Getenv("ARDUINO_APP_CLI__PLATFORM_VERSION_CONSTRAINT"), "<1.0.0") + + constraint, err := semver.ParseConstraint(constraintStr) + if err != nil { + return Configuration{}, fmt.Errorf("invalid version constraint: %w", err) + } + slog.Debug("Using update version constraint", slog.String("constraint", constraintStr)) + c := Configuration{ appsDir: appsDir, dataDir: dataDir, @@ -116,6 +127,7 @@ func NewFromEnv() (Configuration, error) { RunnerVersion: runnerVersion, AllowRoot: allowRoot, LibrariesAPIURL: parsedLibrariesURL, + VersionConstraint: constraint, } if err := c.init(); err != nil { return Configuration{}, err diff --git a/internal/update/apt/service.go b/internal/update/apt/service.go index e9192451d..1dec4ab6e 100644 --- a/internal/update/apt/service.go +++ b/internal/update/apt/service.go @@ -73,7 +73,7 @@ func (s *Service) ListUpgradablePackages(ctx context.Context, matcher func(updat // UpgradePackages upgrades the specified packages using the `apt-get upgrade` command. // It publishes events to subscribers during the upgrade process. // It returns an error if the upgrade is already in progress or if the upgrade command fails. -func (s *Service) UpgradePackages(ctx context.Context, names []string, eventCB update.EventCallback) error { +func (s *Service) UpgradePackages(ctx context.Context, packages []update.PackageInfo, eventCB update.EventCallback) error { if !s.lock.TryLock() { return update.ErrOperationAlreadyInProgress } @@ -91,7 +91,10 @@ func (s *Service) UpgradePackages(ctx context.Context, names []string, eventCB u return } }() - + names := make([]string, 0, len(packages)) + for _, pkg := range packages { + names = append(names, pkg.Name) + } eventCB(update.NewDataEvent(update.StartEvent, "Upgrade is starting")) stream := runUpgradeCommand(ctx, names) for line, err := range stream { diff --git a/internal/update/arduino/arduino.go b/internal/update/arduino/arduino.go index ed53f953e..27fff5990 100644 --- a/internal/update/arduino/arduino.go +++ b/internal/update/arduino/arduino.go @@ -17,15 +17,16 @@ package arduino import ( "context" - "errors" "fmt" "log/slog" + "slices" + "strings" "sync" "github.com/arduino/arduino-cli/commands" - "github.com/arduino/arduino-cli/commands/cmderrors" rpc "github.com/arduino/arduino-cli/rpc/cc/arduino/cli/commands/v1" "github.com/sirupsen/logrus" + semver "go.bug.st/relaxed-semver" "github.com/arduino/arduino-app-cli/internal/helpers" "github.com/arduino/arduino-app-cli/internal/orchestrator" @@ -33,11 +34,14 @@ import ( ) type ArduinoPlatformUpdater struct { - lock sync.Mutex + lock sync.Mutex + constraint semver.Constraint } -func NewArduinoPlatformUpdater() *ArduinoPlatformUpdater { - return &ArduinoPlatformUpdater{} +func NewArduinoPlatformUpdater(versionConstraint semver.Constraint) *ArduinoPlatformUpdater { + return &ArduinoPlatformUpdater{ + constraint: versionConstraint, + } } func setConfig(ctx context.Context, srv rpc.ArduinoCoreServiceServer) error { @@ -121,20 +125,66 @@ func (a *ArduinoPlatformUpdater) ListUpgradablePackages(ctx context.Context, _ f return nil, nil // No platform found } - if platformSummary.GetLatestVersion() == platformSummary.GetInstalledVersion() { - return nil, nil // No update available + installedV, err := semver.Parse(platformSummary.GetInstalledVersion()) + if err != nil { + return nil, fmt.Errorf("invalid installed version '%s': %w", platformSummary.GetInstalledVersion(), err) + } + + availableReleases := make([]string, 0, len(platformSummary.GetReleases())) + for k := range platformSummary.GetReleases() { + availableReleases = append(availableReleases, k) + } + + bestVersion := selectBestVersion(availableReleases, installedV, a.constraint) + + if bestVersion == nil { + return nil, nil + } + + if bestVersion.Equal(installedV) { + return nil, nil } return []update.UpgradablePackage{{ Type: update.Arduino, Name: "arduino:zephyr", FromVersion: platformSummary.GetInstalledVersion(), - ToVersion: platformSummary.GetLatestVersion(), + ToVersion: bestVersion.String(), }}, nil } +func selectBestVersion(available []string, installed *semver.Version, constraint semver.Constraint) *semver.Version { + candidates := make([]*semver.Version, 0, len(available)) + + for _, verStr := range available { + v, err := semver.Parse(verStr) + if err != nil { + continue + } + + if !constraint.Match(v) { + continue + } + if installed != nil && v.LessThan(installed) { + continue + } + + candidates = append(candidates, v) + } + + if len(candidates) == 0 { + return nil + } + + slices.SortFunc(candidates, func(a, b *semver.Version) int { + return a.CompareTo(b) + }) + + return candidates[len(candidates)-1] +} + // UpgradePackages implements ServiceUpdater. -func (a *ArduinoPlatformUpdater) UpgradePackages(ctx context.Context, names []string, eventCB update.EventCallback) error { +func (a *ArduinoPlatformUpdater) UpgradePackages(ctx context.Context, packages []update.PackageInfo, eventCB update.EventCallback) error { if !a.lock.TryLock() { return update.ErrOperationAlreadyInProgress } @@ -185,49 +235,43 @@ func (a *ArduinoPlatformUpdater) UpgradePackages(ctx context.Context, names []st } } - stream, respCB := commands.PlatformUpgradeStreamResponseToCallbackFunction( + stream := commands.PlatformInstallStreamResponseToCallbackFunction( ctx, downloadProgressCB, taskProgressCB, ) - if err := srv.PlatformUpgrade( - &rpc.PlatformUpgradeRequest{ - Instance: inst, - PlatformPackage: "arduino", - Architecture: "zephyr", - SkipPostInstall: false, - SkipPreUninstall: false, + + if len(packages) == 0 { + return nil + } + + targetVersion := packages[0].ToVersion + name := packages[0].Name + + if targetVersion == "" { + if len(packages) > 0 { + return fmt.Errorf("no package of type '%s' found in the upgrade request", name) + } + return fmt.Errorf("package list is empty") + } + + parts := strings.Split(name, ":") + if len(parts) != 2 { + return fmt.Errorf("invalid package name") + } + + platformPackage := parts[0] + architecture := parts[1] + if err := srv.PlatformInstall( + &rpc.PlatformInstallRequest{ + Instance: inst, + PlatformPackage: platformPackage, + Architecture: architecture, + Version: targetVersion, }, stream, ); err != nil { - var alreadyPresent *cmderrors.PlatformAlreadyAtTheLatestVersionError - if errors.As(err, &alreadyPresent) { - eventCB(update.NewDataEvent(update.UpgradeLineEvent, alreadyPresent.Error())) - return nil - } - - var notFound *cmderrors.PlatformNotFoundError - if !errors.As(err, ¬Found) { - return fmt.Errorf("error upgrading platform: %w", err) - } - // If the platform is not found, we will try to install it - err := srv.PlatformInstall( - &rpc.PlatformInstallRequest{ - Instance: inst, - PlatformPackage: "arduino", - Architecture: "zephyr", - }, - commands.PlatformInstallStreamResponseToCallbackFunction( - ctx, - downloadProgressCB, - taskProgressCB, - ), - ) - if err != nil { - return fmt.Errorf("error installing platform: %w", err) - } - } else if respCB().GetPlatform() == nil { - return fmt.Errorf("platform upgrade failed") + return fmt.Errorf("error installing platform version %s: %w", targetVersion, err) } cbw := orchestrator.NewCallbackWriter(func(line string) { diff --git a/internal/update/arduino/arduino_test.go b/internal/update/arduino/arduino_test.go new file mode 100644 index 000000000..46bc3b12b --- /dev/null +++ b/internal/update/arduino/arduino_test.go @@ -0,0 +1,126 @@ +// This file is part of arduino-app-cli. +// +// Copyright 2025 ARDUINO SA (http://www.arduino.cc/) +// +// This software is released under the GNU General Public License version 3, +// which covers the main part of arduino-app-cli. +// The terms of this license can be found at: +// https://www.gnu.org/licenses/gpl-3.0.en.html +// +// You can be released from the requirements of the above licenses by purchasing +// a commercial license. Buying such a license is mandatory if you want to +// modify or otherwise use the software for commercial activities involving the +// Arduino software without disclosing the source code of your own applications. +// To purchase a commercial license, send an email to license@arduino.cc. + +package arduino + +import ( + "testing" + + "github.com/stretchr/testify/require" + + semver "go.bug.st/relaxed-semver" +) + +func TestSelectBestVersion(t *testing.T) { + tests := []struct { + name string + available []string + installed string + constraint string + expectedVer string + expectNil bool + }{ + { + name: "Standard upgrade within constraint", + available: []string{"1.0.0", "1.1.0", "1.2.0"}, + installed: "1.0.0", + constraint: "^1.0.0", + expectedVer: "1.2.0", + expectNil: false, + }, + { + name: "Upgrade available but blocked by constraint", + available: []string{"2.0.0"}, + installed: "1.0.0", + constraint: "^1.0.0", + expectedVer: "", + expectNil: true, + }, + { + name: "Major upgrade allowed by constraint (<3.0.0)", + available: []string{"2.0.0"}, + installed: "1.0.0", + constraint: "<3.0.0", + expectedVer: "2.0.0", + expectNil: false, + }, + { + name: "Sorts correctly mixed versions", + available: []string{"1.5.0", "1.1.0", "1.9.0", "1.2.0"}, + installed: "1.0.0", + constraint: "^1.0.0", + expectedVer: "1.9.0", + expectNil: false, + }, + { + name: "Ignores older versions", + available: []string{"0.9.0", "0.8.0"}, + installed: "1.0.0", + constraint: "^1.0.0", + expectedVer: "", + expectNil: true, + }, + { + name: "Ignores invalid strings", + available: []string{"1.1.0", "not-a-version", "invalid"}, + installed: "1.0.0", + constraint: "^1.0.0", + expectedVer: "1.1.0", + expectNil: false, + }, + { + name: "Empty available list returns nil", + available: []string{}, + installed: "1.0.0", + constraint: "^1.0.0", + expectedVer: "", + expectNil: true, + }, + { + name: "Includes installed version if present in available", + available: []string{"1.0.0"}, + installed: "1.0.0", + constraint: "^1.0.0", + expectedVer: "1.0.0", + expectNil: false, + }, + { + name: "No upgrade found (all available are older)", + available: []string{"1.0.0", "1.1.0"}, + installed: "1.5.0", + constraint: "^1.0.0", + expectedVer: "", + expectNil: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + installedV, err := semver.Parse(tt.installed) + require.NoError(t, err, "Setup: failed to parse installed version") + + constraint, err := semver.ParseConstraint(tt.constraint) + require.NoError(t, err, "Setup: failed to parse constraint") + + got := selectBestVersion(tt.available, installedV, constraint) + + if tt.expectNil { + require.Nil(t, got, "Expected result to be nil") + } else { + require.NotNil(t, got, "Expected result not to be nil") + require.Equal(t, tt.expectedVer, got.String(), "Selected version mismatch") + } + }) + } +} diff --git a/internal/update/update.go b/internal/update/update.go index bf29ce63d..76e39e2d1 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -44,9 +44,14 @@ type UpgradablePackage struct { ToVersion string `json:"to_version"` } +type PackageInfo struct { + Name string + ToVersion string +} + type ServiceUpdater interface { ListUpgradablePackages(ctx context.Context, matcher func(UpgradablePackage) bool) ([]UpgradablePackage, error) - UpgradePackages(ctx context.Context, names []string, eventCB EventCallback) error + UpgradePackages(ctx context.Context, packages []PackageInfo, eventCB EventCallback) error } type Manager struct { @@ -116,14 +121,14 @@ func (m *Manager) UpgradePackages(ctx context.Context, pkgs []UpgradablePackage) return ErrOperationAlreadyInProgress } ctx = context.WithoutCancel(ctx) - var debPkgs []string - var arduinoPlatform []string + var debPkgs []PackageInfo + var arduinoPlatform []PackageInfo for _, v := range pkgs { switch v.Type { case Arduino: - arduinoPlatform = append(arduinoPlatform, v.Name) + arduinoPlatform = append(arduinoPlatform, PackageInfo{Name: v.Name, ToVersion: v.ToVersion}) case Debian: - debPkgs = append(debPkgs, v.Name) + debPkgs = append(debPkgs, PackageInfo{Name: v.Name, ToVersion: v.ToVersion}) default: return fmt.Errorf("unknown package type %s", v.Type) }