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
2 changes: 1 addition & 1 deletion cmd/arduino-app-cli/app/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ func (r appListResult) String() string {
cmdutil.IDToAlias(app.ID),
app.Name,
app.Icon,
app.Status,
app.State,
app.Example,
})
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/arduino-app-cli/app/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ func newStartCmd(cfg config.Configuration) *cobra.Command {
return startHandler(cmd.Context(), cfg, app)
},
ValidArgsFunction: completion.ApplicationNamesWithFilterFunc(cfg, func(apps orchestrator.AppInfo) bool {
return apps.Status != orchestrator.StatusStarting &&
apps.Status != orchestrator.StatusRunning
return apps.State != orchestrator.StatusStarting &&
apps.State != orchestrator.StatusRunning
}),
}
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/arduino-app-cli/app/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ func newStopCmd(cfg config.Configuration) *cobra.Command {
return stopHandler(cmd.Context(), app)
},
ValidArgsFunction: completion.ApplicationNamesWithFilterFunc(cfg, func(apps orchestrator.AppInfo) bool {
return apps.Status == orchestrator.StatusStarting ||
apps.Status == orchestrator.StatusRunning
return apps.State == orchestrator.StatusStarting ||
apps.State == orchestrator.StatusRunning
}),
}
}
Expand Down
10 changes: 5 additions & 5 deletions cmd/gendoc/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,10 @@ func NewOpenApiGenerator(version string) *Generator {
openapi3.SchemaOrRef{
Schema: &openapi3.Schema{
UniqueItems: f.Ptr(true),
Enum: f.Map(orchestrator.Status("").AllowedStatuses(), func(v orchestrator.Status) interface{} { return v }),
Enum: f.Map(orchestrator.State("").AllowedStates(), func(v orchestrator.State) interface{} { return v }),
Type: f.Ptr(openapi3.SchemaTypeString),
Description: f.Ptr("Application status"),
ReflectType: reflect.TypeOf(orchestrator.Status("")),
ReflectType: reflect.TypeOf(orchestrator.State("")),
},
},
)
Expand Down Expand Up @@ -205,7 +205,7 @@ func NewOpenApiGenerator(version string) *Generator {
reflector.DefaultOptions = append(reflector.DefaultOptions,
jsonschema.InterceptSchema(func(params jsonschema.InterceptSchemaParams) (stop bool, err error) {

if params.Value.Type() == reflect.TypeOf(orchestrator.Status("")) {
if params.Value.Type() == reflect.TypeOf(orchestrator.State("")) {
params.Schema.WithRef("#/components/schemas/Status")
return true, nil
}
Expand Down Expand Up @@ -632,8 +632,8 @@ Contains a JSON object with the details of an error.
Path: "/v1/apps",
Request: (*orchestrator.ListAppRequest)(nil),
Parameters: (*struct {
Filter string `query:"filter" description:"Filters apps by apps,examples,default"`
Status orchestrator.Status `query:"status" description:"Filters applications by status"`
Filter string `query:"filter" description:"Filters apps by apps,examples,default"`
Status orchestrator.State `query:"status" description:"Filters applications by status"`
})(nil),
CustomSuccessResponse: &CustomResponseDef{
ContentType: "application/json",
Expand Down
8 changes: 4 additions & 4 deletions internal/api/handlers/app_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,21 +51,21 @@ func HandleAppList(
showApps = slices.Contains(filters, "apps")
}

var statusFilter orchestrator.Status
var stateFilter orchestrator.State
if status := queryParams.Get("status"); status != "" {
status, err := orchestrator.ParseStatus(status)
status, err := orchestrator.ParseStates(status)
if err != nil {
render.EncodeResponse(w, http.StatusBadRequest, models.ErrorResponse{Details: "invalid status filter"})
return
}
statusFilter = status
stateFilter = status
}

res, err := orchestrator.ListApps(r.Context(), dockerCli, orchestrator.ListAppRequest{
ShowApps: showApps,
ShowExamples: showExamples,
ShowOnlyDefault: showOnlyDefault,
StatusFilter: statusFilter,
StateFilter: stateFilter,
}, idProvider, cfg)
if err != nil {
slog.Error("Unable to parse the app.yaml", slog.String("error", err.Error()))
Expand Down
2 changes: 1 addition & 1 deletion internal/api/handlers/app_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func HandlerAppStatus(
sseStream.SendError(render.SSEErrorData{Code: render.InternalServiceErr, Message: err.Error()})
}
for _, app := range result.Apps {
if app.Status != "" {
if app.State != "" {
sseStream.Send(render.SSEEvent{Type: "app", Data: app})
}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/orchestrator/app_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ func parseDockerStatusEvent(ctx context.Context, cfg config.Configuration, docke
Name: app.Descriptor.Name,
Description: app.Descriptor.Description,
Icon: app.Descriptor.Icon,
Status: appStatus.Status,
State: appStatus.State,
Example: id.IsExample(),
Default: isDefault,
}, nil
Expand Down
55 changes: 39 additions & 16 deletions internal/orchestrator/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
"strings"

Expand All @@ -36,34 +37,50 @@ import (

type AppStatusInfo struct {
AppPath *paths.Path
Status Status
State State
}

type containerStateInfo struct {
State State
StatusMessage string
}

// parseAppStatus takes all the containers that matches the DockerAppLabel,
// and construct a map of the state of an app and all its dependencies state.
// For app that have at least 1 dependency, we calculate the overall state
// as follow:
//
// running: all running
// stopped: all stopped
// failed: at least one failed
// stopping: at least one stopping
// starting: at least one starting
// running: all running
// stopped: all stopped
// failed: at least one failed
// stopping: at least one stopping
// stopped: at least one stopped
// starting: at least one starting
func parseAppStatus(containers []container.Summary) []AppStatusInfo {
apps := make([]AppStatusInfo, 0, len(containers))
appsStatusMap := make(map[string][]Status)
appsStatusMap := make(map[string][]containerStateInfo)
for _, c := range containers {
appPath, ok := c.Labels[DockerAppPathLabel]
if !ok {
continue
}
appsStatusMap[appPath] = append(appsStatusMap[appPath], StatusFromDockerState(c.State))
appsStatusMap[appPath] = append(appsStatusMap[appPath], containerStateInfo{
State: StatusFromDockerState(c.State),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't you update this mapping function and don't account for the status message there? If a container exited with anything that isn't 128 + 9 we could consider that a failing state, because it means that the process exited without Docker killing it.

StatusMessage: c.Status,
})

slog.Debug("Container status",
slog.String("appPath", appPath),
slog.String("containerID", c.ID),
slog.String("state", string(c.State)),
slog.String("statusMessage", c.Status),
)
}

appendResult := func(appPath *paths.Path, status Status) {
appendResult := func(appPath *paths.Path, status State) {
apps = append(apps, AppStatusInfo{
AppPath: appPath,
Status: status,
State: status,
})
}

Expand All @@ -73,27 +90,33 @@ func parseAppStatus(containers []container.Summary) []AppStatusInfo {
appPath := paths.New(appPath)

// running: all running
if !slices.ContainsFunc(s, func(v Status) bool { return v != StatusRunning }) {
if !slices.ContainsFunc(s, func(v containerStateInfo) bool { return v.State != StatusRunning }) {
appendResult(appPath, StatusRunning)
continue
}
// stopped: all stopped
if !slices.ContainsFunc(s, func(v Status) bool { return v != StatusStopped }) {
if !slices.ContainsFunc(s, func(v containerStateInfo) bool { return v.State != StatusStopped }) {
appendResult(appPath, StatusStopped)
continue
}

// ...else we have multiple different status we calculate the status
// among the possible left: {failed, stopping, starting}
if slices.ContainsFunc(s, func(v Status) bool { return v == StatusFailed }) {
if slices.ContainsFunc(s, func(v containerStateInfo) bool { return v.State == StatusFailed }) {
appendResult(appPath, StatusFailed)
continue
}
if slices.ContainsFunc(s, func(v containerStateInfo) bool {
return v.State == StatusStopped && strings.Contains(v.StatusMessage, "Exited (0)")
Copy link
Contributor

@lucarin91 lucarin91 Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should account for all exit codes that aren't signals, so everything that is less than 128

If you write an app with Python-like

exit(1)

You say that this isn't a fail, which in fact it is, because the app exits right after be started.

}) {
appendResult(appPath, StatusFailed)
continue
}
if slices.ContainsFunc(s, func(v Status) bool { return v == StatusStopping }) {
if slices.ContainsFunc(s, func(v containerStateInfo) bool { return v.State == StatusStopping }) {
appendResult(appPath, StatusStopping)
continue
}
if slices.ContainsFunc(s, func(v Status) bool { return v == StatusStarting }) {
if slices.ContainsFunc(s, func(v containerStateInfo) bool { return v.State == StatusStarting }) {
appendResult(appPath, StatusStarting)
continue
}
Expand Down Expand Up @@ -188,7 +211,7 @@ func getRunningApp(
return nil, fmt.Errorf("failed to get running apps: %w", err)
}
idx := slices.IndexFunc(apps, func(a AppStatusInfo) bool {
return a.Status == StatusRunning || a.Status == StatusStarting
return a.State == StatusRunning || a.State == StatusStarting
})
if idx == -1 {
return nil, nil
Expand Down
8 changes: 4 additions & 4 deletions internal/orchestrator/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func TestParseAppStatus(t *testing.T) {
tests := []struct {
name string
containerState []container.ContainerState
want Status
want State
}{
{
name: "everything running",
Expand All @@ -46,7 +46,7 @@ func TestParseAppStatus(t *testing.T) {
},
{
name: "failed container takes precedence over stopping and starting",
containerState: []container.ContainerState{container.StateRunning, container.StateDead, container.StateRemoving, container.StateRestarting},
containerState: []container.ContainerState{container.StateRunning, container.StateDead, container.StateRemoving, container.StateRestarting, container.StateExited},
want: StatusFailed,
},
{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should add a test for the new check

Expand All @@ -61,7 +61,7 @@ func TestParseAppStatus(t *testing.T) {
},
{
name: "starting",
containerState: []container.ContainerState{container.StateRestarting, container.StateExited},
containerState: []container.ContainerState{container.StateRestarting},
want: StatusStarting,
},
}
Expand All @@ -76,7 +76,7 @@ func TestParseAppStatus(t *testing.T) {
})
res := parseAppStatus(input)
require.Len(t, res, 1)
require.Equal(t, tc.want, res[0].Status)
require.Equal(t, tc.want, res[0].State)
require.Equal(t, "path1", res[0].AppPath.String())
})
}
Expand Down
37 changes: 23 additions & 14 deletions internal/orchestrator/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,16 @@ func stopAppWithCmd(ctx context.Context, docker command.Cli, app app.ArduinoApp,
ctx, cancel := context.WithCancel(ctx)
defer cancel()

appStatus, err := getAppStatus(ctx, docker, app)
if err != nil {
yield(StreamMessage{error: err})
return
}
if appStatus.State != StatusStarting && appStatus.State != StatusRunning {
yield(StreamMessage{data: fmt.Sprintf("app %q is not running", app.Name)})
return
}

if !yield(StreamMessage{data: fmt.Sprintf("Stopping app %q", app.Name)}) {
return
}
Expand All @@ -410,7 +420,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 appStatus.State != StatusStarting && appStatus.State != StatusRunning {
yield(StreamMessage{data: fmt.Sprintf("app %q is not running", app.Name)})
return
}
Expand Down Expand Up @@ -513,7 +523,7 @@ func StartDefaultApp(
if err != nil {
return fmt.Errorf("failed to get app details: %w", err)
}
if status.Status == "running" {
if status.State == "running" {
return nil
}

Expand All @@ -537,7 +547,7 @@ type AppInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
Status Status `json:"status,omitempty"`
State State `json:"state,omitempty"`
Example bool `json:"example"`
Default bool `json:"default"`
}
Expand All @@ -551,8 +561,7 @@ type ListAppRequest struct {
ShowExamples bool
ShowOnlyDefault bool
ShowApps bool
StatusFilter Status

StateFilter State
// IncludeNonStandardLocationApps will include apps that are not in the standard apps directory.
// We will search by looking for docker container metadata, and add the app not present in the
// standard apps directory in the result list.
Expand Down Expand Up @@ -628,14 +637,14 @@ func ListApps(
continue
}

var status Status
var state State
if idx := slices.IndexFunc(apps, func(a AppStatusInfo) bool {
return a.AppPath.EqualsTo(app.FullPath)
}); idx != -1 {
status = apps[idx].Status
state = apps[idx].State
}

if req.StatusFilter != "" && req.StatusFilter != status {
if req.StateFilter != "" && req.StateFilter != state {
continue
}

Expand All @@ -650,7 +659,7 @@ func ListApps(
Name: app.Name,
Description: app.Descriptor.Description,
Icon: app.Descriptor.Icon,
Status: status,
State: state,
Example: id.IsExample(),
Default: isDefault,
},
Expand All @@ -666,7 +675,7 @@ type AppDetailedInfo struct {
Path string `json:"path"`
Description string `json:"description"`
Icon string `json:"icon"`
Status Status `json:"status" required:"true"`
State State `json:"state" required:"true"`
Example bool `json:"example"`
Default bool `json:"default"`
Bricks []AppDetailedBrick `json:"bricks,omitempty"`
Expand All @@ -690,15 +699,15 @@ func AppDetails(
var wg sync.WaitGroup
wg.Add(2)
var defaultAppPath string
var status Status
var state State
go func() {
defer wg.Done()
app, err := getAppStatus(ctx, docker, userApp)
if err != nil {
slog.Warn("unable to get app status", slog.String("error", err.Error()), slog.String("path", userApp.FullPath.String()))
status = StatusStopped
state = StatusStopped
} else {
status = app.Status
state = app.State
}
}()
go func() {
Expand Down Expand Up @@ -727,7 +736,7 @@ func AppDetails(
Path: userApp.FullPath.String(),
Description: userApp.Descriptor.Description,
Icon: userApp.Descriptor.Icon,
Status: status,
State: state,
Example: id.IsExample(),
Default: defaultAppPath == userApp.FullPath.String(),
Bricks: f.Map(userApp.Descriptor.Bricks, func(b app.Brick) AppDetailedBrick {
Expand Down
Loading
Loading