diff --git a/cmd/arduino-app-cli/app/app.go b/cmd/arduino-app-cli/app/app.go index fca105b8..9fa3d890 100644 --- a/cmd/arduino-app-cli/app/app.go +++ b/cmd/arduino-app-cli/app/app.go @@ -35,6 +35,7 @@ func NewAppCmd(cfg config.Configuration) *cobra.Command { appCmd.AddCommand(newCreateCmd(cfg)) appCmd.AddCommand(newStartCmd(cfg)) appCmd.AddCommand(newStopCmd(cfg)) + appCmd.AddCommand(newDestroyCmd(cfg)) appCmd.AddCommand(newRestartCmd(cfg)) appCmd.AddCommand(newLogsCmd(cfg)) appCmd.AddCommand(newListCmd(cfg)) diff --git a/cmd/arduino-app-cli/app/destroy.go b/cmd/arduino-app-cli/app/destroy.go new file mode 100644 index 00000000..784e28c1 --- /dev/null +++ b/cmd/arduino-app-cli/app/destroy.go @@ -0,0 +1,89 @@ +// 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 ( + "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 newDestroyCmd(cfg config.Configuration) *cobra.Command { + return &cobra.Command{ + Use: "destroy app_path", + Short: "Destroy an Arduino App", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } + app, err := Load(args[0]) + if err != nil { + return err + } + return destroyHandler(cmd.Context(), app) + }, + ValidArgsFunction: completion.ApplicationNamesWithFilterFunc(cfg, func(apps orchestrator.AppInfo) bool { + return apps.Status != orchestrator.StatusUninitialized + }), + } +} + +func destroyHandler(ctx context.Context, app app.ArduinoApp) error { + out, _, getResult := feedback.OutputStreams() + + for message := range orchestrator.StopAndDestroyApp(ctx, servicelocator.GetDockerClient(), app) { + 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: + feedback.Fatal(message.GetError().Error(), feedback.ErrGeneric) + return nil + } + } + outputResult := getResult() + + feedback.PrintResult(destroyAppResult{ + AppName: app.Name, + Status: "uninitialized", + Output: outputResult, + }) + return nil +} + +type destroyAppResult struct { + AppName string `json:"appName"` + Status string `json:"status"` + Output *feedback.OutputStreamsResult `json:"output,omitempty"` +} + +func (r destroyAppResult) String() string { + return fmt.Sprintf("✓ App '%q destroyed successfully.", r.AppName) +} + +func (r destroyAppResult) Data() interface{} { + return r +} diff --git a/internal/api/docs/openapi.yaml b/internal/api/docs/openapi.yaml index 82619741..c6c32060 100644 --- a/internal/api/docs/openapi.yaml +++ b/internal/api/docs/openapi.yaml @@ -1686,6 +1686,7 @@ components: - stopping - stopped - failed + - uninitialized type: string uniqueItems: true UpdateCheckResult: diff --git a/internal/e2e/client/client.gen.go b/internal/e2e/client/client.gen.go index 6e9ef61c..ad6e1ace 100644 --- a/internal/e2e/client/client.gen.go +++ b/internal/e2e/client/client.gen.go @@ -24,11 +24,12 @@ const ( // Defines values for Status. const ( - Failed Status = "failed" - Running Status = "running" - Starting Status = "starting" - Stopped Status = "stopped" - Stopping Status = "stopping" + Failed Status = "failed" + Running Status = "running" + Starting Status = "starting" + Stopped Status = "stopped" + Stopping Status = "stopping" + Uninitialized Status = "uninitialized" ) // Defines values for ListLibrariesParamsSort. diff --git a/internal/e2e/daemon/app_test.go b/internal/e2e/daemon/app_test.go index 86479fc9..9656526a 100644 --- a/internal/e2e/daemon/app_test.go +++ b/internal/e2e/daemon/app_test.go @@ -204,7 +204,7 @@ func TestCreateAndVerifyAppDetails(t *testing.T) { require.False(t, *retrievedApp.Example, "A new app should not be an 'example'") require.False(t, *retrievedApp.Default, "A new app should not be 'default'") - require.Equal(t, client.Stopped, retrievedApp.Status, "The initial status of a new app should be 'stopped'") + require.Equal(t, client.Uninitialized, retrievedApp.Status, "The initial status of a new app should be 'initialized'") require.Empty(t, retrievedApp.Bricks, "A new app should not have 'bricks'") require.NotEmpty(t, retrievedApp.Path, "The app path should not be empty") } @@ -764,7 +764,7 @@ func TestAppDetails(t *testing.T) { ) require.False(t, *detailsResp.JSON200.Example) require.False(t, *detailsResp.JSON200.Default) - require.Equal(t, client.Stopped, detailsResp.JSON200.Status) + require.Equal(t, client.Uninitialized, detailsResp.JSON200.Status) require.NotEmpty(t, detailsResp.JSON200.Path) }) } diff --git a/internal/orchestrator/helpers.go b/internal/orchestrator/helpers.go index 3b94b654..16cfc274 100644 --- a/internal/orchestrator/helpers.go +++ b/internal/orchestrator/helpers.go @@ -150,7 +150,10 @@ func getAppStatusByPath( return nil, fmt.Errorf("failed to list containers: %w", err) } if len(containers) == 0 { - return nil, nil + return &AppStatusInfo{ + AppPath: paths.New(pathLabel), + Status: StatusUninitialized, + }, nil } app := parseAppStatus(containers) @@ -160,23 +163,17 @@ func getAppStatusByPath( return &app[0], nil } -// TODO: merge this with the more efficient getAppStatusByPath func getAppStatus( ctx context.Context, docker command.Cli, app app.ArduinoApp, ) (AppStatusInfo, error) { - apps, err := getAppsStatus(ctx, docker.Client()) + statusInfo, err := getAppStatusByPath(ctx, docker.Client(), app.FullPath.String()) + if err != nil { return AppStatusInfo{}, fmt.Errorf("failed to get app status: %w", err) } - idx := slices.IndexFunc(apps, func(a AppStatusInfo) bool { - return a.AppPath.String() == app.FullPath.String() - }) - if idx == -1 { - return AppStatusInfo{}, fmt.Errorf("app %s not found", app.FullPath) - } - return apps[idx], nil + return *statusInfo, nil } func getRunningApp( diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 51a808cd..0b45616a 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -384,12 +384,27 @@ func getVideoDevices() map[int]string { return deviceMap } -func stopAppWithCmd(ctx context.Context, docker command.Cli, app app.ArduinoApp, cmd string) iter.Seq[StreamMessage] { +type StopOptions struct { + Command string + RequireRunning bool + RemoveVolumes bool + RemoveOrphans bool +} + +func stopAppWithCmd(ctx context.Context, docker command.Cli, app app.ArduinoApp, opts StopOptions) iter.Seq[StreamMessage] { return func(yield func(StreamMessage) bool) { ctx, cancel := context.WithCancel(ctx) defer cancel() - if !yield(StreamMessage{data: fmt.Sprintf("Stopping app %q", app.Name)}) { + var message string + switch opts.Command { + case "stop": + message = fmt.Sprintf("Stopping app %q", app.Name) + case "down": + message = fmt.Sprintf("destroying app %q", app.Name) + } + + if !yield(StreamMessage{data: message}) { return } if err := setStatusLeds(LedTriggerDefault); err != nil { @@ -410,7 +425,7 @@ func stopAppWithCmd(ctx context.Context, docker command.Cli, app app.ArduinoApp, yield(StreamMessage{error: err}) return } - if appStatus.Status != StatusStarting && appStatus.Status != StatusRunning { + if opts.RequireRunning && appStatus.Status != StatusStarting && appStatus.Status != StatusRunning { yield(StreamMessage{data: fmt.Sprintf("app %q is not running", app.Name)}) return } @@ -425,11 +440,26 @@ func stopAppWithCmd(ctx context.Context, docker command.Cli, app app.ArduinoApp, mainCompose := app.AppComposeFilePath() // In case the app was never started if mainCompose.Exist() { - process, err := paths.NewProcess(nil, "docker", "compose", "-f", mainCompose.String(), cmd, fmt.Sprintf("--timeout=%d", DefaultDockerStopTimeoutSeconds)) + cmd := "docker" + args := []string{ + "compose", + "-f", mainCompose.String(), + opts.Command, + fmt.Sprintf("--timeout=%d", DefaultDockerStopTimeoutSeconds), + } + if opts.RemoveVolumes { + args = append(args, "--volumes") + } + if opts.RemoveOrphans { + args = append(args, "--remove-orphans") + } + fullCommand := append([]string{cmd}, args...) + process, err := paths.NewProcess(nil, fullCommand...) if err != nil { yield(StreamMessage{error: err}) return } + process.RedirectStderrTo(callbackWriter) process.RedirectStdoutTo(callbackWriter) if err := process.RunWithinContext(ctx); err != nil { @@ -443,11 +473,50 @@ func stopAppWithCmd(ctx context.Context, docker command.Cli, app app.ArduinoApp, } func StopApp(ctx context.Context, dockerClient command.Cli, app app.ArduinoApp) iter.Seq[StreamMessage] { - return stopAppWithCmd(ctx, dockerClient, app, "stop") + return stopAppWithCmd(ctx, dockerClient, app, StopOptions{ + Command: "stop", + RequireRunning: true, + }) } func StopAndDestroyApp(ctx context.Context, dockerClient command.Cli, app app.ArduinoApp) iter.Seq[StreamMessage] { - return stopAppWithCmd(ctx, dockerClient, app, "down") + return func(yield func(StreamMessage) bool) { + for msg := range stopAppWithCmd(ctx, dockerClient, app, StopOptions{ + Command: "down", + RemoveVolumes: true, + RemoveOrphans: true, + RequireRunning: false, + }) { + if !yield(msg) { + return + } + } + for msg := range cleanAppCacheFiles(app) { + if !yield(msg) { + return + } + } + } +} + +func cleanAppCacheFiles(app app.ArduinoApp) iter.Seq[StreamMessage] { + return func(yield func(StreamMessage) bool) { + cachePath := app.FullPath.Join(".cache") + + if exists, _ := cachePath.ExistCheck(); !exists { + yield(StreamMessage{data: "No cache to clean."}) + return + } + if !yield(StreamMessage{data: "Removing app cache files..."}) { + return + } + slog.Debug("removing app cache", slog.String("path", cachePath.String())) + if err := cachePath.RemoveAll(); err != nil { + yield(StreamMessage{error: fmt.Errorf("unable to remove app cache: %w", err)}) + return + } + yield(StreamMessage{data: "Cache removed successfully."}) + } } func RestartApp( @@ -628,7 +697,7 @@ func ListApps( continue } - var status Status + status := StatusUninitialized if idx := slices.IndexFunc(apps, func(a AppStatusInfo) bool { return a.AppPath.EqualsTo(app.FullPath) }); idx != -1 { diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go index ab42d287..1e80426d 100644 --- a/internal/orchestrator/orchestrator_test.go +++ b/internal/orchestrator/orchestrator_test.go @@ -276,7 +276,7 @@ func TestListApp(t *testing.T) { Name: "example1", Description: "", Icon: "😃", - Status: "", + Status: "uninitialized", Example: true, Default: false, }, @@ -285,7 +285,7 @@ func TestListApp(t *testing.T) { Name: "app1", Description: "", Icon: "😃", - Status: "", + Status: "uninitialized", Example: false, Default: false, }, @@ -294,7 +294,7 @@ func TestListApp(t *testing.T) { Name: "app2", Description: "", Icon: "😃", - Status: "", + Status: "uninitialized", Example: false, Default: false, }, @@ -315,7 +315,7 @@ func TestListApp(t *testing.T) { Name: "app1", Description: "", Icon: "😃", - Status: "", + Status: "uninitialized", Example: false, Default: false, }, @@ -324,7 +324,7 @@ func TestListApp(t *testing.T) { Name: "app2", Description: "", Icon: "😃", - Status: "", + Status: "uninitialized", Example: false, Default: false, }, @@ -345,7 +345,7 @@ func TestListApp(t *testing.T) { Name: "example1", Description: "", Icon: "😃", - Status: "", + Status: "uninitialized", Example: true, Default: false, }, diff --git a/internal/orchestrator/status.go b/internal/orchestrator/status.go index 71bc3228..fe8be5b1 100644 --- a/internal/orchestrator/status.go +++ b/internal/orchestrator/status.go @@ -24,11 +24,12 @@ import ( type Status string const ( - StatusStarting Status = "starting" - StatusRunning Status = "running" - StatusStopping Status = "stopping" - StatusStopped Status = "stopped" - StatusFailed Status = "failed" + StatusStarting Status = "starting" + StatusRunning Status = "running" + StatusStopping Status = "stopping" + StatusStopped Status = "stopped" + StatusFailed Status = "failed" + StatusUninitialized Status = "uninitialized" ) func StatusFromDockerState(s container.ContainerState) Status { @@ -55,7 +56,7 @@ func ParseStatus(s string) (Status, error) { func (s Status) Validate() error { switch s { - case StatusStarting, StatusRunning, StatusStopping, StatusStopped, StatusFailed: + case StatusStarting, StatusRunning, StatusStopping, StatusStopped, StatusFailed, StatusUninitialized: return nil default: return fmt.Errorf("status should be one of %v", s.AllowedStatuses()) @@ -63,5 +64,5 @@ func (s Status) Validate() error { } func (s Status) AllowedStatuses() []Status { - return []Status{StatusStarting, StatusRunning, StatusStopping, StatusStopped, StatusFailed} + return []Status{StatusStarting, StatusRunning, StatusStopping, StatusStopped, StatusFailed, StatusUninitialized} }