Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions cmd/arduino-app-cli/system/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,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())
Expand All @@ -64,7 +64,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{
Expand All @@ -76,7 +76,7 @@ func newUpdateCmd() *cobra.Command {

updater := getUpdater()

pkgs, err := updater.ListUpgradablePackages(cmd.Context(), filterFunc)
pkgs, err := updater.ListUpgradablePackages(cfg, cmd.Context(), filterFunc)
if err != nil {
return err
}
Expand Down
4 changes: 2 additions & 2 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ 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("PUT /v1/system/update/apply", handlers.HandleUpdateApply(cfg, updater))
mux.Handle("GET /v1/system/resources", handlers.HandleSystemResources())

mux.Handle("GET /v1/models", handlers.HandleModelsList(modelsIndex))
Expand Down
9 changes: 5 additions & 4 deletions internal/api/handlers/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,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()

Expand All @@ -41,7 +42,7 @@ func HandleCheckUpgradable(updater *update.Manager) http.HandlerFunc {
filterFunc = update.MatchArduinoPackage
}

pkgs, err := updater.ListUpgradablePackages(r.Context(), filterFunc)
pkgs, err := updater.ListUpgradablePackages(cfg, r.Context(), filterFunc)
if err != nil {
if errors.Is(err, update.ErrOperationAlreadyInProgress) {
render.EncodeResponse(w, http.StatusConflict, models.ErrorResponse{Details: err.Error()})
Expand All @@ -64,7 +65,7 @@ type UpdateCheckResult struct {
Packages []update.UpgradablePackage `json:"updates"`
}

func HandleUpdateApply(updater *update.Manager) http.HandlerFunc {
func HandleUpdateApply(cfg config.Configuration, updater *update.Manager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
queryParams := r.URL.Query()
onlyArduinoPackages := false
Expand All @@ -77,7 +78,7 @@ func HandleUpdateApply(updater *update.Manager) http.HandlerFunc {
filterFunc = update.MatchArduinoPackage
}

pkgs, err := updater.ListUpgradablePackages(r.Context(), filterFunc)
pkgs, err := updater.ListUpgradablePackages(cfg, r.Context(), filterFunc)
if err != nil {
if errors.Is(err, update.ErrOperationAlreadyInProgress) {
render.EncodeResponse(w, http.StatusConflict, models.ErrorResponse{Details: err.Error()})
Expand Down
45 changes: 27 additions & 18 deletions internal/orchestrator/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,16 @@ import (
var runnerVersion = "0.5.0"

type Configuration struct {
appsDir *paths.Path
dataDir *paths.Path
routerSocketPath *paths.Path
customEIModelsDir *paths.Path
PythonImage string
UsedPythonImageTag string
RunnerVersion string
AllowRoot bool
LibrariesAPIURL *url.URL
appsDir *paths.Path
dataDir *paths.Path
routerSocketPath *paths.Path
customEIModelsDir *paths.Path
PythonImage string
UsedPythonImageTag string
RunnerVersion string
AllowRoot bool
LibrariesAPIURL *url.URL
MaxAllowedMajorVersion int
}

func NewFromEnv() (Configuration, error) {
Expand Down Expand Up @@ -105,17 +106,25 @@ func NewFromEnv() (Configuration, error) {
if err != nil {
return Configuration{}, fmt.Errorf("invalid LIBRARIES_API_URL: %w", err)
}
maxVersionStr := os.Getenv("ARDUINO_APP_CLI__MAX_UPDATE_MAJOR_VERSION")

maxVersion, err := strconv.Atoi(maxVersionStr)
if err != nil || maxVersion <= 0 {
maxVersion = 0
}
slog.Debug("Using max update major version", slog.Int("version", maxVersion))

c := Configuration{
appsDir: appsDir,
dataDir: dataDir,
routerSocketPath: routerSocket,
customEIModelsDir: customEIModelsDir,
PythonImage: pythonImage,
UsedPythonImageTag: usedPythonImageTag,
RunnerVersion: runnerVersion,
AllowRoot: allowRoot,
LibrariesAPIURL: parsedLibrariesURL,
appsDir: appsDir,
dataDir: dataDir,
routerSocketPath: routerSocket,
customEIModelsDir: customEIModelsDir,
PythonImage: pythonImage,
UsedPythonImageTag: usedPythonImageTag,
RunnerVersion: runnerVersion,
AllowRoot: allowRoot,
LibrariesAPIURL: parsedLibrariesURL,
MaxAllowedMajorVersion: maxVersion,
}
if err := c.init(); err != nil {
return Configuration{}, err
Expand Down
3 changes: 2 additions & 1 deletion internal/update/apt/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"go.bug.st/f"

"github.com/arduino/arduino-app-cli/internal/orchestrator"
"github.com/arduino/arduino-app-cli/internal/orchestrator/config"
"github.com/arduino/arduino-app-cli/internal/update"
)

Expand All @@ -48,7 +49,7 @@ func New() *Service {
// It runs the `apt-get update` command before listing the packages to ensure the package list is up to date.
// It filters the packages using the provided matcher function.
// It returns a slice of UpgradablePackage or an error if the command fails.
func (s *Service) ListUpgradablePackages(ctx context.Context, matcher func(update.UpgradablePackage) bool) ([]update.UpgradablePackage, error) {
func (s *Service) ListUpgradablePackages(cfg config.Configuration, ctx context.Context, matcher func(update.UpgradablePackage) bool) ([]update.UpgradablePackage, error) {
if !s.lock.TryLock() {
return nil, update.ErrOperationAlreadyInProgress
}
Expand Down
59 changes: 54 additions & 5 deletions internal/update/arduino/arduino.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ import (
"sync"
"time"

"github.com/Masterminds/semver/v3"
"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"

"github.com/arduino/arduino-app-cli/internal/helpers"
"github.com/arduino/arduino-app-cli/internal/orchestrator"
"github.com/arduino/arduino-app-cli/internal/orchestrator/config"
"github.com/arduino/arduino-app-cli/internal/update"
)

Expand All @@ -53,7 +55,7 @@ func setConfig(ctx context.Context, srv rpc.ArduinoCoreServiceServer) error {
}

// ListUpgradablePackages implements ServiceUpdater.
func (a *ArduinoPlatformUpdater) ListUpgradablePackages(ctx context.Context, _ func(update.UpgradablePackage) bool) ([]update.UpgradablePackage, error) {
func (a *ArduinoPlatformUpdater) ListUpgradablePackages(cfg config.Configuration, ctx context.Context, _ func(update.UpgradablePackage) bool) ([]update.UpgradablePackage, error) {
if !a.lock.TryLock() {
return nil, update.ErrOperationAlreadyInProgress
}
Expand Down Expand Up @@ -111,18 +113,65 @@ func (a *ArduinoPlatformUpdater) ListUpgradablePackages(ctx context.Context, _ f
if platformSummary == nil {
return nil, nil // No platform found
}
releasesMap := platformSummary.GetReleases()

if platformSummary.GetLatestVersion() == platformSummary.GetInstalledVersion() {
return nil, nil // No update available
}
releases := make([]string, 0, len(releasesMap))

for k := range releasesMap {
releases = append(releases, k)
}
bestVersion, err := findBestCandidate(
platformSummary.GetInstalledVersion(),
releases,
cfg.MaxAllowedMajorVersion,
)

if bestVersion == "" || err != nil {
return nil, nil
}
return []update.UpgradablePackage{{
Type: update.Arduino,
Name: "arduino:zephyr",
FromVersion: platformSummary.GetInstalledVersion(),
ToVersion: platformSummary.GetLatestVersion(),
ToVersion: bestVersion,
}}, nil
}
func findBestCandidate(installedStr string, availableVersions []string, maxMajorConfig int) (string, error) {
installedV, err := semver.NewVersion(installedStr)
if err != nil {
return "", err
}

maxMajor := uint64(maxMajorConfig)
if maxMajorConfig <= 0 {
maxMajor = installedV.Major()
}

var bestUpdateV *semver.Version

for _, vStr := range availableVersions {
candidateV, err := semver.NewVersion(vStr)
if err != nil {
continue
}

if candidateV.Major() > maxMajor {
continue
}

if !candidateV.GreaterThan(installedV) {
continue
}
if bestUpdateV == nil || candidateV.GreaterThan(bestUpdateV) {
bestUpdateV = candidateV
}
}

if bestUpdateV == nil {
return "", nil
}
return bestUpdateV.Original(), nil
}

// UpgradePackages implements ServiceUpdater.
func (a *ArduinoPlatformUpdater) UpgradePackages(ctx context.Context, names []string) (<-chan update.Event, error) {
Expand Down
101 changes: 101 additions & 0 deletions internal/update/arduino/arduino_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package arduino

import "testing"

func TestFindBestCandidate(t *testing.T) {
tests := []struct {
name string
installed string
available []string
maxMajorConfig int
expectedVersion string
expectError bool
}{
{
name: "Standard update: minor upgrade available",
installed: "1.0.0",
available: []string{"1.0.1", "1.1.0"},
maxMajorConfig: 0,
expectedVersion: "1.1.0",
expectError: false,
},
{
name: "Major update blocked by default (Config=0)",
installed: "1.9.9",
available: []string{"2.0.0", "1.9.10"},
maxMajorConfig: 0,
expectedVersion: "1.9.10",
expectError: false,
},
{
name: "Major update allowed by explicit config",
installed: "1.9.9",
available: []string{"2.0.0", "3.0.0"},
maxMajorConfig: 2,
expectedVersion: "2.0.0",
expectError: false,
},
{
name: "CRITICAL: Regression test for 'Zero Value' bug (Version 2+)",
installed: "2.1.0",
available: []string{"2.2.0", "3.0.0"},
maxMajorConfig: 0,
expectedVersion: "2.2.0",
expectError: false,
},
{
name: "No updates available (all older or same)",
installed: "1.5.0",
available: []string{"1.0.0", "1.5.0"},
maxMajorConfig: 0,
expectedVersion: "",
expectError: false,
},
{
name: "Handle unsorted list and pick highest valid",
installed: "1.0.0",
available: []string{"1.1.0", "1.5.0", "1.2.0"},
maxMajorConfig: 0,
expectedVersion: "1.5.0",
expectError: false,
},
{
name: "Skip invalid candidate strings",
installed: "1.0.0",
available: []string{"invalid-ver", "1.1.0"},
maxMajorConfig: 0,
expectedVersion: "1.1.0",
expectError: false,
},
{
name: "Error on invalid installed version string",
installed: "not-a-semver",
available: []string{"1.0.0"},
maxMajorConfig: 0,
expectedVersion: "",
expectError: true,
},
{
name: "Prerelease handling (standard logic ignores prereleases unless specifically handled)",
installed: "1.0.0",
available: []string{"1.0.1-beta"},
maxMajorConfig: 0,
expectedVersion: "1.0.1-beta",
expectError: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := findBestCandidate(tt.installed, tt.available, tt.maxMajorConfig)

if (err != nil) != tt.expectError {
t.Errorf("findBestCandidate() error = %v, expectError %v", err, tt.expectError)
return
}
if got != tt.expectedVersion {
t.Errorf("findBestCandidate() = %v, want %v", got, tt.expectedVersion)
}
})
}
}
10 changes: 6 additions & 4 deletions internal/update/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import (
"time"

"golang.org/x/sync/errgroup"

"github.com/arduino/arduino-app-cli/internal/orchestrator/config"
)

var ErrOperationAlreadyInProgress = errors.New("an operation is already in progress")
Expand All @@ -48,7 +50,7 @@ type UpgradablePackage struct {
}

type ServiceUpdater interface {
ListUpgradablePackages(ctx context.Context, matcher func(UpgradablePackage) bool) ([]UpgradablePackage, error)
ListUpgradablePackages(cfg config.Configuration, ctx context.Context, matcher func(UpgradablePackage) bool) ([]UpgradablePackage, error)
UpgradePackages(ctx context.Context, names []string) (<-chan Event, error)
}

Expand All @@ -69,7 +71,7 @@ func NewManager(debUpdateService ServiceUpdater, arduinoPlatformUpdateService Se
}
}

func (m *Manager) ListUpgradablePackages(ctx context.Context, matcher func(UpgradablePackage) bool) ([]UpgradablePackage, error) {
func (m *Manager) ListUpgradablePackages(cfg config.Configuration, ctx context.Context, matcher func(UpgradablePackage) bool) ([]UpgradablePackage, error) {
if !m.lock.TryLock() {
return nil, ErrOperationAlreadyInProgress
}
Expand All @@ -89,7 +91,7 @@ func (m *Manager) ListUpgradablePackages(ctx context.Context, matcher func(Upgra
)

g.Go(func() error {
pkgs, err := m.debUpdateService.ListUpgradablePackages(ctx, matcher)
pkgs, err := m.debUpdateService.ListUpgradablePackages(cfg, ctx, matcher)
if err != nil {
return err
}
Expand All @@ -98,7 +100,7 @@ func (m *Manager) ListUpgradablePackages(ctx context.Context, matcher func(Upgra
})

g.Go(func() error {
pkgs, err := m.arduinoPlatformUpdateService.ListUpgradablePackages(ctx, matcher)
pkgs, err := m.arduinoPlatformUpdateService.ListUpgradablePackages(cfg, ctx, matcher)
if err != nil {
return err
}
Expand Down
Loading