Skip to content
Open
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
4df8f58
fix: improve error logging message in HandleBrickCreate function
dido18 Nov 12, 2025
650906d
test: rename TestBrickCreate to TestBrickCreateFromAppExample and upd…
dido18 Nov 12, 2025
59a73d2
fix: log a warning for missing mandatory variables in BrickCreate and…
dido18 Nov 12, 2025
18a0030
revert some changes
dido18 Nov 12, 2025
a8c8314
fix: update test cases for BrickCreate to improve clarity and logging
dido18 Nov 12, 2025
289e6af
fix: correct typo in comment for BrickCreate function to enhance clarity
dido18 Nov 12, 2025
ad54d74
fix: improve warning message for missing mandatory variables in Brick…
dido18 Nov 12, 2025
1e8e541
feat: add validation for app descriptors and corresponding test cases
dido18 Nov 13, 2025
58a4227
fix: correct expected error message for missing required variable in …
dido18 Nov 14, 2025
7878a2c
refactor: rename validation functions and update test cases for clari…
dido18 Nov 14, 2025
ef6c2de
fix: rename variable for clarity and validate app descriptor in Handl…
dido18 Nov 14, 2025
84f1256
fix: update error code to BadRequestErr in HandleAppStart and add cor…
dido18 Nov 14, 2025
9bc05ac
refactor: consolidate brick validation into AppDescriptor and update …
dido18 Nov 14, 2025
05f12bf
fix: ensure bricks index is not nil and capture error from ValidateBr…
dido18 Nov 14, 2025
70e1dbb
test: add YAML test cases for app descriptor validation with empty an…
dido18 Nov 14, 2025
38b49bc
test: add test case for validation of non-existing variable in app de…
dido18 Nov 14, 2025
3708460
fix: enhance brick validation to collect all errors and add new test …
dido18 Nov 14, 2025
d8028b6
fix: update ValidateBricks to return all validation errors as a slice…
dido18 Nov 14, 2025
6a17436
fix: update error handling in ValidateBricks to yield all validation …
dido18 Nov 14, 2025
c7a2e55
fix: refactor ValidateBricks to return a single error and update rela…
dido18 Nov 17, 2025
5742d45
test: add dummy app configuration and main.py for brick creation tests
dido18 Nov 17, 2025
1b18b36
fix: add logging for missing required variables in BrickCreate function
dido18 Nov 17, 2025
36319aa
chore: remove unused app.golden.yaml test data file
dido18 Nov 17, 2025
d9992fe
fix: clean up app.yaml by removing unused variables section
dido18 Nov 17, 2025
89bb0b9
refactor: move ValidateBricks function to a new file and update refer…
dido18 Nov 17, 2025
8acb1cc
refactor: update app type references from appspecification to app
dido18 Nov 17, 2025
4b525b3
FIX
dido18 Nov 17, 2025
7a9bdf2
refactor: log a warning for undeclared variables in brick configuration
dido18 Nov 17, 2025
7f935b9
Update internal/orchestrator/app/validator.go
dido18 Nov 17, 2025
1661442
refactor: rename variable for clarity in ValidateBricks function
dido18 Nov 17, 2025
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 internal/api/handlers/bricks.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ func HandleBrickCreate(
err = brickService.BrickCreate(req, app)
if err != nil {
// TODO: handle specific errors
slog.Error("Unable to parse the app.yaml", slog.String("error", err.Error()))
slog.Error("Unable to create brick", slog.String("error", err.Error()))
render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{Details: "error while creating or updating brick"})
return
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name: App ok
description: App ok
bricks:
- arduino:arduino_cloud:
variables:
ARDUINO_DEVICE_ID: "my-device-id"
ARDUINO_SECRET: "my-secret"
9 changes: 9 additions & 0 deletions internal/orchestrator/app/testdata/validator/bricks-list.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
bricks:
- id: arduino:arduino_cloud
name: Arduino Cloud
description: Connects to Arduino Cloud
variables:
- name: ARDUINO_DEVICE_ID
description: Arduino Cloud Device ID
- name: ARDUINO_SECRET
description: Arduino Cloud Secret
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
name: App with empty bricks
description: App with empty bricks

bricks: []
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name: App with an empty variable
description: App withan empty variable
bricks:
- arduino:arduino_cloud:
variables:
ARDUINO_DEVICE_ID: "my-device-id"
ARDUINO_SECRET:
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
name: App with no bricks
description: App with no bricks description
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name: App no existing brick
description: App no existing brick
bricks:
- arduino:not_existing_brick:
variables:
ARDUINO_DEVICE_ID: "my-device-id"
ARDUINO_SECRET: "LAKDJ"
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
name: App with non existing variable
description: App with non existing variable
bricks:
- arduino:arduino_cloud:
variables:
NOT_EXISTING_VARIABLE: "this-is-a-not-existing-variable-for-the-brick"
ARDUINO_DEVICE_ID: "my-device-id"
ARDUINO_SECRET: "my-secret"
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
name: App only one required variable filled
description: App only one required variable filled
bricks:
- arduino:arduino_cloud:
variables:
ARDUINO_DEVICE_ID: "my-device-id"
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
name: App with no required variables
description: App with no required variables
bricks:
- arduino:arduino_cloud
45 changes: 45 additions & 0 deletions internal/orchestrator/app/validator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package app

import (
"errors"
"fmt"
"log/slog"

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

// ValidateBricks checks that all bricks referenced in the given AppDescriptor exist in the provided BricksIndex,
// It collects and returns all validatio errors as a single joined error, allowing the caller to see all issues at once rather than stopping at the first error.
// If the index or the app is nil, validation is skipped and nil is returned.
func ValidateBricks(a AppDescriptor, index *bricksindex.BricksIndex) error {
if index == nil {
return nil
}
Comment on lines +15 to +17
Copy link
Contributor

Choose a reason for hiding this comment

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

I would bricksindex required

Suggested change
if index == nil {
return nil
}


var allErrors error

for _, appBrick := range a.Bricks {
indexBrick, found := index.FindBrickByID(appBrick.ID)
if !found {
allErrors = errors.Join(allErrors, fmt.Errorf("brick %q not found", appBrick.ID))
continue // Skip further validation for this brick since it doesn't exist
}

for appBrickName := range appBrick.Variables {
_, exist := indexBrick.GetVariable(appBrickName)
if !exist {
slog.Warn("variable is referenced but not declared in the brick configuration", "variable", appBrickName, "brick", indexBrick.ID)
}
}

// Check that all required brick variables are provided by app
for _, indexBrickVariable := range indexBrick.Variables {
if indexBrickVariable.IsRequired() {
if _, exist := appBrick.Variables[indexBrickVariable.Name]; !exist {
allErrors = errors.Join(allErrors, fmt.Errorf("variable %q is required by brick %q", indexBrickVariable.Name, indexBrick.ID))
}
}
}
}
return allErrors
}
84 changes: 84 additions & 0 deletions internal/orchestrator/app/validator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package app

import (
"errors"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/arduino/go-paths-helper"

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

func TestValidateAppDescriptorBricks(t *testing.T) {
bricksIndex, err := bricksindex.GenerateBricksIndexFromFile(paths.New("testdata/validator"))
require.Nil(t, err)
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
require.Nil(t, err)
require.NoError(t, err)

require.NotNil(t, bricksIndex)

testCases := []struct {
name string
filename string
expectedError error
}{
{
name: "valid with all required filled",
filename: "all-required-app.yaml",
expectedError: nil,
},
{
name: "valid with missing bricks",
filename: "no-bricks-app.yaml",
expectedError: nil,
},
{
name: "valid with empty list of bricks",
filename: "empty-bricks-app.yaml",
expectedError: nil,
},
{
name: "valid if required variable is empty string",
filename: "empty-required-app.yaml",
expectedError: nil,
},
{
name: "invalid if required variable is omitted",
filename: "omitted-required-app.yaml",
expectedError: errors.Join(
errors.New("variable \"ARDUINO_DEVICE_ID\" is required by brick \"arduino:arduino_cloud\""),
errors.New("variable \"ARDUINO_SECRET\" is required by brick \"arduino:arduino_cloud\""),
),
},
{
name: "invalid if a required variable among two is omitted",
filename: "omitted-mixed-required-app.yaml",
expectedError: errors.New("variable \"ARDUINO_SECRET\" is required by brick \"arduino:arduino_cloud\""),
},
{
name: "invalid if brick id not found",
filename: "not-found-brick-app.yaml",
expectedError: errors.New("brick \"arduino:not_existing_brick\" not found"),
},
{
name: "log a warning if variable does not exist in the brick",
filename: "not-found-variable-app.yaml",
expectedError: nil,
Comment on lines +27 to +66
Copy link
Contributor

Choose a reason for hiding this comment

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

IMHO, this test is very unreadable; the YAMLs are quite small, so it will be better to include them here instead of storing them in different files.

},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
appDescriptor, err := ParseDescriptorFile(paths.New("testdata/validator/" + tc.filename))
require.NoError(t, err)

err = ValidateBricks(appDescriptor, bricksIndex)
if tc.expectedError == nil {
assert.NoError(t, err, "Expected no validation errors")
} else {
require.Error(t, err, "Expected validation error")
assert.Equal(t, tc.expectedError.Error(), err.Error(), "Error message should match")
}
})
}
}
3 changes: 2 additions & 1 deletion internal/orchestrator/bricks/bricks.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,8 @@ func (s *Service) BrickCreate(
for _, brickVar := range brick.Variables {
if brickVar.DefaultValue == "" {
if _, exist := req.Variables[brickVar.Name]; !exist {
return fmt.Errorf("required variable %q is mandatory", brickVar.Name)
// See issue https://github.com/arduino/arduino-app-cli/issues/68
slog.Warn("[Skip] a required variable is not set by user", "variable", brickVar.Name)
}
}
}
Expand Down
19 changes: 15 additions & 4 deletions internal/orchestrator/bricks/bricks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,24 @@ func TestBrickCreate(t *testing.T) {
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) {
t.Run("do not fail if a mandatory variable is not present", 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: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())
err = brickService.BrickCreate(req, f.Must(app.Load(tempDummyApp.String())))
require.NoError(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, "", after.Descriptor.Bricks[0].Variables["ARDUINO_DEVICE_ID"])
require.Equal(t, "a-secret-a", after.Descriptor.Bricks[0].Variables["ARDUINO_SECRET"])
})

t.Run("the brick is added if it does not exist in the app", func(t *testing.T) {
Expand Down
33 changes: 23 additions & 10 deletions internal/orchestrator/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,20 @@ func StartApp(
provisioner *Provision,
modelsIndex *modelsindex.ModelsIndex,
bricksIndex *bricksindex.BricksIndex,
app app.ArduinoApp,
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()

err := app.ValidateBricks(appToStart.Descriptor, bricksIndex)
if err != nil {
yield(StreamMessage{error: err})
return
}

running, err := getRunningApp(ctx, docker.Client())
if err != nil {
yield(StreamMessage{error: err})
Expand All @@ -131,7 +137,7 @@ 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)}) {
if !yield(StreamMessage{data: fmt.Sprintf("Starting app %q", appToStart.Name)}) {
return
}

Expand All @@ -148,11 +154,11 @@ func StartApp(
if !yield(StreamMessage{progress: &Progress{Name: "preparing", Progress: 0.0}}) {
return
}
if app.MainSketchPath != nil {
if appToStart.MainSketchPath != nil {
if !yield(StreamMessage{progress: &Progress{Name: "sketch compiling and uploading", Progress: 0.0}}) {
return
}
if err := compileUploadSketch(ctx, &app, sketchCallbackWriter); err != nil {
if err := compileUploadSketch(ctx, &appToStart, sketchCallbackWriter); err != nil {
yield(StreamMessage{error: err})
return
}
Expand All @@ -161,23 +167,23 @@ func StartApp(
}
}

if app.MainPythonFile != nil {
envs := getAppEnvironmentVariables(app, bricksIndex, modelsIndex)
if appToStart.MainPythonFile != nil {
envs := getAppEnvironmentVariables(appToStart, bricksIndex, modelsIndex)

if !yield(StreamMessage{data: "python provisioning"}) {
cancel()
return
}
provisionStartProgress := float32(0.0)
if app.MainSketchPath != nil {
if appToStart.MainSketchPath != nil {
provisionStartProgress = 10.0
}

if !yield(StreamMessage{progress: &Progress{Name: "python provisioning", Progress: provisionStartProgress}}) {
return
}

if err := provisioner.App(ctx, bricksIndex, &app, cfg, envs, staticStore); err != nil {
if err := provisioner.App(ctx, bricksIndex, &appToStart, cfg, envs, staticStore); err != nil {
yield(StreamMessage{error: err})
return
}
Expand All @@ -188,10 +194,10 @@ func StartApp(
}

// Launch the docker compose command to start the app
overrideComposeFile := app.AppComposeOverrideFilePath()
overrideComposeFile := appToStart.AppComposeOverrideFilePath()

commands := []string{}
commands = append(commands, "docker", "compose", "-f", app.AppComposeFilePath().String())
commands = append(commands, "docker", "compose", "-f", appToStart.AppComposeFilePath().String())
if ok, _ := overrideComposeFile.ExistCheck(); ok {
commands = append(commands, "-f", overrideComposeFile.String())
}
Expand Down Expand Up @@ -446,6 +452,13 @@ func RestartApp(
return func(yield func(StreamMessage) bool) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()

err := app.ValidateBricks(appToStart.Descriptor, bricksIndex)
if err != nil {
yield(StreamMessage{error: err})
return
}

Comment on lines +455 to +461
Copy link
Contributor

Choose a reason for hiding this comment

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

We don't need this here. The check should be done only before starting and not before stopping

runningApp, err := getRunningApp(ctx, docker.Client())
if err != nil {
yield(StreamMessage{error: err})
Expand Down