Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 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
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
40 changes: 40 additions & 0 deletions internal/orchestrator/app/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import (
"github.com/arduino/go-paths-helper"
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"

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

type Brick struct {
Expand Down Expand Up @@ -138,6 +140,44 @@ func (a *AppDescriptor) IsValid() error {
return allErrors
}

// ValidateBricks checks that all bricks referenced in the given AppDescriptor exist in the provided BricksIndex,
// and that all required variables for each brick are present and valid. It collects and returns all validation
// errors found as a slice, allowing the caller to see all issues at once rather than stopping at the first error.
// If the index is nil, validation is skipped and nil is returned.
func (a *AppDescriptor) ValidateBricks(index *bricksindex.BricksIndex) []error {
if index == nil {
return nil
}

var allErrors []error

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

// Check that all app variables exist in brick definition
for appBrickName := range appBrick.Variables {
_, exist := indexBrick.GetVariable(appBrickName)
if !exist {
allErrors = append(allErrors, fmt.Errorf("variable %q does not exist on brick %q", appBrickName, 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 = append(allErrors, fmt.Errorf("variable %q is required by brick %q", indexBrickVariable.Name, indexBrick.ID))
}
}
}
}
return allErrors
}

func isSingleEmoji(s string) bool {
emojis := 0
for it := emoji.IterateChars(s); it.Next(); {
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
91 changes: 91 additions & 0 deletions internal/orchestrator/app/validator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
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)
require.NotNil(t, bricksIndex)

testCases := []struct {
name string
filename string
expectedErrors []error // Now expecting a slice of error messages
}{
{
name: "valid with all required filled",
filename: "all-required-app.yaml",
expectedErrors: nil,
},
{
name: "valid with missing bricks",
filename: "no-bricks-app.yaml",
expectedErrors: nil,
},
{
name: "valid with empty list of bricks",
filename: "empty-bricks-app.yaml",
expectedErrors: nil,
},
{
name: "valid if required variable is empty string",
filename: "empty-required-app.yaml",
expectedErrors: nil,
},
{
name: "invalid if required variable is omitted",
filename: "omitted-required-app.yaml",
expectedErrors: []error{
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",
expectedErrors: []error{
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",
expectedErrors: []error{
errors.New("brick \"arduino:not_existing_brick\" not found"),
},
},
{
name: "invalid if variable does not exist in the brick",
filename: "not-found-variable-app.yaml",
expectedErrors: []error{
errors.New("variable \"NOT_EXISTING_VARIABLE\" does not exist on brick \"arduino:arduino_cloud\""),
},
},
}

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)

errs := appDescriptor.ValidateBricks(bricksIndex)
if tc.expectedErrors == nil {
require.Nil(t, errs)
assert.Empty(t, errs, "Expected no validation errors")
} else {
require.Len(t, errs, len(tc.expectedErrors), "Expected %d errors but got %d", len(tc.expectedErrors), len(errs))
require.Exactly(t, tc.expectedErrors, errs)
}
})
}
}
2 changes: 1 addition & 1 deletion internal/orchestrator/bricks/bricks.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ 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)
slog.Warn("[Skip] a required variable is not set by user", "variable", brickVar.Name)
}
}
}
Expand Down
59 changes: 39 additions & 20 deletions internal/orchestrator/bricks/bricks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@
package bricks

import (
"fmt"
"testing"
"time"

"github.com/arduino/go-paths-helper"
"github.com/stretchr/testify/require"
Expand All @@ -32,7 +34,7 @@ func TestBrickCreate(t *testing.T) {
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")))
err = brickService.BrickCreate(BrickCreateUpdateRequest{ID: "not-existing-id"}, f.Must(app.Load("testdata/AppFromExample")))
require.Error(t, err)
require.Equal(t, "brick \"not-existing-id\" not found", err.Error())
})
Expand All @@ -41,50 +43,59 @@ func TestBrickCreate(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")))
err = brickService.BrickCreate(req, f.Must(app.Load("testdata/AppFromExample")))
require.Error(t, err)
require.Equal(t, "variable \"NON_EXISTING_VARIABLE\" does not exist on brick \"arduino:arduino_cloud\"", err.Error())
})

//TODO: currently we do not accept an empty string as a valid value for a variable
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")))
err = brickService.BrickCreate(req, f.Must(app.Load("testdata/AppFromExample")))
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) {
t.Run("omit a mandatory variable is not present in the request", func(t *testing.T) {
tempApp, cleanUp := copyToTempApp(t, paths.New("testdata/AppFromExample"))
defer cleanUp()

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(tempApp.String())))
require.Nil(t, err)

after, err := app.Load(tempApp.String())
require.Nil(t, err)
require.Len(t, after.Descriptor.Bricks, 1)
require.Equal(t, "arduino:arduino_cloud", after.Descriptor.Bricks[0].ID)
// NOTE: currently it is not possible to distinguish a field with empty string or missing field into the yaml.
// The 'ARDUINO_DEVICE_ID' is missing from the app.yaml but here we check the empty string.
// A better aproach is to use golden files
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) {
tempDummyApp := paths.New("testdata/dummy-app.temp")
err := tempDummyApp.RemoveAll()
require.Nil(t, err)
require.Nil(t, paths.New("testdata/dummy-app").CopyDirTo(tempDummyApp))
tempApp, cleanUp := copyToTempApp(t, paths.New("testdata/AppFromExample"))
defer cleanUp()

req := BrickCreateUpdateRequest{ID: "arduino:dbstorage_sqlstore"}
err = brickService.BrickCreate(req, f.Must(app.Load(tempDummyApp.String())))
err = brickService.BrickCreate(req, f.Must(app.Load(tempApp.String())))
require.Nil(t, err)
after, err := app.Load(tempDummyApp.String())
after, err := app.Load(tempApp.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)
tempApp, cleanUp := copyToTempApp(t, paths.New("testdata/AppFromExample"))
defer cleanUp()

bricksIndex, err := bricksindex.GenerateBricksIndexFromFile(paths.New("testdata"))
require.Nil(t, err)
brickService := NewService(nil, bricksIndex, nil)
Expand All @@ -99,10 +110,10 @@ func TestBrickCreate(t *testing.T) {
},
}

err = brickService.BrickCreate(req, f.Must(app.Load(tempDummyApp.String())))
err = brickService.BrickCreate(req, f.Must(app.Load(tempApp.String())))
require.Nil(t, err)

after, err := app.Load(tempDummyApp.String())
after, err := app.Load(tempApp.String())
require.Nil(t, err)
require.Len(t, after.Descriptor.Bricks, 1)
require.Equal(t, "arduino:arduino_cloud", after.Descriptor.Bricks[0].ID)
Expand All @@ -111,6 +122,14 @@ func TestBrickCreate(t *testing.T) {
})
}

func copyToTempApp(t *testing.T, srcApp *paths.Path) (tmpApp *paths.Path, cleanUp func()) {
tmpAppPath := paths.New(srcApp.String() + "-" + fmt.Sprint(time.Now().UnixMicro()) + ".temp")
require.Nil(t, srcApp.CopyDirTo(tmpAppPath))
return tmpAppPath, func() {
require.Nil(t, tmpAppPath.RemoveAll())
}
}

func TestGetBrickInstanceVariableDetails(t *testing.T) {
tests := []struct {
name string
Expand Down
Empty file.
21 changes: 21 additions & 0 deletions internal/orchestrator/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,16 @@ func StartApp(
ctx, cancel := context.WithCancel(ctx)
defer cancel()

errs := app.Descriptor.ValidateBricks(bricksIndex)
if errs != nil {
for _, e := range errs {
if !yield(StreamMessage{error: e}) {
return
}
}
return
}

running, err := getRunningApp(ctx, docker.Client())
if err != nil {
yield(StreamMessage{error: err})
Expand Down Expand Up @@ -446,6 +456,17 @@ func RestartApp(
return func(yield func(StreamMessage) bool) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()

errs := appToStart.Descriptor.ValidateBricks(bricksIndex)
if errs != nil {
for _, e := range errs {
if !yield(StreamMessage{error: e}) {
return
}
}
return
}

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