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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/arduino-app-cli/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
89 changes: 89 additions & 0 deletions cmd/arduino-app-cli/app/destroy.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions internal/api/docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1686,6 +1686,7 @@ components:
- stopping
- stopped
- failed
- uninitialized
type: string
uniqueItems: true
UpdateCheckResult:
Expand Down
11 changes: 6 additions & 5 deletions internal/e2e/client/client.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions internal/e2e/daemon/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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)
})
}
Expand Down
17 changes: 7 additions & 10 deletions internal/orchestrator/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down
83 changes: 76 additions & 7 deletions internal/orchestrator/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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 {
Expand All @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 6 additions & 6 deletions internal/orchestrator/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ func TestListApp(t *testing.T) {
Name: "example1",
Description: "",
Icon: "😃",
Status: "",
Status: "uninitialized",
Example: true,
Default: false,
},
Expand All @@ -285,7 +285,7 @@ func TestListApp(t *testing.T) {
Name: "app1",
Description: "",
Icon: "😃",
Status: "",
Status: "uninitialized",
Example: false,
Default: false,
},
Expand All @@ -294,7 +294,7 @@ func TestListApp(t *testing.T) {
Name: "app2",
Description: "",
Icon: "😃",
Status: "",
Status: "uninitialized",
Example: false,
Default: false,
},
Expand All @@ -315,7 +315,7 @@ func TestListApp(t *testing.T) {
Name: "app1",
Description: "",
Icon: "😃",
Status: "",
Status: "uninitialized",
Example: false,
Default: false,
},
Expand All @@ -324,7 +324,7 @@ func TestListApp(t *testing.T) {
Name: "app2",
Description: "",
Icon: "😃",
Status: "",
Status: "uninitialized",
Example: false,
Default: false,
},
Expand All @@ -345,7 +345,7 @@ func TestListApp(t *testing.T) {
Name: "example1",
Description: "",
Icon: "😃",
Status: "",
Status: "uninitialized",
Example: true,
Default: false,
},
Expand Down
15 changes: 8 additions & 7 deletions internal/orchestrator/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -55,13 +56,13 @@ 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())
}
}

func (s Status) AllowedStatuses() []Status {
return []Status{StatusStarting, StatusRunning, StatusStopping, StatusStopped, StatusFailed}
return []Status{StatusStarting, StatusRunning, StatusStopping, StatusStopped, StatusFailed, StatusUninitialized}
}