-
Notifications
You must be signed in to change notification settings - Fork 5
fix(api): allow missing required variable when a brick is added + app validation #74
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 28 commits
4df8f58
650906d
59a73d2
18a0030
a8c8314
289e6af
ad54d74
1e8e541
58a4227
7878a2c
ef6c2de
84f1256
9bc05ac
05f12bf
70e1dbb
38b49bc
3708460
d8028b6
6a17436
c7a2e55
5742d45
1b18b36
36319aa
d9992fe
89bb0b9
8acb1cc
4b525b3
7a9bdf2
7f935b9
1661442
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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" |
| 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 |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would
Suggested change
|
||||||||
|
|
||||||||
| 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 { | ||||||||
dido18 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||
| _, 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 | ||||||||
| } | ||||||||
| 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) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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") | ||||||
| } | ||||||
| }) | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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}) | ||
|
|
@@ -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 | ||
| } | ||
|
|
||
|
|
@@ -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 | ||
| } | ||
|
|
@@ -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 | ||
| } | ||
|
|
@@ -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()) | ||
| } | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.