diff --git a/.github/workflows/test-update.yml b/.github/workflows/test-update.yml new file mode 100644 index 00000000..9b5dc6bf --- /dev/null +++ b/.github/workflows/test-update.yml @@ -0,0 +1,29 @@ +name: test the system update flow + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-and-update: + runs-on: ubuntu-22.04 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run dep package update test + env: + GH_TOKEN: ${{ secrets.ARDUINOBOT_TOKEN }} + run: | + go tool task test:update diff --git a/Taskfile.yml b/Taskfile.yml index 06e54550..8f2e49c0 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -50,9 +50,13 @@ tasks: test:internal: cmds: - - go build ./cmd/arduino-app-cli # needed for e2e tests + - go build ./cmd/arduino-app-cli - task: generate - - go test ./internal/... ./cmd/... -v -race {{ .CLI_ARGS }} + - go test $(go list ./internal/... ./cmd/... | grep -v internal/e2e/updatetest) -v -race {{ .CLI_ARGS }} + + test:update: + cmds: + - go test --timeout 30m -v ./internal/e2e/updatetest test:pkg: desc: Run only tests in the pkg directory @@ -102,9 +106,10 @@ tasks: deps: - build-deb:clone-examples cmds: - - docker build --build-arg BINARY_NAME=arduino-app-cli --build-arg DEB_NAME=arduino-app-cli --build-arg VERSION={{ .VERSION }} --build-arg ARCH={{ .ARCH }} --build-arg RELEASE={{ .RELEASE }} --output=./build -f debian/Dockerfile . + - docker build --build-arg BINARY_NAME=arduino-app-cli --build-arg DEB_NAME=arduino-app-cli --build-arg VERSION={{ .VERSION }} --build-arg ARCH={{ .ARCH }} --build-arg RELEASE={{ .RELEASE }} --output={{ .OUTPUT }} -f debian/Dockerfile . vars: ARCH: '{{.ARCH | default "arm64"}}' + OUTPUT: '{{.OUTPUT | default "./build"}}' build-deb:clone-examples: desc: "Clones the examples repo directly into the debian structure" @@ -114,7 +119,7 @@ tasks: echo "Runner version set as: {{ .EXAMPLE_VERSION }}" TMP_PATH="$(mktemp -d)" DEST_PATH="debian/arduino-app-cli/home/arduino/.local/share/arduino-app-cli/" - echo "Cloning arduino/app-bricks-example into temporary directory ${TMP_PATH}..." + echo "Cloning arduino/app-bricks-examples into temporary directory ${TMP_PATH}..." git clone --depth 1 --branch "{{ .EXAMPLE_VERSION }}" https://github.com/arduino/app-bricks-examples "${TMP_PATH}" rm -rf "${DEST_PATH}/examples" mkdir -p "${DEST_PATH}" @@ -167,7 +172,7 @@ tasks: cmds: - docker rm -f adbd - board:install-arduino-app-cli: + board:install: desc: Install arduino-app-cli on the board interactive: true cmds: @@ -190,7 +195,7 @@ tasks: TMP_PATH="$(mktemp -d)" echo "Cloning examples into temporary directory ${TMP_PATH}..." - git clone --depth 1 https://github.com/arduino/app-bricks-example.git "${TMP_PATH}" + git clone --depth 1 https://github.com/arduino/app-bricks-examples.git "${TMP_PATH}" echo "Installing examples to ${DEST_PATH}examples" rm -rf "${DEST_PATH}examples" diff --git a/cmd/arduino-app-cli/app/app.go b/cmd/arduino-app-cli/app/app.go index 3d6b6341..d8aae2fb 100644 --- a/cmd/arduino-app-cli/app/app.go +++ b/cmd/arduino-app-cli/app/app.go @@ -38,8 +38,8 @@ func NewAppCmd(cfg config.Configuration) *cobra.Command { appCmd.AddCommand(newRestartCmd(cfg)) appCmd.AddCommand(newLogsCmd(cfg)) appCmd.AddCommand(newListCmd(cfg)) - appCmd.AddCommand(newPsCmd()) appCmd.AddCommand(newMonitorCmd(cfg)) + appCmd.AddCommand(newCacheCleanCmd(cfg)) return appCmd } diff --git a/cmd/arduino-app-cli/app/clean.go b/cmd/arduino-app-cli/app/clean.go new file mode 100644 index 00000000..adef29c8 --- /dev/null +++ b/cmd/arduino-app-cli/app/clean.go @@ -0,0 +1,65 @@ +package app + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/arduino/arduino-app-cli/cmd/arduino-app-cli/completion" + "github.com/arduino/arduino-app-cli/cmd/arduino-app-cli/internal/servicelocator" + "github.com/arduino/arduino-app-cli/cmd/feedback" + "github.com/arduino/arduino-app-cli/internal/orchestrator" + "github.com/arduino/arduino-app-cli/internal/orchestrator/app" + "github.com/arduino/arduino-app-cli/internal/orchestrator/config" +) + +func newCacheCleanCmd(cfg config.Configuration) *cobra.Command { + var forceClean bool + appCmd := &cobra.Command{ + Use: "clean-cache ", + Short: "Delete app cache", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + app, err := Load(args[0]) + if err != nil { + return err + } + return cacheCleanHandler(cmd.Context(), app, forceClean) + }, + ValidArgsFunction: completion.ApplicationNames(cfg), + } + appCmd.Flags().BoolVarP(&forceClean, "force", "", false, "Forcefully clean the cache even if the app is running") + + return appCmd +} + +func cacheCleanHandler(ctx context.Context, app app.ArduinoApp, forceClean bool) error { + err := orchestrator.CleanAppCache( + ctx, + servicelocator.GetDockerClient(), + app, + orchestrator.CleanAppCacheRequest{ForceClean: forceClean}, + ) + if err != nil { + feedback.Fatal(err.Error(), feedback.ErrGeneric) + } + feedback.PrintResult(cacheCleanResult{ + AppName: app.Name, + Path: app.ProvisioningStateDir().String(), + }) + return nil +} + +type cacheCleanResult struct { + AppName string `json:"appName"` + Path string `json:"path"` +} + +func (r cacheCleanResult) String() string { + return fmt.Sprintf("✓ Cache of %q App cleaned", r.AppName) +} + +func (r cacheCleanResult) Data() interface{} { + return r +} diff --git a/cmd/arduino-app-cli/app/ps.go b/cmd/arduino-app-cli/app/ps.go deleted file mode 100644 index 6549b2b9..00000000 --- a/cmd/arduino-app-cli/app/ps.go +++ /dev/null @@ -1,30 +0,0 @@ -// 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 app - -import ( - "github.com/spf13/cobra" -) - -func newPsCmd() *cobra.Command { - return &cobra.Command{ - Use: "ps", - Short: "Shows the list of running Arduino Apps", - RunE: func(cmd *cobra.Command, args []string) error { - panic("not implemented") - }, - } -} diff --git a/cmd/arduino-app-cli/app/restart.go b/cmd/arduino-app-cli/app/restart.go index 3462a9a5..892f108f 100644 --- a/cmd/arduino-app-cli/app/restart.go +++ b/cmd/arduino-app-cli/app/restart.go @@ -16,10 +16,18 @@ package app import ( + "context" + "fmt" + "github.com/spf13/cobra" + "golang.org/x/text/cases" + "golang.org/x/text/language" "github.com/arduino/arduino-app-cli/cmd/arduino-app-cli/completion" + "github.com/arduino/arduino-app-cli/cmd/arduino-app-cli/internal/servicelocator" "github.com/arduino/arduino-app-cli/cmd/feedback" + "github.com/arduino/arduino-app-cli/internal/orchestrator" + "github.com/arduino/arduino-app-cli/internal/orchestrator/app" "github.com/arduino/arduino-app-cli/internal/orchestrator/config" ) @@ -32,17 +40,63 @@ func newRestartCmd(cfg config.Configuration) *cobra.Command { if len(args) == 0 { return cmd.Help() } - app, err := Load(args[0]) + appToStart, err := Load(args[0]) if err != nil { feedback.Fatal(err.Error(), feedback.ErrBadArgument) - return nil - } - if err := stopHandler(cmd.Context(), app); err != nil { - feedback.Warnf("failed to stop app: %s", err.Error()) } - return startHandler(cmd.Context(), cfg, app) + return restartHandler(cmd.Context(), cfg, appToStart) }, ValidArgsFunction: completion.ApplicationNames(cfg), } return cmd } + +func restartHandler(ctx context.Context, cfg config.Configuration, app app.ArduinoApp) error { + out, _, getResult := feedback.OutputStreams() + + stream := orchestrator.RestartApp( + ctx, + servicelocator.GetDockerClient(), + servicelocator.GetProvisioner(), + servicelocator.GetModelsIndex(), + servicelocator.GetBricksIndex(), + app, + cfg, + servicelocator.GetStaticStore(), + ) + for message := range stream { + switch message.GetType() { + case orchestrator.ProgressType: + fmt.Fprintf(out, "Progress[%s]: %.0f%%\n", message.GetProgress().Name, message.GetProgress().Progress) + case orchestrator.InfoType: + fmt.Fprintln(out, "[INFO]", message.GetData()) + case orchestrator.ErrorType: + errMesg := cases.Title(language.AmericanEnglish).String(message.GetError().Error()) + feedback.Fatal(fmt.Sprintf("[ERROR] %s", errMesg), feedback.ErrGeneric) + return nil + } + } + + outputResult := getResult() + feedback.PrintResult(restartAppResult{ + AppName: app.Name, + Status: "restarted", + Output: outputResult, + }) + + return nil +} + +type restartAppResult struct { + AppName string `json:"app_name"` + Status string `json:"status"` + Output *feedback.OutputStreamsResult `json:"output,omitempty"` +} + +func (r restartAppResult) String() string { + return fmt.Sprintf("✓ App %q restarted successfully", r.AppName) +} + +func (r restartAppResult) Data() interface{} { + return r +} diff --git a/cmd/arduino-app-cli/board/board.go b/cmd/arduino-app-cli/board/board.go deleted file mode 100644 index 98b3c04b..00000000 --- a/cmd/arduino-app-cli/board/board.go +++ /dev/null @@ -1,306 +0,0 @@ -// 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 board - -import ( - "context" - "fmt" - "os" - "strconv" - - "github.com/spf13/cobra" - "golang.org/x/term" - - "github.com/arduino/arduino-app-cli/cmd/feedback" - "github.com/arduino/arduino-app-cli/pkg/board" - "github.com/arduino/arduino-app-cli/pkg/board/remote" - "github.com/arduino/arduino-app-cli/pkg/board/remote/adb" -) - -type contextKey string - -const remoteConnKey contextKey = "remoteConn" -const boardsListKey contextKey = "boardsList" - -func NewBoardCmd() *cobra.Command { - var fqbn, host string - fsCmd := &cobra.Command{ - Use: "board", - Short: "Manage boards", - Long: "", - Hidden: true, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - if host != "" { - conn, err := adb.FromHost(host, "") - if err != nil { - panic(fmt.Errorf("failed to connect to ADB host %s: %w", host, err)) - } - cmd.SetContext(context.WithValue(cmd.Context(), remoteConnKey, conn)) - return nil - } - - boards, err := board.FromFQBN(cmd.Context(), fqbn) - if err != nil { - return fmt.Errorf("failed to get boards for FQBN %s: %w", fqbn, err) - } - if len(boards) == 0 { - return fmt.Errorf("no boards found for FQBN %s", fqbn) - } - conn, err := boards[0].GetConnection() - if err != nil { - return fmt.Errorf("failed to connect to board %s: %w", boards[0].BoardName, err) - } - - cmd.SetContext(context.WithValue(cmd.Context(), remoteConnKey, conn)) - cmd.SetContext(context.WithValue(cmd.Context(), boardsListKey, boards)) - return nil - }, - } - fsCmd.PersistentFlags().StringVarP(&fqbn, "fqbn", "b", "arduino:zephyr:unoq", "fqbn of the board") - fsCmd.PersistentFlags().StringVar(&host, "host", "", "ADB host address") - - fsCmd.AddCommand(newBoardListCmd()) - fsCmd.AddCommand(newBoardSetName()) - fsCmd.AddCommand(newSetPasswordCmd()) - fsCmd.AddCommand(newEnableNetworkModeCmd()) - fsCmd.AddCommand(newDisableNetworkModeCmd()) - fsCmd.AddCommand(newNetworkModeStatusCmd()) - - fsCmd.AddCommand(listKeyboardLayouts()) - fsCmd.AddCommand(getKeyboardLayout()) - fsCmd.AddCommand(setKeyboardLayout()) - - return fsCmd -} - -func newBoardListCmd() *cobra.Command { - listCmd := &cobra.Command{ - Use: "list", - Short: "List available boards", - RunE: func(cmd *cobra.Command, args []string) error { - boards := cmd.Context().Value(boardsListKey).([]board.Board) - for _, b := range boards { - - var address, configured string - switch b.Protocol { - case board.SerialProtocol, board.LocalProtocol: - address = b.Serial - - if conn, err := b.GetConnection(); err != nil { - return fmt.Errorf("failed to connect to board %s: %w", b.BoardName, err) - } else { - if s, err := board.IsUserPasswordSet(conn); err != nil { - return fmt.Errorf("failed to check if user password is set: %w", err) - } else { - configured = "- Configured: " + strconv.FormatBool(s) - } - } - case board.NetworkProtocol: - address = b.Address - default: - panic("unreachable") - } - - feedback.Printf("%s (%s) - Connection: %s [%s] %s\n", b.BoardName, b.CustomName, b.Protocol, address, configured) - } - return nil - }, - } - - return listCmd -} - -func newBoardSetName() *cobra.Command { - setNameCmd := &cobra.Command{ - Use: "set-name ", - Short: "Set the custom name of the board", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - conn := cmd.Context().Value(remoteConnKey).(remote.RemoteConn) - name := args[0] - - if err := board.SetCustomName(cmd.Context(), conn, name); err != nil { - return fmt.Errorf("failed to set custom name: %w", err) - } - feedback.Printf("Custom name set to %q\n", name) - return nil - }, - } - - return setNameCmd -} - -func newSetPasswordCmd() *cobra.Command { - return &cobra.Command{ - Use: "set-password", - Short: "Set the user password of the board", - Args: cobra.ExactArgs(0), - RunE: func(cmd *cobra.Command, args []string) error { - conn := cmd.Context().Value(remoteConnKey).(remote.RemoteConn) - - feedback.Print("Enter new password: ") - // TODO: fix for not interactive terminal - password, err := term.ReadPassword(int(os.Stdin.Fd())) // nolint:forbidigo - if err != nil { - return fmt.Errorf("failed to read password: %w", err) - } - - if err := board.SetUserPassword(cmd.Context(), conn, string(password)); err != nil { - return fmt.Errorf("failed to set user password: %w", err) - } - - feedback.Printf("User password set\n") - return nil - }, - } -} - -func newEnableNetworkModeCmd() *cobra.Command { - return &cobra.Command{ - Use: "enable-ssh", - Short: "Enable and start the SSH service on the board", - Args: cobra.ExactArgs(0), - RunE: func(cmd *cobra.Command, args []string) error { - conn := cmd.Context().Value(remoteConnKey).(remote.RemoteConn) - - if err := board.EnableNetworkMode(cmd.Context(), conn); err != nil { - return fmt.Errorf("failed to enable SSH: %w", err) - } - - feedback.Printf("SSH service enabled and started\n") - return nil - }, - } -} - -func newDisableNetworkModeCmd() *cobra.Command { - return &cobra.Command{ - Use: "disable-ssh", - Short: "Disable and stop the SSH service on the board", - Args: cobra.ExactArgs(0), - RunE: func(cmd *cobra.Command, args []string) error { - conn := cmd.Context().Value(remoteConnKey).(remote.RemoteConn) - - if err := board.DisableNetworkMode(cmd.Context(), conn); err != nil { - return fmt.Errorf("failed to disable SSH: %w", err) - } - - feedback.Printf("SSH service disabled and stopped\n") - return nil - }, - } -} - -func newNetworkModeStatusCmd() *cobra.Command { - return &cobra.Command{ - Use: "status-ssh", - Short: "Check the status of the network mode on the board", - Args: cobra.ExactArgs(0), - RunE: func(cmd *cobra.Command, args []string) error { - conn := cmd.Context().Value(remoteConnKey).(remote.RemoteConn) - - isEnabled, err := board.NetworkModeStatus(cmd.Context(), conn) - if err != nil { - return fmt.Errorf("failed to check network mode status: %w", err) - } - - feedback.Printf("Network mode is %s\n", map[bool]string{true: "enabled", false: "disabled"}[isEnabled]) - return nil - }, - } -} - -func getKeyboardLayout() *cobra.Command { - return &cobra.Command{ - Use: "get-keyboard-layout", - Short: "Returns the current system keyboard layout code", - Args: cobra.ExactArgs(0), - RunE: func(cmd *cobra.Command, args []string) error { - conn := cmd.Context().Value(remoteConnKey).(remote.RemoteConn) - - layoutCode, err := board.GetKeyboardLayout(cmd.Context(), conn) - if err != nil { - return fmt.Errorf("failed: %w", err) - } - feedback.Printf("Layout: %s", layoutCode) - - return nil - }, - } -} - -func setKeyboardLayout() *cobra.Command { - return &cobra.Command{ - Use: "set-keyboard-layout ", - Short: "Saves and applies the current system keyboard layout", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - conn := cmd.Context().Value(remoteConnKey).(remote.RemoteConn) - layoutCode := args[0] - - err := validateKeyboardLayoutCode(conn, layoutCode) - if err != nil { - return fmt.Errorf("failed: %w", err) - } - - err = board.SetKeyboardLayout(cmd.Context(), conn, layoutCode) - if err != nil { - return fmt.Errorf("failed: %w", err) - } - - feedback.Printf("New layout applied: %s", layoutCode) - return nil - }, - } -} - -func validateKeyboardLayoutCode(conn remote.RemoteConn, layoutCode string) error { - // Make sure the input layout code is in the list of valid ones - layouts, err := board.ListKeyboardLayouts(conn) - if err != nil { - return fmt.Errorf("failed to fetch valid layouts: %w", err) - } - - for _, layout := range layouts { - if layout.LayoutId == layoutCode { - return nil - } - } - - return fmt.Errorf("invalid layout code: %s", layoutCode) -} - -func listKeyboardLayouts() *cobra.Command { - return &cobra.Command{ - Use: "list-keyboard-layouts", - Short: "Returns the list of valid keyboard layouts, with a description", - Args: cobra.ExactArgs(0), - RunE: func(cmd *cobra.Command, args []string) error { - conn := cmd.Context().Value(remoteConnKey).(remote.RemoteConn) - - layouts, err := board.ListKeyboardLayouts(conn) - if err != nil { - return fmt.Errorf("failed: %w", err) - } - - for _, layout := range layouts { - feedback.Printf("%s, %s", layout.LayoutId, layout.Description) - } - - return nil - }, - } -} diff --git a/cmd/arduino-app-cli/brick/bricks.go b/cmd/arduino-app-cli/brick/bricks.go index 692552cc..74dd3a3a 100644 --- a/cmd/arduino-app-cli/brick/bricks.go +++ b/cmd/arduino-app-cli/brick/bricks.go @@ -17,16 +17,18 @@ package brick import ( "github.com/spf13/cobra" + + "github.com/arduino/arduino-app-cli/internal/orchestrator/config" ) -func NewBrickCmd() *cobra.Command { +func NewBrickCmd(cfg config.Configuration) *cobra.Command { appCmd := &cobra.Command{ Use: "brick", Short: "Manage Arduino Bricks", } appCmd.AddCommand(newBricksListCmd()) - appCmd.AddCommand(newBricksDetailsCmd()) + appCmd.AddCommand(newBricksDetailsCmd(cfg)) return appCmd } diff --git a/cmd/arduino-app-cli/brick/details.go b/cmd/arduino-app-cli/brick/details.go index fe025078..e898c9a0 100644 --- a/cmd/arduino-app-cli/brick/details.go +++ b/cmd/arduino-app-cli/brick/details.go @@ -22,24 +22,28 @@ import ( "github.com/spf13/cobra" + "github.com/arduino/arduino-app-cli/cmd/arduino-app-cli/completion" "github.com/arduino/arduino-app-cli/cmd/arduino-app-cli/internal/servicelocator" "github.com/arduino/arduino-app-cli/cmd/feedback" "github.com/arduino/arduino-app-cli/internal/orchestrator/bricks" + "github.com/arduino/arduino-app-cli/internal/orchestrator/config" ) -func newBricksDetailsCmd() *cobra.Command { +func newBricksDetailsCmd(cfg config.Configuration) *cobra.Command { return &cobra.Command{ Use: "details", Short: "Details of a specific brick", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { - bricksDetailsHandler(args[0]) + bricksDetailsHandler(args[0], cfg) }, + ValidArgsFunction: completion.BrickIDs(), } } -func bricksDetailsHandler(id string) { - res, err := servicelocator.GetBrickService().BricksDetails(id) +func bricksDetailsHandler(id string, cfg config.Configuration) { + res, err := servicelocator.GetBrickService().BricksDetails(id, servicelocator.GetAppIDProvider(), + cfg) if err != nil { if errors.Is(err, bricks.ErrBrickNotFound) { feedback.Fatal(err.Error(), feedback.ErrBadArgument) diff --git a/cmd/arduino-app-cli/completion/completion.go b/cmd/arduino-app-cli/completion/completion.go index 418f9a3e..ae222201 100644 --- a/cmd/arduino-app-cli/completion/completion.go +++ b/cmd/arduino-app-cli/completion/completion.go @@ -24,6 +24,7 @@ import ( "github.com/arduino/arduino-app-cli/cmd/arduino-app-cli/internal/servicelocator" "github.com/arduino/arduino-app-cli/cmd/feedback" "github.com/arduino/arduino-app-cli/internal/orchestrator" + "github.com/arduino/arduino-app-cli/internal/orchestrator/bricks" "github.com/arduino/arduino-app-cli/internal/orchestrator/config" ) @@ -97,3 +98,24 @@ func ApplicationNamesWithFilterFunc(cfg config.Configuration, filter func(apps o return res, cobra.ShellCompDirectiveNoFileComp } } + +func BrickIDs() cobra.CompletionFunc { + return BrickIDsWithFilterFunc(func(_ bricks.BrickListItem) bool { return true }) +} + +func BrickIDsWithFilterFunc(filter func(apps bricks.BrickListItem) bool) cobra.CompletionFunc { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + brickList, err := servicelocator.GetBrickService().List() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var res []string + for _, brick := range brickList.Bricks { + if filter(brick) { + res = append(res, brick.ID) + } + } + return res, cobra.ShellCompDirectiveNoFileComp + } +} diff --git a/cmd/arduino-app-cli/config/config.go b/cmd/arduino-app-cli/config/config.go index 635ad2bd..f6367fd3 100644 --- a/cmd/arduino-app-cli/config/config.go +++ b/cmd/arduino-app-cli/config/config.go @@ -29,7 +29,7 @@ import ( func NewConfigCmd(cfg config.Configuration) *cobra.Command { appCmd := &cobra.Command{ Use: "config", - Short: "Manage arduino-app-cli config", + Short: "Manage Arduino App CLI config", } appCmd.AddCommand(newConfigGetCmd(cfg)) diff --git a/cmd/arduino-app-cli/daemon/daemon.go b/cmd/arduino-app-cli/daemon/daemon.go index f96a4e0c..eeac105c 100644 --- a/cmd/arduino-app-cli/daemon/daemon.go +++ b/cmd/arduino-app-cli/daemon/daemon.go @@ -38,7 +38,7 @@ import ( func NewDaemonCmd(cfg config.Configuration, version string) *cobra.Command { daemonCmd := &cobra.Command{ Use: "daemon", - Short: "Run an HTTP server to expose arduino-app-cli functionality through REST API", + Short: "Run the Arduino App CLI as an HTTP daemon", Run: func(cmd *cobra.Command, args []string) { daemonPort, _ := cmd.Flags().GetString("port") diff --git a/cmd/arduino-app-cli/main.go b/cmd/arduino-app-cli/main.go index 4df37ca8..c9dfddca 100644 --- a/cmd/arduino-app-cli/main.go +++ b/cmd/arduino-app-cli/main.go @@ -25,7 +25,6 @@ import ( "go.bug.st/cleanup" "github.com/arduino/arduino-app-cli/cmd/arduino-app-cli/app" - "github.com/arduino/arduino-app-cli/cmd/arduino-app-cli/board" "github.com/arduino/arduino-app-cli/cmd/arduino-app-cli/brick" "github.com/arduino/arduino-app-cli/cmd/arduino-app-cli/completion" "github.com/arduino/arduino-app-cli/cmd/arduino-app-cli/config" @@ -49,7 +48,7 @@ func run(configuration cfg.Configuration) error { defer func() { _ = servicelocator.CloseDockerClient() }() rootCmd := &cobra.Command{ Use: "arduino-app-cli", - Short: "A CLI to manage the Python app", + Short: "A CLI to manage Arduino Apps", PersistentPreRun: func(cmd *cobra.Command, args []string) { format, ok := feedback.ParseOutputFormat(format) if !ok { @@ -72,13 +71,12 @@ func run(configuration cfg.Configuration) error { rootCmd.AddCommand( app.NewAppCmd(configuration), - brick.NewBrickCmd(), + brick.NewBrickCmd(configuration), completion.NewCompletionCommand(), daemon.NewDaemonCmd(configuration, Version), properties.NewPropertiesCmd(configuration), config.NewConfigCmd(configuration), system.NewSystemCmd(configuration), - board.NewBoardCmd(), version.NewVersionCmd(Version), ) diff --git a/cmd/arduino-app-cli/system/system.go b/cmd/arduino-app-cli/system/system.go index 6297797f..54c89128 100644 --- a/cmd/arduino-app-cli/system/system.go +++ b/cmd/arduino-app-cli/system/system.go @@ -37,19 +37,21 @@ import ( func NewSystemCmd(cfg config.Configuration) *cobra.Command { cmd := &cobra.Command{ - Use: "system", + Use: "system", + Short: "Manage the board’s system configuration", } - cmd.AddCommand(newDownloadImage(cfg)) + cmd.AddCommand(newDownloadImageCmd(cfg)) cmd.AddCommand(newUpdateCmd()) cmd.AddCommand(newCleanUpCmd(cfg, servicelocator.GetDockerClient())) - cmd.AddCommand(newNetworkMode()) - cmd.AddCommand(newkeyboardSet()) + cmd.AddCommand(newNetworkModeCmd()) + cmd.AddCommand(newKeyboardSetCmd()) + cmd.AddCommand(newBoardSetNameCmd()) return cmd } -func newDownloadImage(cfg config.Configuration) *cobra.Command { +func newDownloadImageCmd(cfg config.Configuration) *cobra.Command { cmd := &cobra.Command{ Use: "init", Args: cobra.ExactArgs(0), @@ -173,7 +175,7 @@ func newCleanUpCmd(cfg config.Configuration, docker command.Cli) *cobra.Command return cmd } -func newNetworkMode() *cobra.Command { +func newNetworkModeCmd() *cobra.Command { cmd := &cobra.Command{ Use: "network-mode ", Short: "Manage the network mode of the system", @@ -209,7 +211,7 @@ func newNetworkMode() *cobra.Command { return cmd } -func newkeyboardSet() *cobra.Command { +func newKeyboardSetCmd() *cobra.Command { cmd := &cobra.Command{ Use: "keyboard [layout]", Short: "Manage the keyboard layout of the system", @@ -250,3 +252,21 @@ func newkeyboardSet() *cobra.Command { return cmd } + +func newBoardSetNameCmd() *cobra.Command { + setNameCmd := &cobra.Command{ + Use: "set-name ", + Short: "Set the custom name of the board", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + if err := board.SetCustomName(cmd.Context(), &local.LocalConnection{}, name); err != nil { + return fmt.Errorf("failed to set custom name: %w", err) + } + feedback.Printf("Custom name set to %q\n", name) + return nil + }, + } + + return setNameCmd +} diff --git a/cmd/arduino-app-cli/version/version.go b/cmd/arduino-app-cli/version/version.go index 86ed7b3c..1cb06d05 100644 --- a/cmd/arduino-app-cli/version/version.go +++ b/cmd/arduino-app-cli/version/version.go @@ -16,34 +16,95 @@ package version import ( + "encoding/json" "fmt" + "net" + "net/http" + "net/url" + "time" "github.com/spf13/cobra" "github.com/arduino/arduino-app-cli/cmd/feedback" ) -func NewVersionCmd(version string) *cobra.Command { +// The actual listening address for the daemon +// is defined in the installation package +const ( + DefaultHostname = "localhost" + DefaultPort = "8800" + ProgramName = "Arduino App CLI" +) + +func NewVersionCmd(clientVersion string) *cobra.Command { cmd := &cobra.Command{ Use: "version", Short: "Print the version number of Arduino App CLI", Run: func(cmd *cobra.Command, args []string) { - feedback.PrintResult(versionResult{ - AppName: "Arduino App CLI", - Version: version, - }) + port, _ := cmd.Flags().GetString("port") + + daemonVersion, err := getDaemonVersion(http.Client{}, port) + if err != nil { + feedback.Warnf("Warning: cannot get the running daemon version on %s:%s\n", DefaultHostname, port) + } + + result := versionResult{ + Name: ProgramName, + Version: clientVersion, + DaemonVersion: daemonVersion, + } + + feedback.PrintResult(result) }, } + cmd.Flags().String("port", DefaultPort, "The daemon network port") return cmd } +func getDaemonVersion(httpClient http.Client, port string) (string, error) { + + httpClient.Timeout = time.Second + + url := url.URL{ + Scheme: "http", + Host: net.JoinHostPort(DefaultHostname, port), + Path: "/v1/version", + } + + resp, err := httpClient.Get(url.String()) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status code received") + } + + var daemonResponse struct { + Version string `json:"version"` + } + if err := json.NewDecoder(resp.Body).Decode(&daemonResponse); err != nil { + return "", err + } + + return daemonResponse.Version, nil +} + type versionResult struct { - AppName string `json:"appName"` - Version string `json:"version"` + Name string `json:"name"` + Version string `json:"version"` + DaemonVersion string `json:"daemon_version,omitempty"` } func (r versionResult) String() string { - return fmt.Sprintf("%s v%s", r.AppName, r.Version) + resultMessage := fmt.Sprintf("%s version %s", ProgramName, r.Version) + + if r.DaemonVersion != "" { + resultMessage = fmt.Sprintf("%s\ndaemon version: %s", + resultMessage, r.DaemonVersion) + } + return resultMessage } func (r versionResult) Data() interface{} { diff --git a/cmd/arduino-app-cli/version/version_test.go b/cmd/arduino-app-cli/version/version_test.go new file mode 100644 index 00000000..39617968 --- /dev/null +++ b/cmd/arduino-app-cli/version/version_test.go @@ -0,0 +1,125 @@ +// 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 version + +import ( + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDaemonVersion(t *testing.T) { + testCases := []struct { + name string + serverStub Tripper + port string + expectedResult string + expectedErrorMessage string + }{ + { + name: "return the server version when the server is up", + serverStub: successServer, + port: "8800", + expectedResult: "3.0-server", + expectedErrorMessage: "", + }, + { + name: "return error if default server is not listening on default port", + serverStub: failureServer, + port: "8800", + expectedResult: "", + expectedErrorMessage: `Get "http://localhost:8800/v1/version": connection refused`, + }, + { + name: "return error if provided server is not listening on provided port", + serverStub: failureServer, + port: "1234", + expectedResult: "", + expectedErrorMessage: `Get "http://localhost:1234/v1/version": connection refused`, + }, + { + name: "return error for server response 500 Internal Server Error", + serverStub: failureInternalServerError, + port: "0", + expectedResult: "", + expectedErrorMessage: "unexpected status code received", + }, + + { + name: "return error for server up and wrong json response", + serverStub: successServerWrongJson, + port: "8800", + expectedResult: "", + expectedErrorMessage: "invalid character '<' looking for beginning of value", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // arrange + httpClient := http.Client{} + httpClient.Transport = tc.serverStub + + // act + result, err := getDaemonVersion(httpClient, tc.port) + + // assert + require.Equal(t, tc.expectedResult, result) + if err != nil { + require.Equal(t, tc.expectedErrorMessage, err.Error()) + } + }) + } +} + +// Leverage the http.Client's RoundTripper +// to return a canned response and bypass network calls. +type Tripper func(*http.Request) (*http.Response, error) + +func (t Tripper) RoundTrip(request *http.Request) (*http.Response, error) { + return t(request) +} + +var successServer = Tripper(func(*http.Request) (*http.Response, error) { + body := io.NopCloser(strings.NewReader(`{"version":"3.0-server"}`)) + return &http.Response{ + StatusCode: http.StatusOK, + Body: body, + }, nil +}) + +var successServerWrongJson = Tripper(func(*http.Request) (*http.Response, error) { + body := io.NopCloser(strings.NewReader(`&2 + exit 1 +fi + + +if [ ! -w "$TARGET_FILE" ]; then + echo "Error: Target file $TARGET_FILE not found or not writable." >&2 + exit 1 +fi + +SERIAL_NUMBER=$(cat "$SERIAL_NUMBER_PATH") + +if [ -z "$SERIAL_NUMBER" ]; then + echo "Error: Serial number file is empty." >&2 + exit 1 +fi + +if grep -q "serial_number=" "$TARGET_FILE"; then + echo "Serial number ($SERIAL_NUMBER) already configured." + exit 0 +fi + +echo "Adding serial number to $TARGET_FILE..." +sed -i "/<\/service>/i serial_number=${SERIAL_NUMBER}<\/txt-record>" "$TARGET_FILE" + +echo "Avahi configuration attempt finished." +exit 0 \ No newline at end of file diff --git a/go.mod b/go.mod index 48ac9537..083d8282 100644 --- a/go.mod +++ b/go.mod @@ -50,7 +50,6 @@ require ( go.bug.st/relaxed-semver v0.15.0 golang.org/x/crypto v0.41.0 golang.org/x/sync v0.17.0 - golang.org/x/term v0.35.0 golang.org/x/text v0.29.0 ) @@ -130,7 +129,7 @@ require ( github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fvbommel/sortorder v1.1.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect - github.com/getkin/kin-openapi v0.132.0 // indirect + github.com/getkin/kin-openapi v0.133.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/go-git/go-git/v5 v5.16.2 // indirect @@ -207,7 +206,7 @@ require ( github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect - github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect + github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect @@ -258,6 +257,7 @@ require ( github.com/ulikunitz/xz v0.5.15 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect + github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect @@ -291,6 +291,7 @@ require ( golang.org/x/net v0.43.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.36.0 // indirect + golang.org/x/term v0.35.0 // indirect golang.org/x/time v0.11.0 // indirect golang.org/x/tools v0.36.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect diff --git a/go.sum b/go.sum index 98b1009f..1f1f015e 100644 --- a/go.sum +++ b/go.sum @@ -310,8 +310,8 @@ github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQ github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= -github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk= -github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58= +github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= +github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= @@ -676,8 +676,8 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLA github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 h1:iJvF8SdB/3/+eGOXEpsWkD8FQAHj6mqkb6Fnsoc8MFU= -github.com/oapi-codegen/oapi-codegen/v2 v2.5.0/go.mod h1:fwlMxUEMuQK5ih9aymrxKPQqNm2n8bdLk1ppjH+lr9w= +github.com/oapi-codegen/oapi-codegen/v2 v2.5.1 h1:5vHNY1uuPBRBWqB2Dp0G7YB03phxLQZupZTIZaeorjc= +github.com/oapi-codegen/oapi-codegen/v2 v2.5.1/go.mod h1:ro0npU1BWkcGpCgGD9QwPp44l5OIZ94tB3eabnT7DjQ= github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= @@ -909,6 +909,8 @@ github.com/warthog618/go-gpiocdev v0.9.1 h1:pwHPaqjJfhCipIQl78V+O3l9OKHivdRDdmgX github.com/warthog618/go-gpiocdev v0.9.1/go.mod h1:dN3e3t/S2aSNC+hgigGE/dBW8jE1ONk9bDSEYfoPyl8= github.com/warthog618/go-gpiosim v0.1.1 h1:MRAEv+T+itmw+3GeIGpQJBfanUVyg0l3JCTwHtwdre4= github.com/warthog618/go-gpiosim v0.1.1/go.mod h1:YXsnB+I9jdCMY4YAlMSRrlts25ltjmuIsrnoUrBLdqU= +github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= +github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= diff --git a/internal/api/api.go b/internal/api/api.go index 1d825317..08d31d84 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -56,7 +56,7 @@ func NewHTTPRouter( mux.Handle("GET /v1/version", handlers.HandlerVersion(version)) mux.Handle("GET /v1/config", handlers.HandleConfig(cfg)) mux.Handle("GET /v1/bricks", handlers.HandleBrickList(brickService)) - mux.Handle("GET /v1/bricks/{brickID}", handlers.HandleBrickDetails(brickService)) + mux.Handle("GET /v1/bricks/{brickID}", handlers.HandleBrickDetails(brickService, idProvider, cfg)) mux.Handle("GET /v1/properties", handlers.HandlePropertyKeys(cfg)) mux.Handle("GET /v1/properties/{key}", handlers.HandlePropertyGet(cfg)) diff --git a/internal/api/docs/openapi.yaml b/internal/api/docs/openapi.yaml index 577d77d1..f2b3a999 100644 --- a/internal/api/docs/openapi.yaml +++ b/internal/api/docs/openapi.yaml @@ -1276,6 +1276,17 @@ components: name: type: string type: object + BrickConfigVariable: + properties: + description: + type: string + name: + type: string + required: + type: boolean + value: + type: string + type: object BrickCreateUpdateRequest: properties: model: @@ -1325,6 +1336,10 @@ components: type: string category: type: string + config_variables: + items: + $ref: '#/components/schemas/BrickConfigVariable' + type: array id: type: string model: @@ -1336,6 +1351,8 @@ components: variables: additionalProperties: type: string + description: 'Deprecated: use config_variables instead. This field is kept + for backward compatibility.' type: object type: object BrickListItem: diff --git a/internal/api/handlers/bricks.go b/internal/api/handlers/bricks.go index d21c6b3a..7e95753a 100644 --- a/internal/api/handlers/bricks.go +++ b/internal/api/handlers/bricks.go @@ -26,6 +26,7 @@ import ( "github.com/arduino/arduino-app-cli/internal/api/models" "github.com/arduino/arduino-app-cli/internal/orchestrator/app" "github.com/arduino/arduino-app-cli/internal/orchestrator/bricks" + "github.com/arduino/arduino-app-cli/internal/orchestrator/config" "github.com/arduino/arduino-app-cli/internal/render" ) @@ -153,14 +154,15 @@ func HandleBrickCreate( } } -func HandleBrickDetails(brickService *bricks.Service) http.HandlerFunc { +func HandleBrickDetails(brickService *bricks.Service, idProvider *app.IDProvider, + cfg config.Configuration) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("brickID") if id == "" { render.EncodeResponse(w, http.StatusBadRequest, models.ErrorResponse{Details: "id must be set"}) return } - res, err := brickService.BricksDetails(id) + res, err := brickService.BricksDetails(id, idProvider, cfg) if err != nil { if errors.Is(err, bricks.ErrBrickNotFound) { details := fmt.Sprintf("brick with id %q not found", id) diff --git a/internal/api/handlers/monitor.go b/internal/api/handlers/monitor.go index e421283f..5aaf8f4d 100644 --- a/internal/api/handlers/monitor.go +++ b/internal/api/handlers/monitor.go @@ -83,24 +83,53 @@ func monitorStream(mon net.Conn, ws *websocket.Conn) { }() } +func splitOrigin(origin string) (scheme, host, port string, err error) { + parts := strings.SplitN(origin, "://", 2) + if len(parts) != 2 { + return "", "", "", fmt.Errorf("invalid origin format: %s", origin) + } + scheme = parts[0] + hostPort := parts[1] + hostParts := strings.SplitN(hostPort, ":", 2) + host = hostParts[0] + if len(hostParts) == 2 { + port = hostParts[1] + } else { + port = "*" + } + return scheme, host, port, nil +} + func checkOrigin(origin string, allowedOrigins []string) bool { + scheme, host, port, err := splitOrigin(origin) + if err != nil { + slog.Error("WebSocket origin check failed", slog.String("origin", origin), slog.String("error", err.Error())) + return false + } for _, allowed := range allowedOrigins { - if strings.HasSuffix(allowed, "*") { - // String ends with *, match the prefix - if strings.HasPrefix(origin, strings.TrimSuffix(allowed, "*")) { - return true - } - } else { - // Exact match - if allowed == origin { - return true - } + allowedScheme, allowedHost, allowedPort, err := splitOrigin(allowed) + if err != nil { + panic(err) + } + if allowedScheme != scheme { + continue } + if allowedHost != host && allowedHost != "*" { + continue + } + if allowedPort != port && allowedPort != "*" { + continue + } + return true } + slog.Error("WebSocket origin check failed", slog.String("origin", origin)) return false } func HandleMonitorWS(allowedOrigins []string) http.HandlerFunc { + // Do a dry-run of checkorigin, so it can panic if misconfigured now, not on first request + _ = checkOrigin("http://localhost", allowedOrigins) + upgrader := websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, diff --git a/internal/api/handlers/monitor_test.go b/internal/api/handlers/monitor_test.go new file mode 100644 index 00000000..e54c7f24 --- /dev/null +++ b/internal/api/handlers/monitor_test.go @@ -0,0 +1,52 @@ +// 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 handlers + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCheckOrigin(t *testing.T) { + origins := []string{ + "wails://wails", + "wails://wails.localhost:*", + "http://wails.localhost:*", + "http://localhost:*", + "https://localhost:*", + "http://example.com:7000", + "https://*:443", + } + + allow := func(origin string) { + require.True(t, checkOrigin(origin, origins), "Expected origin %s to be allowed", origin) + } + deny := func(origin string) { + require.False(t, checkOrigin(origin, origins), "Expected origin %s to be denied", origin) + } + allow("wails://wails") + allow("wails://wails:8000") + allow("http://wails.localhost") + allow("http://localhost") + allow("http://example.com:7000") + allow("https://blah.com:443") + deny("wails://evil.com") + deny("https://wails.localhost:8000") + deny("http://example.com:8000") + deny("http://blah.com:443") + deny("https://blah.com:8080") +} diff --git a/internal/e2e/client/client.gen.go b/internal/e2e/client/client.gen.go index 9e12c9ec..f6094430 100644 --- a/internal/e2e/client/client.gen.go +++ b/internal/e2e/client/client.gen.go @@ -119,6 +119,14 @@ type AppReference struct { Name *string `json:"name,omitempty"` } +// BrickConfigVariable defines model for BrickConfigVariable. +type BrickConfigVariable struct { + Description *string `json:"description,omitempty"` + Name *string `json:"name,omitempty"` + Required *bool `json:"required,omitempty"` + Value *string `json:"value,omitempty"` +} + // BrickCreateUpdateRequest defines model for BrickCreateUpdateRequest. type BrickCreateUpdateRequest struct { Model *string `json:"model"` @@ -142,12 +150,15 @@ type BrickDetailsResult struct { // BrickInstance defines model for BrickInstance. type BrickInstance struct { - Author *string `json:"author,omitempty"` - Category *string `json:"category,omitempty"` - Id *string `json:"id,omitempty"` - Model *string `json:"model,omitempty"` - Name *string `json:"name,omitempty"` - Status *string `json:"status,omitempty"` + Author *string `json:"author,omitempty"` + Category *string `json:"category,omitempty"` + ConfigVariables *[]BrickConfigVariable `json:"config_variables,omitempty"` + Id *string `json:"id,omitempty"` + Model *string `json:"model,omitempty"` + Name *string `json:"name,omitempty"` + Status *string `json:"status,omitempty"` + + // Variables Deprecated: use config_variables instead. This field is kept for backward compatibility. Variables *map[string]string `json:"variables,omitempty"` } diff --git a/internal/e2e/daemon/app_test.go b/internal/e2e/daemon/app_test.go index bc6556e6..1de8ff64 100644 --- a/internal/e2e/daemon/app_test.go +++ b/internal/e2e/daemon/app_test.go @@ -470,7 +470,7 @@ func TestDeleteApp(t *testing.T) { t.Run("DeletingExampleApp_Fail", func(t *testing.T) { var actualResponseBody models.ErrorResponse - deleteResp, err := httpClient.DeleteApp(t.Context(), noExisitingExample) + deleteResp, err := httpClient.DeleteApp(t.Context(), "ZXhhbXBsZXM6anVzdGJsaW5f") require.NoError(t, err) defer deleteResp.Body.Close() @@ -818,7 +818,7 @@ func TestAppPorts(t *testing.T) { respBrick, err := httpClient.UpsertAppBrickInstanceWithResponse( t.Context(), *createResp.JSON201.Id, - StreamLitUi, + "arduino:streamlit_ui", client.BrickCreateUpdateRequest{}, func(ctx context.Context, req *http.Request) error { return nil }, ) diff --git a/internal/e2e/daemon/brick_test.go b/internal/e2e/daemon/brick_test.go index ab04859f..fa1cab40 100644 --- a/internal/e2e/daemon/brick_test.go +++ b/internal/e2e/daemon/brick_test.go @@ -24,13 +24,44 @@ import ( "github.com/arduino/go-paths-helper" "github.com/stretchr/testify/require" + "go.bug.st/f" "github.com/arduino/arduino-app-cli/internal/api/models" + "github.com/arduino/arduino-app-cli/internal/e2e/client" "github.com/arduino/arduino-app-cli/internal/orchestrator/bricksindex" "github.com/arduino/arduino-app-cli/internal/orchestrator/config" "github.com/arduino/arduino-app-cli/internal/store" ) +func setupTestBrick(t *testing.T) (*client.CreateAppResp, *client.ClientWithResponses) { + httpClient := GetHttpclient(t) + createResp, err := httpClient.CreateAppWithResponse( + t.Context(), + &client.CreateAppParams{SkipSketch: f.Ptr(true)}, + client.CreateAppRequest{ + Icon: f.Ptr("💻"), + Name: "test-app", + Description: f.Ptr("My app description"), + }, + func(ctx context.Context, req *http.Request) error { return nil }, + ) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, createResp.StatusCode()) + require.NotNil(t, createResp.JSON201) + + resp, err := httpClient.UpsertAppBrickInstanceWithResponse( + t.Context(), + *createResp.JSON201.Id, + ImageClassifactionBrickID, + client.BrickCreateUpdateRequest{Model: f.Ptr("mobilenet-image-classification")}, + func(ctx context.Context, req *http.Request) error { return nil }, + ) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode()) + + return createResp, httpClient +} + func TestBricksList(t *testing.T) { httpClient := GetHttpclient(t) @@ -56,8 +87,8 @@ func TestBricksList(t *testing.T) { } func TestBricksDetails(t *testing.T) { + _, httpClient := setupTestBrick(t) - httpClient := GetHttpclient(t) t.Run("should return 404 Not Found for an invalid brick ID", func(t *testing.T) { invalidBrickID := "notvalidBrickId" var actualBody models.ErrorResponse @@ -76,6 +107,14 @@ func TestBricksDetails(t *testing.T) { t.Run("should return 200 OK with full details for a valid brick ID", func(t *testing.T) { validBrickID := "arduino:image_classification" + expectedUsedByApps := []client.AppReference{ + { + Id: f.Ptr("dXNlcjp0ZXN0LWFwcA"), + Name: f.Ptr("test-app"), + Icon: f.Ptr("💻"), + }, + } + response, err := httpClient.GetBrickDetailsWithResponse(t.Context(), validBrickID, func(ctx context.Context, req *http.Request) error { return nil }) require.NoError(t, err) require.Equal(t, http.StatusOK, response.StatusCode(), "status code should be 200 ok") @@ -92,6 +131,7 @@ func TestBricksDetails(t *testing.T) { require.Equal(t, "path to the model file", *(*response.JSON200.Variables)["EI_CLASSIFICATION_MODEL"].Description) require.Equal(t, false, *(*response.JSON200.Variables)["EI_CLASSIFICATION_MODEL"].Required) require.NotEmpty(t, *response.JSON200.Readme) - require.Nil(t, response.JSON200.UsedByApps) + require.NotNil(t, response.JSON200.UsedByApps, "UsedByApps should not be nil") + require.Equal(t, expectedUsedByApps, *(response.JSON200.UsedByApps)) }) } diff --git a/internal/e2e/daemon/const.go b/internal/e2e/daemon/const.go index f656fd0f..3bb35899 100644 --- a/internal/e2e/daemon/const.go +++ b/internal/e2e/daemon/const.go @@ -16,11 +16,7 @@ package daemon const ( - ImageClassifactionBrickID = "arduino:image_classification" - StreamLitUi = "arduino:streamlit_ui" - expectedDetailsAppNotfound = "unable to find the app" - expectedDetailsAppInvalidAppId = "invalid app id" - noExistingApp = "dXNlcjp0ZXN0LWFwcAw" - malformedAppId = "this-is-definitely-not-base64" - noExisitingExample = "ZXhhbXBsZXM6anVzdGJsaW5f" + ImageClassifactionBrickID = "arduino:image_classification" + noExistingApp = "dXNlcjp0ZXN0LWFwcAw" + malformedAppId = "this-is-definitely-not-base64" ) diff --git a/internal/e2e/daemon/instance_bricks_test.go b/internal/e2e/daemon/instance_bricks_test.go index 3399476c..c210a9e6 100644 --- a/internal/e2e/daemon/instance_bricks_test.go +++ b/internal/e2e/daemon/instance_bricks_test.go @@ -31,6 +31,28 @@ import ( "github.com/arduino/arduino-app-cli/internal/e2e/client" ) +const ( + expectedDetailsAppInvalidAppId = "invalid app id" + expectedDetailsAppNotfound = "unable to find the app" +) + +var ( + expectedConfigVariables = []client.BrickConfigVariable{ + { + Description: f.Ptr("path to the custom model directory"), + Name: f.Ptr("CUSTOM_MODEL_PATH"), + Required: f.Ptr(false), + Value: f.Ptr("/home/arduino/.arduino-bricks/ei-models"), + }, + { + Description: f.Ptr("path to the model file"), + Name: f.Ptr("EI_CLASSIFICATION_MODEL"), + Required: f.Ptr(false), + Value: f.Ptr("/models/ootb/ei/mobilenet-v2-224px.eim"), + }, + } +) + func setupTestApp(t *testing.T) (*client.CreateAppResp, *client.ClientWithResponses) { httpClient := GetHttpclient(t) createResp, err := httpClient.CreateAppWithResponse( @@ -68,6 +90,7 @@ func TestGetAppBrickInstances(t *testing.T) { require.NoError(t, err) require.Len(t, *brickInstances.JSON200.Bricks, 1) require.Equal(t, ImageClassifactionBrickID, *(*brickInstances.JSON200.Bricks)[0].Id) + require.Equal(t, expectedConfigVariables, *(*brickInstances.JSON200.Bricks)[0].ConfigVariables) }) @@ -111,6 +134,7 @@ func TestGetAppBrickInstanceById(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, brickInstance.JSON200) require.Equal(t, ImageClassifactionBrickID, *brickInstance.JSON200.Id) + require.Equal(t, expectedConfigVariables, (*brickInstance.JSON200.ConfigVariables)) }) t.Run("GetAppBrickInstanceByBrickID_InvalidAppID_Fails", func(t *testing.T) { diff --git a/internal/e2e/updatetest/helpers.go b/internal/e2e/updatetest/helpers.go new file mode 100644 index 00000000..410a9999 --- /dev/null +++ b/internal/e2e/updatetest/helpers.go @@ -0,0 +1,331 @@ +package updatetest + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "iter" + "log" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func fetchDebPackageLatest(t *testing.T, path, repo string) string { + t.Helper() + + repo = fmt.Sprintf("github.com/arduino/%s", repo) + cmd := exec.Command( + "gh", "release", "list", + "--repo", repo, + "--exclude-pre-releases", + "--limit", "1", + ) + + output, err := cmd.CombinedOutput() + if err != nil { + log.Fatalf("command failed: %v\nOutput: %s", err, output) + } + + fmt.Println(string(output)) + + fields := strings.Fields(string(output)) + if len(fields) == 0 { + log.Fatal("could not parse tag from gh release list output") + } + tag := fields[0] + + fmt.Println("Detected tag:", tag) + cmd2 := exec.Command( + "gh", "release", "download", + tag, + "--repo", repo, + "--pattern", "*.deb", + "--dir", path, + ) + + out, err := cmd2.CombinedOutput() + if err != nil { + log.Fatalf("download failed: %v\nOutput: %s", err, out) + } + + return tag + +} + +func buildDebVersion(t *testing.T, storePath, tagVersion, arch string) { + t.Helper() + cwd, err := os.Getwd() + if err != nil { + panic(err) + } + outputDir := filepath.Join(cwd, storePath) + + tagVersion = fmt.Sprintf("VERSION=%s", tagVersion) + arch = fmt.Sprintf("ARCH=%s", arch) + outputDir = fmt.Sprintf("OUTPUT=%s", outputDir) + + cmd := exec.Command( + "go", "tool", "task", "build-deb", + tagVersion, + arch, + outputDir, + ) + + if err := cmd.Run(); err != nil { + log.Fatalf("failed to run build command: %v", err) + } +} + +func genMajorTag(t *testing.T, tag string) string { + t.Helper() + + parts := strings.Split(tag, ".") + last := parts[len(parts)-1] + + lastNum, _ := strconv.Atoi(strings.TrimPrefix(last, "v")) + lastNum++ + + parts[len(parts)-1] = strconv.Itoa(lastNum) + newTag := strings.Join(parts, ".") + + return newTag +} + +func genMinorTag(t *testing.T, tag string) string { + t.Helper() + + parts := strings.Split(tag, ".") + last := parts[len(parts)-1] + + lastNum, _ := strconv.Atoi(strings.TrimPrefix(last, "v")) + if lastNum > 0 { + lastNum-- + } + + parts[len(parts)-1] = strconv.Itoa(lastNum) + newTag := strings.Join(parts, ".") + + if !strings.HasPrefix(newTag, "v") { + newTag = "v" + newTag + } + return newTag +} + +func buildDockerImage(t *testing.T, dockerfile, name, arch string) { + t.Helper() + + arch = fmt.Sprintf("ARCH=%s", arch) + + cmd := exec.Command("docker", "build", "--build-arg", arch, "-t", name, "-f", dockerfile, ".") + // Capture both stdout and stderr + var out bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + fmt.Printf("❌ Docker build failed: %v\n", err) + fmt.Printf("---- STDERR ----\n%s\n", stderr.String()) + fmt.Printf("---- STDOUT ----\n%s\n", out.String()) + return + } + + fmt.Println("✅ Docker build succeeded!") +} + +func startDockerContainer(t *testing.T, containerName string, containerImageName string) { + t.Helper() + + cmd := exec.Command( + "docker", "run", "--rm", "-d", + "-p", "8800:8800", + "--privileged", + "--cgroupns=host", + "--network", "host", + "-v", "/sys/fs/cgroup:/sys/fs/cgroup:rw", + "-v", "/var/run/docker.sock:/var/run/docker.sock", + "-e", "DOCKER_HOST=unix:///var/run/docker.sock", + "--name", containerName, + containerImageName, + ) + + if err := cmd.Run(); err != nil { + t.Fatalf("failed to run container: %v", err) + } + +} + +func getAppCliVersion(t *testing.T, containerName string) string { + t.Helper() + + cmd := exec.Command( + "docker", "exec", + "--user", "arduino", + containerName, + "arduino-app-cli", "version", "--format", "json", + ) + output, err := cmd.CombinedOutput() + if err != nil { + log.Fatalf("command failed: %v\nOutput: %s", err, output) + } + + var version struct { + Version string `json:"version"` + DaemonVersion string `json:"daemon_version"` + } + err = json.Unmarshal(output, &version) + require.NoError(t, err) + // TODO to enable after 0.6.7 + // require.Equal(t, version.Version, version.DaemonVersion, "client and daemon versions should match") + require.NotEmpty(t, version.Version) + return version.Version + +} + +func runSystemUpdate(t *testing.T, containerName string) { + t.Helper() + + cmd := exec.Command( + "docker", "exec", + "--user", "arduino", + containerName, + "arduino-app-cli", "system", "update", "--yes", + ) + output, err := cmd.CombinedOutput() + require.NoError(t, err, "system update failed: %s", output) + t.Logf("system update output: %s", output) +} + +func stopDockerContainer(t *testing.T, containerName string) { + t.Helper() + + cleanupCmd := exec.Command("docker", "rm", "-f", containerName) + + fmt.Println("🧹 Removing Docker container " + containerName) + if err := cleanupCmd.Run(); err != nil { + fmt.Printf("⚠️ Warning: could not remove container (might not exist): %v\n", err) + } + +} + +func putUpdateRequest(t *testing.T, host string) { + + t.Helper() + + url := fmt.Sprintf("http://%s/v1/system/update/apply", host) + + req, err := http.NewRequest(http.MethodPut, url, nil) + if err != nil { + log.Fatalf("Error creating request: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + log.Fatalf("Error sending request: %v", err) + } + defer resp.Body.Close() + + require.Equal(t, 202, resp.StatusCode) + +} + +func NewSSEClient(ctx context.Context, method, url string) iter.Seq2[Event, error] { + return func(yield func(Event, error) bool) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + _ = yield(Event{}, err) + return + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + _ = yield(Event{}, err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + _ = yield(Event{}, fmt.Errorf("got response status code %d", resp.StatusCode)) + return + } + + reader := bufio.NewReader(resp.Body) + + evt := Event{} + for { + line, err := reader.ReadString('\n') + if err != nil { + _ = yield(Event{}, err) + return + } + switch { + case strings.HasPrefix(line, "data:"): + evt.Data = []byte(strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + case strings.HasPrefix(line, "event:"): + evt.Event = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + case strings.HasPrefix(line, "id:"): + evt.ID = strings.TrimSpace(strings.TrimPrefix(line, "id:")) + case strings.HasPrefix(line, "\n"): + if !yield(evt, nil) { + return + } + evt = Event{} + default: + _ = yield(Event{}, fmt.Errorf("unknown line: '%s'", line)) + return + } + } + } +} + +type Event struct { + ID string + Event string + Data []byte // json +} + +func waitForPort(t *testing.T, host string, timeout time.Duration) { // nolint:unparam + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", host, 500*time.Millisecond) + if err == nil { + _ = conn.Close() + t.Logf("Server is up on %s", host) + return + } + time.Sleep(200 * time.Millisecond) + } + t.Fatalf("Server at %s did not start within %v", host, timeout) +} + +func waitForUpgrade(t *testing.T, host string) { + t.Helper() + + url := fmt.Sprintf("http://%s/v1/system/update/events", host) + + itr := NewSSEClient(t.Context(), "GET", url) + for event, err := range itr { + require.NoError(t, err) + t.Logf("Received event: ID=%s, Event=%s, Data=%s\n", event.ID, event.Event, string(event.Data)) + if event.Event == "restarting" { + break + } + } + +} diff --git a/internal/e2e/updatetest/test.Dockerfile b/internal/e2e/updatetest/test.Dockerfile new file mode 100644 index 00000000..578e71c0 --- /dev/null +++ b/internal/e2e/updatetest/test.Dockerfile @@ -0,0 +1,33 @@ +FROM debian:trixie + +RUN apt update && \ + apt install -y systemd systemd-sysv dbus \ + sudo docker.io ca-certificates curl gnupg \ + dpkg-dev apt-utils adduser gzip && \ + rm -rf /var/lib/apt/lists/* + +ARG ARCH=amd64 + +COPY build/stable/arduino-app-cli*_${ARCH}.deb /tmp/stable.deb +COPY build/arduino-app-cli*_${ARCH}.deb /tmp/unstable.deb +COPY build/stable/arduino-router*_${ARCH}.deb /tmp/router.deb + +RUN apt update && apt install -y /tmp/stable.deb /tmp/router.deb \ + && rm /tmp/stable.deb /tmp/router.deb \ + && mkdir -p /var/www/html/myrepo/dists/trixie/main/binary-${ARCH} \ + && mv /tmp/unstable.deb /var/www/html/myrepo/dists/trixie/main/binary-${ARCH}/ + +WORKDIR /var/www/html/myrepo +RUN dpkg-scanpackages dists/trixie/main/binary-${ARCH} /dev/null | gzip -9c > dists/trixie/main/binary-${ARCH}/Packages.gz +WORKDIR / + +RUN usermod -s /bin/bash arduino || true +RUN mkdir -p /home/arduino && chown -R arduino:arduino /home/arduino +RUN usermod -aG docker arduino + +RUN echo "deb [trusted=yes arch=${ARCH}] file:/var/www/html/myrepo trixie main" \ + > /etc/apt/sources.list.d/my-mock-repo.list + +EXPOSE 8800 +# CMD: systemd must be PID 1 +CMD ["/sbin/init"] diff --git a/internal/e2e/updatetest/update_test.go b/internal/e2e/updatetest/update_test.go new file mode 100644 index 00000000..061a8ff6 --- /dev/null +++ b/internal/e2e/updatetest/update_test.go @@ -0,0 +1,122 @@ +package updatetest + +import ( + "fmt" + "os" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var arch = runtime.GOARCH + +const dockerFile = "test.Dockerfile" +const daemonHost = "127.0.0.1:8800" + +func TestUpdatePackage(t *testing.T) { + fmt.Printf("***** ARCH %s ***** \n", arch) + + t.Run("Stable To Current", func(t *testing.T) { + t.Cleanup(func() { os.RemoveAll("build") }) + + tagAppCli := fetchDebPackageLatest(t, "build/stable", "arduino-app-cli") + fetchDebPackageLatest(t, "build/stable", "arduino-router") + majorTag := genMajorTag(t, tagAppCli) + + fmt.Printf("Updating from stable version %s to unstable version %s \n", tagAppCli, majorTag) + fmt.Printf("Building local deb version %s \n", majorTag) + buildDebVersion(t, "build", majorTag, arch) + + const dockerImageName = "apt-test-update-image" + fmt.Println("**** BUILD docker image *****") + buildDockerImage(t, dockerFile, dockerImageName, arch) + //TODO: t cleanup remove docker image + + t.Run("CLI Command", func(t *testing.T) { + const containerName = "apt-test-update" + t.Cleanup(func() { stopDockerContainer(t, containerName) }) + + fmt.Println("**** RUN docker image *****") + startDockerContainer(t, containerName, dockerImageName) + waitForPort(t, daemonHost, 5*time.Second) + + preUpdateVersion := getAppCliVersion(t, containerName) + require.Equal(t, "v"+preUpdateVersion, tagAppCli) + runSystemUpdate(t, containerName) + postUpdateVersion := getAppCliVersion(t, containerName) + require.Equal(t, "v"+postUpdateVersion, majorTag) + }) + + t.Run("HTTP Request", func(t *testing.T) { + const containerName = "apt-test-update-http" + t.Cleanup(func() { stopDockerContainer(t, containerName) }) + + startDockerContainer(t, containerName, dockerImageName) + waitForPort(t, daemonHost, 5*time.Second) + + preUpdateVersion := getAppCliVersion(t, containerName) + require.Equal(t, "v"+preUpdateVersion, tagAppCli) + + putUpdateRequest(t, daemonHost) + waitForUpgrade(t, daemonHost) + + postUpdateVersion := getAppCliVersion(t, containerName) + require.Equal(t, "v"+postUpdateVersion, majorTag) + }) + + }) + + t.Run("CurrentToStable", func(t *testing.T) { + t.Cleanup(func() { os.RemoveAll("build") }) + + tagAppCli := fetchDebPackageLatest(t, "build", "arduino-app-cli") + fetchDebPackageLatest(t, "build/stable", "arduino-router") + minorTag := genMinorTag(t, tagAppCli) + + fmt.Printf("Updating from unstable version %s to stable version %s \n", minorTag, tagAppCli) + fmt.Printf("Building local deb version %s \n", minorTag) + buildDebVersion(t, "build/stable", minorTag, arch) + + fmt.Println("**** BUILD docker image *****") + const dockerImageName = "test-apt-update-unstable-image" + + buildDockerImage(t, dockerFile, dockerImageName, arch) + //TODO: t cleanup remove docker image + + t.Run("CLI Command", func(t *testing.T) { + const containerName = "apt-test-update-unstable" + t.Cleanup(func() { stopDockerContainer(t, containerName) }) + + fmt.Println("**** RUN docker image *****") + startDockerContainer(t, containerName, dockerImageName) + waitForPort(t, daemonHost, 5*time.Second) + + preUpdateVersion := getAppCliVersion(t, containerName) + require.Equal(t, "v"+preUpdateVersion, minorTag) + runSystemUpdate(t, containerName) + postUpdateVersion := getAppCliVersion(t, containerName) + require.Equal(t, "v"+postUpdateVersion, tagAppCli) + }) + + t.Run("HTTP Request", func(t *testing.T) { + const containerName = "apt-test-update--unstable-http" + t.Cleanup(func() { stopDockerContainer(t, containerName) }) + + startDockerContainer(t, containerName, dockerImageName) + waitForPort(t, daemonHost, 5*time.Second) + + preUpdateVersion := getAppCliVersion(t, containerName) + require.Equal(t, "v"+preUpdateVersion, minorTag) + + putUpdateRequest(t, daemonHost) + waitForUpgrade(t, daemonHost) + + postUpdateVersion := getAppCliVersion(t, containerName) + require.Equal(t, "v"+postUpdateVersion, tagAppCli) + }) + + }) + +} diff --git a/internal/orchestrator/app/app.go b/internal/orchestrator/app/app.go index 165b0062..c40c9009 100644 --- a/internal/orchestrator/app/app.go +++ b/internal/orchestrator/app/app.go @@ -48,7 +48,7 @@ func Load(appPath string) (ArduinoApp, error) { return ArduinoApp{}, fmt.Errorf("app path is not valid: %w", err) } if !exist { - return ArduinoApp{}, fmt.Errorf("no such file or directory: %s", path) + return ArduinoApp{}, fmt.Errorf("app path must be a directory: %s", path) } path, err = path.Abs() if err != nil { diff --git a/internal/orchestrator/app/app_test.go b/internal/orchestrator/app/app_test.go index 1256c648..47e3f53f 100644 --- a/internal/orchestrator/app/app_test.go +++ b/internal/orchestrator/app/app_test.go @@ -25,13 +25,26 @@ import ( ) func TestLoad(t *testing.T) { - t.Run("empty", func(t *testing.T) { + t.Run("it fails if the app path is empty", func(t *testing.T) { app, err := Load("") assert.Error(t, err) assert.Empty(t, app) + assert.Contains(t, err.Error(), "empty app path") }) - t.Run("AppSimple", func(t *testing.T) { + t.Run("it fails if the app path exist but it's a file", func(t *testing.T) { + _, err := Load("testdata/app.yaml") + assert.Error(t, err) + assert.Contains(t, err.Error(), "app path must be a directory") + }) + + t.Run("it fails if the app path does not exist", func(t *testing.T) { + _, err := Load("testdata/this-folder-does-not-exist") + assert.Error(t, err) + assert.Contains(t, err.Error(), "app path is not valid") + }) + + t.Run("it loads an app correctly", func(t *testing.T) { app, err := Load("testdata/AppSimple") assert.NoError(t, err) assert.NotEmpty(t, app) diff --git a/internal/orchestrator/bricks/bricks.go b/internal/orchestrator/bricks/bricks.go index 4e08b2c2..e8dea9da 100644 --- a/internal/orchestrator/bricks/bricks.go +++ b/internal/orchestrator/bricks/bricks.go @@ -18,7 +18,7 @@ package bricks import ( "errors" "fmt" - "maps" + "log/slog" "slices" "github.com/arduino/go-paths-helper" @@ -26,6 +26,7 @@ import ( "github.com/arduino/arduino-app-cli/internal/orchestrator/app" "github.com/arduino/arduino-app-cli/internal/orchestrator/bricksindex" + "github.com/arduino/arduino-app-cli/internal/orchestrator/config" "github.com/arduino/arduino-app-cli/internal/orchestrator/modelsindex" "github.com/arduino/arduino-app-cli/internal/store" ) @@ -78,15 +79,20 @@ func (s *Service) AppBrickInstancesList(a *app.ArduinoApp) (AppBrickInstancesRes if !found { return AppBrickInstancesResult{}, fmt.Errorf("brick not found with id %s", brickInstance.ID) } + + variablesMap, configVariables := getBrickConfigDetails(brick, brickInstance.Variables) + res.BrickInstances[i] = BrickInstance{ - ID: brick.ID, - Name: brick.Name, - Author: "Arduino", // TODO: for now we only support our bricks - Category: brick.Category, - Status: "installed", - ModelID: brickInstance.Model, // TODO: in case is not set by the user, should we return the default model? - Variables: brickInstance.Variables, // TODO: do we want to show also the default value of not explicitly set variables? + ID: brick.ID, + Name: brick.Name, + Author: "Arduino", // TODO: for now we only support our bricks + Category: brick.Category, + Status: "installed", + ModelID: brickInstance.Model, // TODO: in case is not set by the user, should we return the default model? + Variables: variablesMap, // TODO: do we want to show also the default value of not explicitly set variables? + ConfigVariables: configVariables, } + } return res, nil } @@ -102,12 +108,7 @@ func (s *Service) AppBrickInstanceDetails(a *app.ArduinoApp, brickID string) (Br return BrickInstance{}, fmt.Errorf("brick %s not added in the app", brickID) } - variables := make(map[string]string, len(brick.Variables)) - for _, v := range brick.Variables { - variables[v.Name] = v.DefaultValue - } - // Add/Update the variables with the ones from the app descriptor - maps.Copy(variables, a.Descriptor.Bricks[brickIndex].Variables) + variables, configVariables := getBrickConfigDetails(brick, a.Descriptor.Bricks[brickIndex].Variables) modelID := a.Descriptor.Bricks[brickIndex].Model if modelID == "" { @@ -115,17 +116,45 @@ func (s *Service) AppBrickInstanceDetails(a *app.ArduinoApp, brickID string) (Br } return BrickInstance{ - ID: brickID, - Name: brick.Name, - Author: "Arduino", // TODO: for now we only support our bricks - Category: brick.Category, - Status: "installed", // For now every Arduino brick are installed - Variables: variables, - ModelID: modelID, + ID: brickID, + Name: brick.Name, + Author: "Arduino", // TODO: for now we only support our bricks + Category: brick.Category, + Status: "installed", // For now every Arduino brick are installed + Variables: variables, + ConfigVariables: configVariables, + ModelID: modelID, }, nil } -func (s *Service) BricksDetails(id string) (BrickDetailsResult, error) { +func getBrickConfigDetails( + brick *bricksindex.Brick, userVariables map[string]string, +) (map[string]string, []BrickConfigVariable) { + variablesMap := make(map[string]string, len(brick.Variables)) + variableDetails := make([]BrickConfigVariable, 0, len(brick.Variables)) + + for _, v := range brick.Variables { + finalValue := v.DefaultValue + + userValue, ok := userVariables[v.Name] + if ok { + finalValue = userValue + } + variablesMap[v.Name] = finalValue + + variableDetails = append(variableDetails, BrickConfigVariable{ + Name: v.Name, + Value: finalValue, + Description: v.Description, + Required: v.IsRequired(), + }) + } + + return variablesMap, variableDetails +} + +func (s *Service) BricksDetails(id string, idProvider *app.IDProvider, + cfg config.Configuration) (BrickDetailsResult, error) { brick, found := s.bricksIndex.FindBrickByID(id) if !found { return BrickDetailsResult{}, ErrBrickNotFound @@ -160,6 +189,11 @@ func (s *Service) BricksDetails(id string) (BrickDetailsResult, error) { } }) + usedByApps, err := getUsedByApps(cfg, brick.ID, idProvider) + if err != nil { + return BrickDetailsResult{}, fmt.Errorf("unable to get used by apps: %w", err) + } + return BrickDetailsResult{ ID: id, Name: brick.Name, @@ -171,9 +205,63 @@ func (s *Service) BricksDetails(id string) (BrickDetailsResult, error) { Readme: readme, ApiDocsPath: apiDocsPath, CodeExamples: codeExamples, + UsedByApps: usedByApps, }, nil } +func getUsedByApps( + cfg config.Configuration, brickId string, idProvider *app.IDProvider) ([]AppReference, error) { + var ( + pathsToExplore paths.PathList + appPaths paths.PathList + ) + pathsToExplore.Add(cfg.ExamplesDir()) + pathsToExplore.Add(cfg.AppsDir()) + usedByApps := []AppReference{} + + for _, p := range pathsToExplore { + res, err := p.ReadDirRecursiveFiltered(func(file *paths.Path) bool { + if file.Base() == ".cache" { + return false + } + if file.Join("app.yaml").NotExist() && file.Join("app.yml").NotExist() { + return true + } + return false + }, paths.FilterDirectories(), paths.FilterOutNames("python", "sketch", ".cache")) + if err != nil { + slog.Error("unable to list apps", slog.String("error", err.Error())) + return usedByApps, err + } + appPaths.AddAllMissing(res) + } + + for _, file := range appPaths { + app, err := app.Load(file.String()) + if err != nil { + // we are not considering the broken apps + slog.Warn("unable to parse app.yaml, skipping", "path", file.String(), "error", err.Error()) + continue + } + + for _, b := range app.Descriptor.Bricks { + if b.ID == brickId { + id, err := idProvider.IDFromPath(app.FullPath) + if err != nil { + return usedByApps, fmt.Errorf("failed to get app ID for %s: %w", app.FullPath, err) + } + usedByApps = append(usedByApps, AppReference{ + Name: app.Name, + ID: id.String(), + Icon: app.Descriptor.Icon, + }) + break + } + } + } + return usedByApps, nil +} + type BrickCreateUpdateRequest struct { ID string `json:"-"` Model *string `json:"model"` @@ -186,25 +274,24 @@ func (s *Service) BrickCreate( ) error { brick, present := s.bricksIndex.FindBrickByID(req.ID) if !present { - return fmt.Errorf("brick not found with id %s", req.ID) + return fmt.Errorf("brick %q not found", req.ID) } for name, reqValue := range req.Variables { value, exist := brick.GetVariable(name) if !exist { - return errors.New("variable does not exist") + return fmt.Errorf("variable %q does not exist on brick %q", name, brick.ID) } if value.DefaultValue == "" && reqValue == "" { - return errors.New("variable default value cannot be empty") + return fmt.Errorf("variable %q cannot be empty", name) } } for _, brickVar := range brick.Variables { if brickVar.DefaultValue == "" { if _, exist := req.Variables[brickVar.Name]; !exist { - return errors.New("variable does not exist") + return fmt.Errorf("required variable %q is mandatory", brickVar.Name) } - return errors.New("variable default value cannot be empty") } } @@ -227,25 +314,20 @@ func (s *Service) BrickCreate( if idx == -1 { return fmt.Errorf("model %s does not exsist", *req.Model) } - brickInstance.Model = models[idx].ID } brickInstance.Variables = req.Variables if brickIndex == -1 { - appCurrent.Descriptor.Bricks = append(appCurrent.Descriptor.Bricks, brickInstance) - } else { appCurrent.Descriptor.Bricks[brickIndex] = brickInstance - } err := appCurrent.Save() if err != nil { return fmt.Errorf("cannot save brick instance with id %s", req.ID) } - return nil } diff --git a/internal/orchestrator/bricks/bricks_test.go b/internal/orchestrator/bricks/bricks_test.go new file mode 100644 index 00000000..2f03d96b --- /dev/null +++ b/internal/orchestrator/bricks/bricks_test.go @@ -0,0 +1,192 @@ +// 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 bricks + +import ( + "testing" + + "github.com/arduino/go-paths-helper" + "github.com/stretchr/testify/require" + "go.bug.st/f" + + "github.com/arduino/arduino-app-cli/internal/orchestrator/app" + "github.com/arduino/arduino-app-cli/internal/orchestrator/bricksindex" +) + +func TestBrickCreate(t *testing.T) { + bricksIndex, err := bricksindex.GenerateBricksIndexFromFile(paths.New("testdata")) + require.Nil(t, err) + brickService := NewService(nil, bricksIndex, nil) + + t.Run("fails if brick id does not exist", func(t *testing.T) { + err = brickService.BrickCreate(BrickCreateUpdateRequest{ID: "not-existing-id"}, f.Must(app.Load("testdata/dummy-app"))) + require.Error(t, err) + require.Equal(t, "brick \"not-existing-id\" not found", err.Error()) + }) + + t.Run("fails if the requestes variable is not present in the brick definition", func(t *testing.T) { + req := BrickCreateUpdateRequest{ID: "arduino:arduino_cloud", Variables: map[string]string{ + "NON_EXISTING_VARIABLE": "some-value", + }} + err = brickService.BrickCreate(req, f.Must(app.Load("testdata/dummy-app"))) + require.Error(t, err) + require.Equal(t, "variable \"NON_EXISTING_VARIABLE\" does not exist on brick \"arduino:arduino_cloud\"", err.Error()) + }) + + t.Run("fails if a required variable is set empty", func(t *testing.T) { + req := BrickCreateUpdateRequest{ID: "arduino:arduino_cloud", Variables: map[string]string{ + "ARDUINO_DEVICE_ID": "", + "ARDUINO_SECRET": "a-secret-a", + }} + err = brickService.BrickCreate(req, f.Must(app.Load("testdata/dummy-app"))) + require.Error(t, err) + require.Equal(t, "variable \"ARDUINO_DEVICE_ID\" cannot be empty", err.Error()) + }) + + t.Run("fails if a mandatory variable is not present in the request", func(t *testing.T) { + req := BrickCreateUpdateRequest{ID: "arduino:arduino_cloud", Variables: map[string]string{ + "ARDUINO_SECRET": "a-secret-a", + }} + err = brickService.BrickCreate(req, f.Must(app.Load("testdata/dummy-app"))) + require.Error(t, err) + require.Equal(t, "required variable \"ARDUINO_DEVICE_ID\" is mandatory", err.Error()) + }) + + t.Run("the brick is added if it does not exist in the app", func(t *testing.T) { + tempDummyApp := paths.New("testdata/dummy-app.temp") + err := tempDummyApp.RemoveAll() + require.Nil(t, err) + require.Nil(t, paths.New("testdata/dummy-app").CopyDirTo(tempDummyApp)) + + req := BrickCreateUpdateRequest{ID: "arduino:dbstorage_sqlstore"} + err = brickService.BrickCreate(req, f.Must(app.Load(tempDummyApp.String()))) + require.Nil(t, err) + after, err := app.Load(tempDummyApp.String()) + require.Nil(t, err) + require.Len(t, after.Descriptor.Bricks, 2) + require.Equal(t, "arduino:dbstorage_sqlstore", after.Descriptor.Bricks[1].ID) + }) + t.Run("the variables of a brick are updated", func(t *testing.T) { + tempDummyApp := paths.New("testdata/dummy-app.brick-override.temp") + err := tempDummyApp.RemoveAll() + require.Nil(t, err) + err = paths.New("testdata/dummy-app").CopyDirTo(tempDummyApp) + require.Nil(t, err) + bricksIndex, err := bricksindex.GenerateBricksIndexFromFile(paths.New("testdata")) + require.Nil(t, err) + brickService := NewService(nil, bricksIndex, nil) + + deviceID := "this-is-a-device-id" + secret := "this-is-a-secret" + req := BrickCreateUpdateRequest{ + ID: "arduino:arduino_cloud", + Variables: map[string]string{ + "ARDUINO_DEVICE_ID": deviceID, + "ARDUINO_SECRET": secret, + }, + } + + err = brickService.BrickCreate(req, f.Must(app.Load(tempDummyApp.String()))) + require.Nil(t, err) + + after, err := app.Load(tempDummyApp.String()) + require.Nil(t, err) + require.Len(t, after.Descriptor.Bricks, 1) + require.Equal(t, "arduino:arduino_cloud", after.Descriptor.Bricks[0].ID) + require.Equal(t, deviceID, after.Descriptor.Bricks[0].Variables["ARDUINO_DEVICE_ID"]) + require.Equal(t, secret, after.Descriptor.Bricks[0].Variables["ARDUINO_SECRET"]) + }) +} + +func TestGetBrickInstanceVariableDetails(t *testing.T) { + tests := []struct { + name string + brick *bricksindex.Brick + userVariables map[string]string + expectedConfigVariables []BrickConfigVariable + expectedVariableMap map[string]string + }{ + { + name: "variable is present in the map", + brick: &bricksindex.Brick{ + Variables: []bricksindex.BrickVariable{ + {Name: "VAR1", Description: "desc"}, + }, + }, + userVariables: map[string]string{"VAR1": "value1"}, + expectedConfigVariables: []BrickConfigVariable{ + {Name: "VAR1", Value: "value1", Description: "desc", Required: true}, + }, + expectedVariableMap: map[string]string{"VAR1": "value1"}, + }, + { + name: "variable not present in the map", + brick: &bricksindex.Brick{ + Variables: []bricksindex.BrickVariable{ + {Name: "VAR1", Description: "desc"}, + }, + }, + userVariables: map[string]string{}, + expectedConfigVariables: []BrickConfigVariable{ + {Name: "VAR1", Value: "", Description: "desc", Required: true}, + }, + expectedVariableMap: map[string]string{"VAR1": ""}, + }, + { + name: "variable with default value", + brick: &bricksindex.Brick{ + Variables: []bricksindex.BrickVariable{ + {Name: "VAR1", DefaultValue: "default", Description: "desc"}, + }, + }, + userVariables: map[string]string{}, + expectedConfigVariables: []BrickConfigVariable{ + {Name: "VAR1", Value: "default", Description: "desc", Required: false}, + }, + expectedVariableMap: map[string]string{"VAR1": "default"}, + }, + { + name: "multiple variables", + brick: &bricksindex.Brick{ + Variables: []bricksindex.BrickVariable{ + {Name: "VAR1", Description: "desc1"}, + {Name: "VAR2", DefaultValue: "def2", Description: "desc2"}, + }, + }, + userVariables: map[string]string{"VAR1": "v1"}, + expectedConfigVariables: []BrickConfigVariable{ + {Name: "VAR1", Value: "v1", Description: "desc1", Required: true}, + {Name: "VAR2", Value: "def2", Description: "desc2", Required: false}, + }, + expectedVariableMap: map[string]string{"VAR1": "v1", "VAR2": "def2"}, + }, + { + name: "no variables", + brick: &bricksindex.Brick{Variables: []bricksindex.BrickVariable{}}, + userVariables: map[string]string{}, + expectedConfigVariables: []BrickConfigVariable{}, + expectedVariableMap: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actualVariableMap, actualConfigVariables := getBrickConfigDetails(tt.brick, tt.userVariables) + require.Equal(t, tt.expectedVariableMap, actualVariableMap) + require.Equal(t, tt.expectedConfigVariables, actualConfigVariables) + }) + } +} diff --git a/internal/orchestrator/bricks/testdata/.gitignore b/internal/orchestrator/bricks/testdata/.gitignore new file mode 100644 index 00000000..d4685d62 --- /dev/null +++ b/internal/orchestrator/bricks/testdata/.gitignore @@ -0,0 +1 @@ +*.temp \ No newline at end of file diff --git a/internal/orchestrator/bricks/testdata/bricks-list.yaml b/internal/orchestrator/bricks/testdata/bricks-list.yaml new file mode 100644 index 00000000..8e3114d6 --- /dev/null +++ b/internal/orchestrator/bricks/testdata/bricks-list.yaml @@ -0,0 +1,25 @@ +bricks: +- id: arduino:arduino_cloud + name: Arduino Cloud + description: Connects to Arduino Cloud + require_container: false + require_model: false + require_devices: false + mount_devices_into_container: false + ports: [] + category: null + variables: + - name: ARDUINO_DEVICE_ID + description: Arduino Cloud Device ID + - name: ARDUINO_SECRET + description: Arduino Cloud Secret +- id: arduino:dbstorage_sqlstore + name: Database - SQL + description: Simplified database storage layer for Arduino sensor data using SQLite + local database. + require_container: false + require_model: false + require_devices: false + mount_devices_into_container: false + ports: [] + category: storage diff --git a/internal/orchestrator/bricks/testdata/dummy-app/app.yaml b/internal/orchestrator/bricks/testdata/dummy-app/app.yaml new file mode 100644 index 00000000..281821c6 --- /dev/null +++ b/internal/orchestrator/bricks/testdata/dummy-app/app.yaml @@ -0,0 +1,6 @@ +name: Copy of Blinking LED from Arduino Cloud +description: Control the LED from the Arduino IoT Cloud using RPC calls +icon: ☁️ +ports: [] +bricks: +- arduino:arduino_cloud: \ No newline at end of file diff --git a/internal/orchestrator/bricks/testdata/dummy-app/python/main.py b/internal/orchestrator/bricks/testdata/dummy-app/python/main.py new file mode 100644 index 00000000..336e825c --- /dev/null +++ b/internal/orchestrator/bricks/testdata/dummy-app/python/main.py @@ -0,0 +1,2 @@ +def main(): + pass diff --git a/internal/orchestrator/bricks/types.go b/internal/orchestrator/bricks/types.go index ae803745..868c563a 100644 --- a/internal/orchestrator/bricks/types.go +++ b/internal/orchestrator/bricks/types.go @@ -34,13 +34,21 @@ type AppBrickInstancesResult struct { } type BrickInstance struct { - ID string `json:"id"` - Name string `json:"name"` - Author string `json:"author"` - Category string `json:"category"` - Status string `json:"status"` - Variables map[string]string `json:"variables,omitempty"` - ModelID string `json:"model,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Author string `json:"author"` + Category string `json:"category"` + Status string `json:"status"` + Variables map[string]string `json:"variables,omitempty" description:"Deprecated: use config_variables instead. This field is kept for backward compatibility."` + ConfigVariables []BrickConfigVariable `json:"config_variables,omitempty"` + ModelID string `json:"model,omitempty"` +} + +type BrickConfigVariable struct { + Name string `json:"name"` + Value string `json:"value"` + Description string `json:"description"` + Required bool `json:"required"` } type BrickVariable struct { diff --git a/internal/orchestrator/cache.go b/internal/orchestrator/cache.go new file mode 100644 index 00000000..62b3a950 --- /dev/null +++ b/internal/orchestrator/cache.go @@ -0,0 +1,41 @@ +package orchestrator + +import ( + "context" + "errors" + + "github.com/docker/cli/cli/command" + + "github.com/arduino/arduino-app-cli/internal/orchestrator/app" +) + +type CleanAppCacheRequest struct { + ForceClean bool +} + +var ErrCleanCacheRunningApp = errors.New("cannot remove cache of a running app") + +// CleanAppCache removes the `.cache` folder. If it detects that the app is running +// it tries to stop it first. +func CleanAppCache( + ctx context.Context, + docker command.Cli, + app app.ArduinoApp, + req CleanAppCacheRequest, +) error { + runningApp, err := getRunningApp(ctx, docker.Client()) + if err != nil { + return err + } + if runningApp != nil && runningApp.FullPath.EqualsTo(app.FullPath) { + if !req.ForceClean { + return ErrCleanCacheRunningApp + } + // We try to remove docker related resources at best effort + for range StopAndDestroyApp(ctx, app) { + // just consume the iterator + } + } + + return app.ProvisioningStateDir().RemoveAll() +} diff --git a/internal/orchestrator/modelsindex/models_index.go b/internal/orchestrator/modelsindex/models_index.go index a966a678..e18797f1 100644 --- a/internal/orchestrator/modelsindex/models_index.go +++ b/internal/orchestrator/modelsindex/models_index.go @@ -48,6 +48,7 @@ type AIModel struct { ModuleDescription string `yaml:"description"` Runner string `yaml:"runner"` Bricks []string `yaml:"bricks,omitempty"` + ModelLabels []string `yaml:"model_labels,omitempty"` Metadata map[string]string `yaml:"metadata,omitempty"` ModelConfiguration map[string]string `yaml:"model_configuration,omitempty"` } diff --git a/internal/orchestrator/modelsindex/modelsindex_test.go b/internal/orchestrator/modelsindex/modelsindex_test.go new file mode 100644 index 00000000..53ffb585 --- /dev/null +++ b/internal/orchestrator/modelsindex/modelsindex_test.go @@ -0,0 +1,72 @@ +package modelsindex + +import ( + "testing" + + "github.com/arduino/go-paths-helper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestModelsIndex(t *testing.T) { + modelsIndex, err := GenerateModelsIndexFromFile(paths.New("testdata")) + require.NoError(t, err) + require.NotNil(t, modelsIndex) + + t.Run("it parses a valid model-list.yaml", func(t *testing.T) { + models := modelsIndex.GetModels() + assert.Len(t, models, 2, "Expected 2 models to be parsed") + }) + + t.Run("it gets a model by ID", func(t *testing.T) { + model, found := modelsIndex.GetModelByID("not-existing-model") + assert.False(t, found) + assert.Nil(t, model) + + model, found = modelsIndex.GetModelByID("face-detection") + assert.Equal(t, "brick", model.Runner) + require.True(t, found, "face-detection should be found") + assert.Equal(t, "face-detection", model.ID) + assert.Equal(t, "Lightweight-Face-Detection", model.Name) + assert.Equal(t, "Face bounding box detection. This model is trained on the WIDER FACE dataset and can detect faces in images.", model.ModuleDescription) + assert.Equal(t, []string{"face"}, model.ModelLabels) + assert.Equal(t, "/models/ootb/ei/lw-face-det.eim", model.ModelConfiguration["EI_OBJ_DETECTION_MODEL"]) + assert.Equal(t, []string{"arduino:object_detection", "arduino:video_object_detection"}, model.Bricks) + assert.Equal(t, "qualcomm-ai-hub", model.Metadata["source"]) + assert.Equal(t, "false", model.Metadata["ei-gpu-mode"]) + assert.Equal(t, "face-det-lite", model.Metadata["source-model-id"]) + assert.Equal(t, "https://aihub.qualcomm.com/models/face_det_lite", model.Metadata["source-model-url"]) + }) + + t.Run("it fails if model-list.yaml does not exist", func(t *testing.T) { + nonExistentPath := paths.New("nonexistentdir") + modelsIndex, err := GenerateModelsIndexFromFile(nonExistentPath) + assert.Error(t, err) + assert.Nil(t, modelsIndex) + }) + + t.Run("it gets models by a brick", func(t *testing.T) { + model := modelsIndex.GetModelsByBrick("not-existing-brick") + assert.Nil(t, model) + + model = modelsIndex.GetModelsByBrick("arduino:object_detection") + assert.Len(t, model, 1) + assert.Equal(t, "face-detection", model[0].ID) + }) + + t.Run("it gets models by bricks", func(t *testing.T) { + models := modelsIndex.GetModelsByBricks([]string{"arduino:non_existing"}) + assert.Len(t, models, 0) + assert.Nil(t, models) + + models = modelsIndex.GetModelsByBricks([]string{"arduino:video_object_detection"}) + assert.Len(t, models, 2) + assert.Equal(t, "face-detection", models[0].ID) + assert.Equal(t, "yolox-object-detection", models[1].ID) + + models = modelsIndex.GetModelsByBricks([]string{"arduino:object_detection", "arduino:video_object_detection"}) + assert.Len(t, models, 2) + assert.Equal(t, "face-detection", models[0].ID) + assert.Equal(t, "yolox-object-detection", models[1].ID) + }) +} diff --git a/internal/orchestrator/modelsindex/testdata/models-list.yaml b/internal/orchestrator/modelsindex/testdata/models-list.yaml new file mode 100644 index 00000000..7d0aefb5 --- /dev/null +++ b/internal/orchestrator/modelsindex/testdata/models-list.yaml @@ -0,0 +1,111 @@ +models: + - face-detection: + runner: brick + name : "Lightweight-Face-Detection" + description: "Face bounding box detection. This model is trained on the WIDER FACE dataset and can detect faces in images." + model_configuration: + "EI_OBJ_DETECTION_MODEL": "/models/ootb/ei/lw-face-det.eim" + model_labels: + - face + bricks: + - arduino:object_detection + - arduino:video_object_detection + metadata: + source: "qualcomm-ai-hub" + ei-gpu-mode: false + source-model-id: "face-det-lite" + source-model-url: "https://aihub.qualcomm.com/models/face_det_lite" + - yolox-object-detection: + runner: brick + name : "General purpose object detection - YoloX" + description: "General purpose object detection model based on YoloX Nano. This model is trained on the COCO dataset and can detect 80 different object classes." + model_configuration: + "EI_OBJ_DETECTION_MODEL": "/models/ootb/ei/yolo-x-nano.eim" + model_labels: + - airplane + - apple + - backpack + - banana + - baseball bat + - baseball glove + - bear + - bed + - bench + - bicycle + - bird + - boat + - book + - bottle + - bowl + - broccoli + - bus + - cake + - car + - carrot + - cat + - cell phone + - chair + - clock + - couch + - cow + - cup + - dining table + - dog + - donut + - elephant + - fire hydrant + - fork + - frisbee + - giraffe + - hair drier + - handbag + - hot dog + - horse + - keyboard + - kite + - knife + - laptop + - microwave + - motorcycle + - mouse + - orange + - oven + - parking meter + - person + - pizza + - potted plant + - refrigerator + - remote + - sandwich + - scissors + - sheep + - sink + - skateboard + - skis + - snowboard + - spoon + - sports ball + - stop sign + - suitcase + - surfboard + - teddy bear + - tennis racket + - tie + - toaster + - toilet + - toothbrush + - traffic light + - train + - truck + - tv + - umbrella + - vase + - wine glass + - zebra + metadata: + source: "edgeimpulse" + ei-project-id: 717280 + source-model-id: "YOLOX-Nano" + source-model-url: "https://github.com/Megvii-BaseDetection/YOLOX" + bricks: + - arduino:video_object_detection diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 41890fa4..19181998 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -131,6 +131,9 @@ func StartApp( yield(StreamMessage{error: fmt.Errorf("app %q is running", running.Name)}) return } + if !yield(StreamMessage{data: fmt.Sprintf("Starting app %q", app.Name)}) { + return + } if err := setStatusLeds(LedTriggerNone); err != nil { slog.Debug("unable to set status leds", slog.String("error", err.Error())) @@ -379,6 +382,9 @@ func stopAppWithCmd(ctx context.Context, app app.ArduinoApp, cmd string) iter.Se ctx, cancel := context.WithCancel(ctx) defer cancel() + if !yield(StreamMessage{data: fmt.Sprintf("Stopping app %q", app.Name)}) { + return + } if err := setStatusLeds(LedTriggerDefault); err != nil { slog.Debug("unable to set status leds", slog.String("error", err.Error())) } @@ -427,6 +433,46 @@ func StopAndDestroyApp(ctx context.Context, app app.ArduinoApp) iter.Seq[StreamM return stopAppWithCmd(ctx, app, "down") } +func RestartApp( + ctx context.Context, + docker command.Cli, + provisioner *Provision, + modelsIndex *modelsindex.ModelsIndex, + bricksIndex *bricksindex.BricksIndex, + appToStart app.ArduinoApp, + cfg config.Configuration, + staticStore *store.StaticStore, +) iter.Seq[StreamMessage] { + return func(yield func(StreamMessage) bool) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + runningApp, err := getRunningApp(ctx, docker.Client()) + if err != nil { + yield(StreamMessage{error: err}) + return + } + + if runningApp != nil { + if runningApp.FullPath.String() != appToStart.FullPath.String() { + yield(StreamMessage{error: fmt.Errorf("another app %q is running", runningApp.Name)}) + return + } + + stopStream := StopApp(ctx, *runningApp) + for msg := range stopStream { + if !yield(msg) { + return + } + if msg.error != nil { + return + } + } + } + startStream := StartApp(ctx, docker, provisioner, modelsIndex, bricksIndex, appToStart, cfg, staticStore) + startStream(yield) + } +} + func StartDefaultApp( ctx context.Context, docker command.Cli, @@ -1078,8 +1124,6 @@ func compileUploadSketch( arduinoApp *app.ArduinoApp, w io.Writer, ) error { - const fqbn = "arduino:zephyr:unoq" - logrus.SetLevel(logrus.ErrorLevel) // Reduce the log level of arduino-cli srv := commands.NewArduinoCoreServer() @@ -1101,6 +1145,9 @@ func compileUploadSketch( } sketch := sketchResp.GetSketch() profile := sketch.GetDefaultProfile().GetName() + if profile == "" { + return fmt.Errorf("sketch %q has no default profile", sketchPath) + } initReq := &rpc.InitRequest{ Instance: inst, SketchPath: sketchPath, @@ -1140,18 +1187,13 @@ func compileUploadSketch( // build the sketch server, getCompileResult := commands.CompilerServerToStreams(ctx, w, w, nil) - - // TODO: add build cache compileReq := rpc.CompileRequest{ Instance: inst, - Fqbn: fqbn, + Fqbn: "arduino:zephyr:unoq", SketchPath: sketchPath, BuildPath: buildPath, Jobs: 2, } - if profile == "" { - compileReq.Libraries = []string{sketchPath + "/../../sketch-libraries"} - } err = srv.Compile(&compileReq, server) if err != nil { @@ -1174,12 +1216,67 @@ func compileUploadSketch( slog.Info("Used library " + lib.GetName() + " (" + lib.GetVersion() + ") in " + lib.GetInstallDir()) } + if err := uploadSketchInRam(ctx, w, srv, inst, sketchPath, buildPath); err != nil { + slog.Warn("failed to upload in ram mode, trying to configure the board in ram mode, and retry", slog.String("error", err.Error())) + if err := configureMicroInRamMode(ctx, w, srv, inst); err != nil { + return err + } + return uploadSketchInRam(ctx, w, srv, inst, sketchPath, buildPath) + } + return nil +} + +func uploadSketchInRam(ctx context.Context, + w io.Writer, + srv rpc.ArduinoCoreServiceServer, + inst *rpc.Instance, + sketchPath string, + buildPath string, +) error { stream, _ := commands.UploadToServerStreams(ctx, w, w) - return srv.Upload(&rpc.UploadRequest{ + if err := srv.Upload(&rpc.UploadRequest{ Instance: inst, - Fqbn: fqbn, + Fqbn: "arduino:zephyr:unoq:flash_mode=ram", SketchPath: sketchPath, ImportDir: buildPath, + }, stream); err != nil { + return err + } + return nil +} + +// configureMicroInRamMode uploads an empty binary overing any sketch previously uploaded in flash. +// This is required to be able to upload sketches in ram mode after if there is already a sketch in flash. +func configureMicroInRamMode( + ctx context.Context, + w io.Writer, + srv rpc.ArduinoCoreServiceServer, + inst *rpc.Instance, +) error { + emptyBinDir := paths.New("/tmp/empty") + _ = emptyBinDir.MkdirAll() + defer func() { _ = emptyBinDir.RemoveAll() }() + + zeros, err := os.Open("/dev/zero") + if err != nil { + return err + } + defer zeros.Close() + + empty, err := emptyBinDir.Join("empty.ino.elf-zsk.bin").Create() + if err != nil { + return err + } + defer empty.Close() + if _, err := io.CopyN(empty, zeros, 50); err != nil { + return err + } + + stream, _ := commands.UploadToServerStreams(ctx, w, w) + return srv.Upload(&rpc.UploadRequest{ + Instance: inst, + Fqbn: "arduino:zephyr:unoq:flash_mode=flash", + ImportDir: emptyBinDir.String(), }, stream) } diff --git a/internal/orchestrator/sketch_libs.go b/internal/orchestrator/sketch_libs.go index d28c9ba8..3ef3b7ff 100644 --- a/internal/orchestrator/sketch_libs.go +++ b/internal/orchestrator/sketch_libs.go @@ -17,6 +17,8 @@ package orchestrator import ( "context" + "log/slog" + "time" "github.com/arduino/arduino-cli/commands" rpc "github.com/arduino/arduino-cli/rpc/cc/arduino/cli/commands/v1" @@ -25,6 +27,8 @@ import ( "github.com/arduino/arduino-app-cli/internal/orchestrator/app" ) +const indexUpdateInterval = 10 * time.Minute + func AddSketchLibrary(ctx context.Context, app app.ArduinoApp, libRef LibraryReleaseID, addDeps bool) ([]LibraryReleaseID, error) { srv := commands.NewArduinoCoreServer() var inst *rpc.Instance @@ -43,6 +47,15 @@ func AddSketchLibrary(ctx context.Context, app app.ArduinoApp, libRef LibraryRel return nil, err } + stream, _ := commands.UpdateLibrariesIndexStreamResponseToCallbackFunction(ctx, func(curr *rpc.DownloadProgress) { + slog.Debug("downloading library index", "progress", curr.GetMessage()) + }) + // update the local library index after a certain time, to avoid if a library is added to the sketch but the local library index is not update, the compile can fail (because the lib is not found) + req := &rpc.UpdateLibrariesIndexRequest{Instance: inst, UpdateIfOlderThanSecs: int64(indexUpdateInterval.Seconds())} + if err := srv.UpdateLibrariesIndex(req, stream); err != nil { + slog.Warn("error updating library index, skipping", slog.String("error", err.Error())) + } + resp, err := srv.ProfileLibAdd(ctx, &rpc.ProfileLibAddRequest{ Instance: inst, SketchPath: app.MainSketchPath.String(), @@ -59,6 +72,7 @@ func AddSketchLibrary(ctx context.Context, app app.ArduinoApp, libRef LibraryRel if err != nil { return nil, err } + return f.Map(resp.GetAddedLibraries(), rpcProfileLibReferenceToLibReleaseID), nil } diff --git a/internal/update/arduino/arduino.go b/internal/update/arduino/arduino.go index 36d7c056..01076dff 100644 --- a/internal/update/arduino/arduino.go +++ b/internal/update/arduino/arduino.go @@ -41,13 +41,6 @@ func NewArduinoPlatformUpdater() *ArduinoPlatformUpdater { } func setConfig(ctx context.Context, srv rpc.ArduinoCoreServiceServer) error { - if _, err := srv.SettingsSetValue(ctx, &rpc.SettingsSetValueRequest{ - Key: "board_manager.additional_urls", - EncodedValue: "https://apt-repo.arduino.cc/zephyr-core-imola.json", - ValueFormat: "cli", - }); err != nil { - return err - } if _, err := srv.SettingsSetValue(ctx, &rpc.SettingsSetValueRequest{ Key: "network.connection_timeout", EncodedValue: "600s", diff --git a/pkg/board/board.go b/pkg/board/board.go index 9c2cb831..3dac4af2 100644 --- a/pkg/board/board.go +++ b/pkg/board/board.go @@ -186,7 +186,6 @@ func FromFQBN(ctx context.Context, fqbn string) ([]Board, error) { switch port.GetPort().GetProtocol() { case SerialProtocol: serial := strings.ToLower(port.GetPort().GetHardwareId()) // in windows this is uppercase. - // TODO: we should store the board custom name in the product id so we can get it from the discovery service. var customName string if conn, err := adb.FromSerial(serial, ""); err == nil { @@ -211,10 +210,15 @@ func FromFQBN(ctx context.Context, fqbn string) ([]Board, error) { } customName = name[:idx] } + var serial string + if sn, ok := port.GetPort().GetProperties()["serial_number"]; ok { + serial = sn + } boards = append(boards, Board{ Protocol: NetworkProtocol, Address: port.GetPort().GetAddress(), + Serial: serial, BoardName: boardName, CustomName: customName, }) @@ -438,16 +442,6 @@ func EnsurePlatformInstalled(ctx context.Context, rawFQBN string) error { _, _ = srv.Destroy(ctx, &rpc.DestroyRequest{Instance: inst}) }() - // TODO: after embargo remove this - _, err = srv.SettingsSetValue(ctx, &rpc.SettingsSetValueRequest{ - Key: "board_manager.additional_urls", - EncodedValue: "https://apt-repo.arduino.cc/zephyr-core-imola.json", - ValueFormat: "cli", - }) - if err != nil { - return err - } - stream, _ := commands.UpdateIndexStreamResponseToCallbackFunction(ctx, func(curr *rpc.DownloadProgress) { slog.Debug("Update index progress", slog.String("download_progress", curr.String())) }) diff --git a/pkg/board/remote/adb/adb.go b/pkg/board/remote/adb/adb.go index 745ff0af..eb401305 100644 --- a/pkg/board/remote/adb/adb.go +++ b/pkg/board/remote/adb/adb.go @@ -82,12 +82,13 @@ func (a *ADBConnection) Forward(ctx context.Context, localPort int, remotePort i if err != nil { return err } - if err := cmd.RunWithinContext(ctx); err != nil { + if out, err := cmd.RunAndCaptureCombinedOutput(ctx); err != nil { return fmt.Errorf( - "failed to forward ADB port %s to %s: %w", + "failed to forward ADB port %s to %s: %w: %s", local, remote, err, + out, ) } @@ -99,8 +100,8 @@ func (a *ADBConnection) ForwardKillAll(ctx context.Context) error { if err != nil { return err } - if err := cmd.RunWithinContext(ctx); err != nil { - return fmt.Errorf("failed to kill all ADB forwarded ports: %w", err) + if out, err := cmd.RunAndCaptureCombinedOutput(ctx); err != nil { + return fmt.Errorf("failed to kill all ADB forwarded ports: %w: %s", err, out) } return nil } @@ -119,6 +120,7 @@ func (a *ADBConnection) List(path string) ([]remote.FileInfo, error) { if err := cmd.Start(); err != nil { return nil, err } + defer func() { _ = cmd.Wait() }() r := bufio.NewReader(output) _, err = r.ReadBytes('\n') // Skip the first line @@ -166,6 +168,7 @@ func (a *ADBConnection) Stats(p string) (remote.FileInfo, error) { if err := cmd.Start(); err != nil { return remote.FileInfo{}, err } + defer func() { _ = cmd.Wait() }() r := bufio.NewReader(output) line, err := r.ReadBytes('\n') @@ -226,6 +229,7 @@ func (a *ADBConnection) Remove(path string) error { type ADBCommand struct { cmd *paths.Process + err error } func (a *ADBConnection) GetCmd(cmd string, args ...string) remote.Cmder { @@ -242,19 +246,31 @@ func (a *ADBConnection) GetCmd(cmd string, args ...string) remote.Cmder { cmds = append(cmds, args...) } - command, _ := paths.NewProcess(nil, cmds...) - return &ADBCommand{cmd: command} + command, err := paths.NewProcess(nil, cmds...) + return &ADBCommand{cmd: command, err: err} } func (a *ADBCommand) Run(ctx context.Context) error { + if a.err != nil { + return fmt.Errorf("failed to create command: %w", a.err) + } + return a.cmd.RunWithinContext(ctx) } func (a *ADBCommand) Output(ctx context.Context) ([]byte, error) { + if a.err != nil { + return nil, fmt.Errorf("failed to create command: %w", a.err) + } + return a.cmd.RunAndCaptureCombinedOutput(ctx) } func (a *ADBCommand) Interactive() (io.WriteCloser, io.Reader, io.Reader, remote.Closer, error) { + if a.err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to create command: %w", a.err) + } + stdin, err := a.cmd.StdinPipe() if err != nil { return nil, nil, nil, nil, fmt.Errorf("failed to get stdin pipe: %w", err) diff --git a/pkg/board/remote/adb/adb_nowindows.go b/pkg/board/remote/adb/adb_nowindows.go index 32699417..e856b9e2 100644 --- a/pkg/board/remote/adb/adb_nowindows.go +++ b/pkg/board/remote/adb/adb_nowindows.go @@ -18,11 +18,14 @@ package adb import ( + "cmp" "context" "fmt" "io" "github.com/arduino/go-paths-helper" + + "github.com/arduino/arduino-app-cli/pkg/board/remote" ) func adbReadFile(a *ADBConnection, path string) (io.ReadCloser, error) { @@ -37,7 +40,14 @@ func adbReadFile(a *ADBConnection, path string) (io.ReadCloser, error) { if err := cmd.Start(); err != nil { return nil, err } - return output, nil + return remote.WithCloser{ + Reader: output, + CloseFun: func() error { + err1 := output.Close() + err2 := cmd.Wait() + return cmp.Or(err1, err2) + }, + }, nil } func adbWriteFile(a *ADBConnection, r io.Reader, pathStr string) error { diff --git a/pkg/board/remote/adb/adb_windows.go b/pkg/board/remote/adb/adb_windows.go index 26b752a5..150d9cc0 100644 --- a/pkg/board/remote/adb/adb_windows.go +++ b/pkg/board/remote/adb/adb_windows.go @@ -26,7 +26,7 @@ import ( "github.com/arduino/go-paths-helper" - "github.com/arduino/arduino-app-cli/pkg/board/remote/ssh" + "github.com/arduino/arduino-app-cli/pkg/board/remote" ) func adbReadFile(a *ADBConnection, path string) (io.ReadCloser, error) { @@ -44,7 +44,7 @@ func adbReadFile(a *ADBConnection, path string) (io.ReadCloser, error) { return nil, err } - return ssh.WithCloser{ + return remote.WithCloser{ Reader: decoded, CloseFun: func() error { err1 := output.Close() diff --git a/pkg/board/remote/remote.go b/pkg/board/remote/remote.go index 9330610a..247e7718 100644 --- a/pkg/board/remote/remote.go +++ b/pkg/board/remote/remote.go @@ -59,3 +59,17 @@ type Cmder interface { Output(ctx context.Context) ([]byte, error) Interactive() (io.WriteCloser, io.Reader, io.Reader, Closer, error) } + +// WithCloser is a helper to create an io.ReadCloser from an io.Reader +// and a close function. +type WithCloser struct { + io.Reader + CloseFun func() error +} + +func (w WithCloser) Close() error { + if w.CloseFun != nil { + return w.CloseFun() + } + return nil +} diff --git a/pkg/board/remote/ssh/ssh.go b/pkg/board/remote/ssh/ssh.go index cca231f5..9b14d8f4 100644 --- a/pkg/board/remote/ssh/ssh.go +++ b/pkg/board/remote/ssh/ssh.go @@ -219,18 +219,6 @@ func (a *SSHConnection) WriteFile(r io.Reader, path string) error { return nil } -type WithCloser struct { - io.Reader - CloseFun func() error -} - -func (w WithCloser) Close() error { - if w.CloseFun != nil { - return w.CloseFun() - } - return nil -} - func (a *SSHConnection) ReadFile(path string) (io.ReadCloser, error) { session, err := a.client.NewSession() if err != nil { @@ -247,7 +235,7 @@ func (a *SSHConnection) ReadFile(path string) (io.ReadCloser, error) { return nil, fmt.Errorf("failed to start command: %w", err) } - return WithCloser{ + return remote.WithCloser{ Reader: output, CloseFun: session.Close, }, nil