diff --git a/cmd/arduino-app-cli/app/restart.go b/cmd/arduino-app-cli/app/restart.go index aaea80bd5..42ecb6988 100644 --- a/cmd/arduino-app-cli/app/restart.go +++ b/cmd/arduino-app-cli/app/restart.go @@ -62,6 +62,7 @@ func restartHandler(ctx context.Context, cfg config.Configuration, app app.Ardui servicelocator.GetProvisioner(), servicelocator.GetModelsIndex(), servicelocator.GetBricksIndex(), + servicelocator.GetServicesIndex(), app, cfg, servicelocator.GetStaticStore(), diff --git a/cmd/arduino-app-cli/app/start.go b/cmd/arduino-app-cli/app/start.go index a6b10e33c..58c29b6d2 100644 --- a/cmd/arduino-app-cli/app/start.go +++ b/cmd/arduino-app-cli/app/start.go @@ -64,6 +64,7 @@ func startHandler(ctx context.Context, cfg config.Configuration, app app.Arduino servicelocator.GetProvisioner(), servicelocator.GetModelsIndex(), servicelocator.GetBricksIndex(), + servicelocator.GetServicesIndex(), app, cfg, servicelocator.GetStaticStore(), diff --git a/cmd/arduino-app-cli/daemon/daemon.go b/cmd/arduino-app-cli/daemon/daemon.go index 8aa43c4ae..a37f301bb 100644 --- a/cmd/arduino-app-cli/daemon/daemon.go +++ b/cmd/arduino-app-cli/daemon/daemon.go @@ -62,6 +62,7 @@ func NewDaemonCmd(cfg config.Configuration, version string) *cobra.Command { servicelocator.GetProvisioner(), servicelocator.GetModelsIndex(), servicelocator.GetBricksIndex(), + servicelocator.GetServicesIndex(), servicelocator.GetAppIDProvider(), cfg, servicelocator.GetStaticStore(), @@ -121,6 +122,7 @@ func httpHandler(ctx context.Context, cfg config.Configuration, daemonPort, vers servicelocator.GetStaticStore(), servicelocator.GetModelsIndex(), servicelocator.GetBricksIndex(), + servicelocator.GetServicesIndex(), servicelocator.GetBrickService(), servicelocator.GetAppIDProvider(), servicelocator.GetPlatform(), diff --git a/cmd/arduino-app-cli/internal/servicelocator/servicelocator.go b/cmd/arduino-app-cli/internal/servicelocator/servicelocator.go index cb6ff4792..9cddc2e99 100644 --- a/cmd/arduino-app-cli/internal/servicelocator/servicelocator.go +++ b/cmd/arduino-app-cli/internal/servicelocator/servicelocator.go @@ -33,6 +33,7 @@ import ( "github.com/arduino/arduino-app-cli/internal/orchestrator/bricksindex" "github.com/arduino/arduino-app-cli/internal/orchestrator/config" "github.com/arduino/arduino-app-cli/internal/orchestrator/modelsindex" + "github.com/arduino/arduino-app-cli/internal/orchestrator/servicesindex" "github.com/arduino/arduino-app-cli/internal/platform" "github.com/arduino/arduino-app-cli/internal/store" ) @@ -52,6 +53,10 @@ var ( return f.Must(modelsindex.Load(GetStaticStore().GetAssetsFolder(), globalConfig.CustomModelsDir())) }) + GetServicesIndex = sync.OnceValue(func() *servicesindex.ServicesIndex { + return f.Must(servicesindex.Load(GetStaticStore().GetServicesFolder())) + }) + GetProvisioner = sync.OnceValue(func() *orchestrator.Provision { return f.Must(orchestrator.NewProvision( GetDockerClient(), diff --git a/internal/api/api.go b/internal/api/api.go index 0161db448..fb08e4093 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -28,6 +28,7 @@ import ( "github.com/arduino/arduino-app-cli/internal/orchestrator/bricksindex" "github.com/arduino/arduino-app-cli/internal/orchestrator/config" "github.com/arduino/arduino-app-cli/internal/orchestrator/modelsindex" + "github.com/arduino/arduino-app-cli/internal/orchestrator/servicesindex" "github.com/arduino/arduino-app-cli/internal/platform" "github.com/arduino/arduino-app-cli/internal/store" "github.com/arduino/arduino-app-cli/internal/update" @@ -48,6 +49,7 @@ func NewHTTPRouter( staticStore *store.StaticStore, modelsIndex *modelsindex.ModelsIndex, bricksIndex *bricksindex.BricksIndex, + servicesIndex *servicesindex.ServicesIndex, brickService *bricks.Service, idProvider *app.IDProvider, platform platform.Platform, @@ -83,7 +85,7 @@ func NewHTTPRouter( mux.Handle("GET /v1/apps/{appID}", handlers.HandleAppDetails(dockerClient, bricksIndex, idProvider, cfg)) mux.Handle("PATCH /v1/apps/{appID}", handlers.HandleAppDetailsEdits(dockerClient, bricksIndex, idProvider, cfg)) mux.Handle("GET /v1/apps/{appID}/logs", handlers.HandleAppLogs(dockerClient, idProvider, bricksIndex)) - mux.Handle("POST /v1/apps/{appID}/start", handlers.HandleAppStart(dockerClient, provisioner, modelsIndex, bricksIndex, idProvider, cfg, staticStore, platform)) + mux.Handle("POST /v1/apps/{appID}/start", handlers.HandleAppStart(dockerClient, provisioner, modelsIndex, bricksIndex, servicesIndex, idProvider, cfg, staticStore, platform)) mux.Handle("POST /v1/apps/{appID}/stop", handlers.HandleAppStop(dockerClient, idProvider, platform)) mux.Handle("POST /v1/apps/{appID}/clone", handlers.HandleAppClone(dockerClient, idProvider, cfg)) mux.Handle("DELETE /v1/apps/{appID}", handlers.HandleAppDelete(dockerClient, idProvider, platform)) diff --git a/internal/api/handlers/app_start.go b/internal/api/handlers/app_start.go index 637d770f6..bd9df6ce9 100644 --- a/internal/api/handlers/app_start.go +++ b/internal/api/handlers/app_start.go @@ -29,6 +29,7 @@ import ( "github.com/arduino/arduino-app-cli/internal/orchestrator/bricksindex" "github.com/arduino/arduino-app-cli/internal/orchestrator/config" "github.com/arduino/arduino-app-cli/internal/orchestrator/modelsindex" + "github.com/arduino/arduino-app-cli/internal/orchestrator/servicesindex" "github.com/arduino/arduino-app-cli/internal/platform" "github.com/arduino/arduino-app-cli/internal/render" "github.com/arduino/arduino-app-cli/internal/store" @@ -39,6 +40,7 @@ func HandleAppStart( provisioner *orchestrator.Provision, modelsIndex *modelsindex.ModelsIndex, bricksIndex *bricksindex.BricksIndex, + servicesIndex *servicesindex.ServicesIndex, idProvider *app.IDProvider, cfg config.Configuration, staticStore *store.StaticStore, @@ -73,7 +75,7 @@ func HandleAppStart( type log struct { Message string `json:"message"` } - for item := range orchestrator.StartApp(r.Context(), dockerCli, provisioner, modelsIndex, bricksIndex, app, cfg, staticStore, platform) { + for item := range orchestrator.StartApp(r.Context(), dockerCli, provisioner, modelsIndex, bricksIndex, servicesIndex, app, cfg, staticStore, platform) { switch item.GetType() { case orchestrator.ProgressType: sseStream.Send(render.SSEEvent{Type: "progress", Data: progress(*item.GetProgress())}) diff --git a/internal/orchestrator/bricksindex/bricks_index.go b/internal/orchestrator/bricksindex/bricks_index.go index f300ea705..359d8ac91 100644 --- a/internal/orchestrator/bricksindex/bricks_index.go +++ b/internal/orchestrator/bricksindex/bricks_index.go @@ -89,6 +89,7 @@ type Brick struct { ModelName string `yaml:"model_name,omitempty"` MountDevicesIntoContainer bool `yaml:"mount_devices_into_container,omitempty"` RequiredDevices []peripherals.DeviceClass `yaml:"required_devices,omitempty"` + RequiresServices []string `yaml:"requires_services,omitempty"` Source string `yaml:"-"` diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 63d5cc31c..aa4363693 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -48,6 +48,7 @@ import ( "github.com/arduino/arduino-app-cli/internal/orchestrator/config" "github.com/arduino/arduino-app-cli/internal/orchestrator/modelsindex" "github.com/arduino/arduino-app-cli/internal/orchestrator/peripherals" + "github.com/arduino/arduino-app-cli/internal/orchestrator/servicesindex" "github.com/arduino/arduino-app-cli/internal/platform" "github.com/arduino/arduino-app-cli/internal/store" ) @@ -113,6 +114,7 @@ func StartApp( provisioner *Provision, modelsIndex *modelsindex.ModelsIndex, bricksIndex *bricksindex.BricksIndex, + servicesIndex *servicesindex.ServicesIndex, appToStart app.ArduinoApp, cfg config.Configuration, staticStore *store.StaticStore, @@ -198,7 +200,7 @@ func StartApp( return } - if err := provisioner.App(ctx, bricksIndex, &appToStart, cfg, envs, platform, devices); err != nil { + if err := provisioner.App(ctx, bricksIndex, servicesIndex, &appToStart, cfg, envs, platform, devices); err != nil { yield(StreamMessage{error: err}) return } @@ -432,6 +434,7 @@ func RestartApp( provisioner *Provision, modelsIndex *modelsindex.ModelsIndex, bricksIndex *bricksindex.BricksIndex, + servicesIndex *servicesindex.ServicesIndex, appToStart app.ArduinoApp, cfg config.Configuration, staticStore *store.StaticStore, @@ -462,7 +465,7 @@ func RestartApp( } } } - startStream := StartApp(ctx, docker, provisioner, modelsIndex, bricksIndex, appToStart, cfg, staticStore, platform) + startStream := StartApp(ctx, docker, provisioner, modelsIndex, bricksIndex, servicesIndex, appToStart, cfg, staticStore, platform) startStream(yield) } } @@ -473,6 +476,7 @@ func StartDefaultApp( provisioner *Provision, modelsIndex *modelsindex.ModelsIndex, bricksIndex *bricksindex.BricksIndex, + servicesIndex *servicesindex.ServicesIndex, idProvider *app.IDProvider, cfg config.Configuration, staticStore *store.StaticStore, @@ -496,7 +500,7 @@ func StartDefaultApp( } // TODO: we need to stop all other running app before starting the default app. - for msg := range StartApp(ctx, docker, provisioner, modelsIndex, bricksIndex, *app, cfg, staticStore, platform) { + for msg := range StartApp(ctx, docker, provisioner, modelsIndex, bricksIndex, servicesIndex, *app, cfg, staticStore, platform) { if msg.IsError() { return fmt.Errorf("failed to start app: %w", msg.GetError()) } diff --git a/internal/orchestrator/provision.go b/internal/orchestrator/provision.go index 8021e9f4c..26c53f5de 100644 --- a/internal/orchestrator/provision.go +++ b/internal/orchestrator/provision.go @@ -41,6 +41,7 @@ import ( "github.com/arduino/arduino-app-cli/internal/orchestrator/bricksindex" "github.com/arduino/arduino-app-cli/internal/orchestrator/config" "github.com/arduino/arduino-app-cli/internal/orchestrator/peripherals" + "github.com/arduino/arduino-app-cli/internal/orchestrator/servicesindex" "github.com/arduino/arduino-app-cli/internal/platform" ) @@ -120,6 +121,7 @@ func NewProvision( func (p *Provision) App( ctx context.Context, bricksIndex *bricksindex.BricksIndex, + servicesIndex *servicesindex.ServicesIndex, arduinoApp *app.ArduinoApp, cfg config.Configuration, mapped_env map[string]string, @@ -138,7 +140,7 @@ func (p *Provision) App( bricksIndex = bricksIndex.WithAppBricks(arduinoApp.LocalBricks) - return generateMainComposeFile(arduinoApp, bricksIndex, p.pythonImage, cfg, mapped_env, platform, devices) + return generateMainComposeFile(arduinoApp, bricksIndex, servicesIndex, p.pythonImage, cfg, mapped_env, platform, devices) } func (p *Provision) init( @@ -216,6 +218,7 @@ const ( func generateMainComposeFile( app *app.ArduinoApp, bricksIndex *bricksindex.BricksIndex, + servicesIndex *servicesindex.ServicesIndex, pythonImage string, cfg config.Configuration, envs helpers.EnvVars, @@ -229,6 +232,7 @@ func generateMainComposeFile( ports[fmt.Sprintf("%d:%d", p, p)] = struct{}{} } + brickServices := make(map[string]servicesindex.Service) var composeFiles paths.PathList services := make([]serviceInfo, 0, len(app.Descriptor.Bricks)) for _, brick := range app.Descriptor.Bricks { @@ -243,6 +247,16 @@ func generateMainComposeFile( ports[fmt.Sprintf("%s:%s", p, p)] = struct{}{} } + // 2. Retrieve the required singleton services + for _, id := range idxBrick.RequiresServices { + idxService, found := servicesIndex.FindServiceByID(id) + if !found { + slog.Error("service required by brick not found in services index", slog.String("service_id", id), slog.String("brick_id", brick.ID)) + continue + } + brickServices[id] = *idxService + } + // The following code is needed only if the brick requires a container. // In case it doesn't we just skip to the next one. if !idxBrick.RequireContainer { @@ -279,6 +293,22 @@ func generateMainComposeFile( slog.Warn("The 'required_devices' field is deprecated. Please move requirements to the specific 'bricks' section.") } + // Add the singleton services compose files to the list of the brick compose files + for _, s := range brickServices { + serviceCompose, ok := s.GetComposeFile() + if !ok { + slog.Error("service compose not found", slog.String("service_id", s.ServiceID)) + continue + } + svcs, err := extractServicesFromComposeFile(serviceCompose) + if err != nil { + slog.Error("loading service_compose", slog.String("service_id", s.ServiceID), slog.String("path", serviceCompose.String()), slog.Any("error", err)) + continue + } + composeFiles.AddIfMissing(serviceCompose) + services = append(services, svcs...) + } + // Create a single docker-mainCompose that includes all the required services mainComposeFile := app.AppComposeFilePath() // If required, create an override compose file for devices diff --git a/internal/orchestrator/provision_test.go b/internal/orchestrator/provision_test.go index bbf17b4ba..aa840bdec 100644 --- a/internal/orchestrator/provision_test.go +++ b/internal/orchestrator/provision_test.go @@ -27,6 +27,7 @@ import ( "github.com/arduino/arduino-app-cli/internal/orchestrator/app" "github.com/arduino/arduino-app-cli/internal/orchestrator/bricksindex" "github.com/arduino/arduino-app-cli/internal/orchestrator/peripherals" + "github.com/arduino/arduino-app-cli/internal/orchestrator/servicesindex" "github.com/arduino/arduino-app-cli/internal/platform" "github.com/goccy/go-yaml" @@ -106,6 +107,10 @@ bricks: err = cfg.AssetsDir().Join("bricks-list.yaml").WriteFile(bricksIndexContent) require.NoError(t, err) + require.NoError(t, cfg.AssetsDir().Join("services").MkdirAll()) + servicesIndex, err := servicesindex.Load(cfg.AssetsDir().Join("services")) + require.NoError(t, err, "Failed to load services index") + // Override brick index with custom test content bricksIndex, err := bricksindex.Load(cfg.AssetsDir()) require.Nil(t, err, "Failed to load bricks index with custom content") @@ -126,7 +131,7 @@ bricks: HasSoundDevice: false, HasVideoDevice: true, } - err = generateMainComposeFile(&app, bricksIndex, "app-bricks:python-apps-base:dev-latest", cfg, env, unkownPlatform, devices) + err = generateMainComposeFile(&app, bricksIndex, servicesIndex, "app-bricks:python-apps-base:dev-latest", cfg, env, unkownPlatform, devices) // Validate that the main compose file and overrides are created require.NoError(t, err, "Failed to generate main compose file") @@ -343,6 +348,10 @@ bricks: err := cfg.AssetsDir().Join("bricks-list.yaml").WriteFile(bricksIndexContent) require.NoError(t, err) + require.NoError(t, cfg.AssetsDir().Join("services").MkdirAll()) + servicesIndex, err := servicesindex.Load(cfg.AssetsDir().Join("services")) + require.NoError(t, err, "Failed to load services index") + bricksIndex, err := bricksindex.Load(cfg.AssetsDir()) require.Nil(t, err, "Failed to load bricks index with custom content") br, ok := bricksIndex.FindBrickByID("arduino:dbstorage_tsstore") @@ -389,7 +398,7 @@ services: } // Run the provision function to generate the main compose file - err = generateMainComposeFile(&app, bricksIndex, "app-bricks:python-apps-base:dev-latest", cfg, env, unkownPlatform, devices) + err = generateMainComposeFile(&app, bricksIndex, servicesIndex, "app-bricks:python-apps-base:dev-latest", cfg, env, unkownPlatform, devices) require.NoError(t, err, "Failed to generate main compose file") composeFilePath := paths.New(tempDirectory).Join(".cache").Join("app-compose.yaml") require.True(t, composeFilePath.Exist(), "Main compose file should exist") @@ -445,7 +454,7 @@ services: HasVideoDevice: true, } // Run the provision function to generate the main compose file - err = generateMainComposeFile(&app, bricksIndex, "app-bricks:python-apps-base:dev-latest", cfg, env, unkownPlatform, devices) + err = generateMainComposeFile(&app, bricksIndex, servicesIndex, "app-bricks:python-apps-base:dev-latest", cfg, env, unkownPlatform, devices) require.NoError(t, err, "Failed to generate main compose file") composeFilePath := paths.New(tempDirectory).Join(".cache").Join("app-compose.yaml") require.True(t, composeFilePath.Exist(), "Main compose file should exist") @@ -510,6 +519,10 @@ bricks: err := cfg.AssetsDir().Join("bricks-list.yaml").WriteFile(bricksIndexContent) require.NoError(t, err) + require.NoError(t, cfg.AssetsDir().Join("services").MkdirAll()) + servicesIndex, err := servicesindex.Load(cfg.AssetsDir().Join("services")) + require.NoError(t, err, "Failed to load services index") + bricksIndex, err := bricksindex.Load(cfg.AssetsDir()) require.Nil(t, err, "Failed to load bricks index with custom content") br, ok := bricksIndex.FindBrickByID("arduino:dbstorage_tsstore") @@ -567,7 +580,7 @@ services: HasVideoDevice: true, } // Run the provision function to generate the main compose file - err = generateMainComposeFile(&app, bricksIndex, "app-bricks:python-apps-base:dev-latest", cfg, env, unkownPlatform, availableDevices) + err = generateMainComposeFile(&app, bricksIndex, servicesIndex, "app-bricks:python-apps-base:dev-latest", cfg, env, unkownPlatform, availableDevices) require.NoError(t, err, "Failed to generate main compose file") composeFilePath := paths.New(tempDirectory).Join(".cache").Join("app-compose.yaml") require.True(t, composeFilePath.Exist(), "Main compose file should exist") @@ -603,3 +616,104 @@ services: }) } + +func TestProvisionAppWithServices(t *testing.T) { + cfg := setTestOrchestratorConfig(t) + tempDirectory := t.TempDir() + + // Define a mock app with bricks that require overrides + app := app.ArduinoApp{ + Name: "TestApp", + Descriptor: app.AppDescriptor{ + Bricks: []app.Brick{ + { + ID: "arduino:video_object_detection", + }, + }, + }, + FullPath: paths.New(tempDirectory), + } + require.NoError(t, app.ProvisioningStateDir().MkdirAll()) + // Add compose files for the bricks - video object detection + videoObjectDetectionPath := cfg.AssetsDir().Join("services", "arduino", "video_object_detection") + require.NoError(t, videoObjectDetectionPath.MkdirAll()) + composeForVideoObjectDetection := ` +version: '3.8' +services: + ei-video-obj-detection-runner: + image: arduino/video-object-detection:latest + ports: + - "8080:8080" +` + err := videoObjectDetectionPath.Join("service_compose.yaml").WriteFile([]byte(composeForVideoObjectDetection)) + require.NoError(t, err) + + configForVideoObjectDetection := ` +service_id: arduino:foo +name: Foo Service +description: | + This is a sample Foo service used for testing purposes. +category: test +supported_boards: ["foobar"] +` + err = videoObjectDetectionPath.Join("service_config.yaml").WriteFile([]byte(configForVideoObjectDetection)) + require.NoError(t, err) + + bricksIndexContent := []byte(` +bricks: +- id: arduino:video_object_detection + name: Object Detection + description: "Brick for object detection using a pre-trained model." + require_container: false + require_model: false + ports: [] + category: video + requires_services: ["arduino:foo"]`) + err = cfg.AssetsDir().Join("bricks-list.yaml").WriteFile(bricksIndexContent) + require.NoError(t, err) + servicesIndex, err := servicesindex.Load(cfg.AssetsDir().Join("services")) + require.NoError(t, err, "Failed to load services index") + + // Override brick index with custom test content + bricksIndex, err := bricksindex.Load(cfg.AssetsDir()) + require.Nil(t, err, "Failed to load bricks index with custom content") + + br, ok := bricksIndex.FindBrickByID("arduino:video_object_detection") + require.True(t, ok, "Brick arduino:video_object_detection should exist in the index") + require.NotNil(t, br, "Brick arduino:video_object_detection should not be nil") + require.Equal(t, "Object Detection", br.Name, "Brick name should match") + + service, ok := servicesIndex.FindServiceByID("arduino:foo") + require.True(t, ok, "Service arduino:foo should exist in the index") + require.NotNil(t, service, "Service arduino:foo should not be nil") + compose, ok := service.GetComposeFile() + require.True(t, ok, "Service arduino:foo should have a compose file") + require.Equal(t, videoObjectDetectionPath.Join("service_compose.yaml").String(), compose.String()) + + // Run the provision function to generate the main compose file + env := map[string]string{ + "FOO": "bar", + } + + err = generateMainComposeFile(&app, bricksIndex, servicesIndex, "app-bricks:python-apps-base:dev-latest", cfg, env, unkownPlatform, peripherals.AvailableDevices{}) + + // Validate that the main compose file and overrides are created + require.NoError(t, err, "Failed to generate main compose file") + composeFilePath := paths.New(tempDirectory).Join(".cache").Join("app-compose.yaml") + require.True(t, composeFilePath.Exist(), "Main compose file should exist") + overridesFilePath := paths.New(tempDirectory).Join(".cache").Join("app-compose-overrides.yaml") + require.True(t, overridesFilePath.Exist(), "Override compose file should exist") + + // Open override file and check for the expected override + overridesContent, err := overridesFilePath.ReadFile() + require.NoError(t, err) + + type services struct { + Services map[string]map[string]interface{} `yaml:"services"` + } + content := services{} + err = yaml.Unmarshal(overridesContent, &content) + require.Nil(t, err, "Failed to unmarshal overrides content") + require.NotNil(t, content.Services["ei-video-obj-detection-runner"], "Override for ei-video-obj-detection-runner should exist") + require.Equal(t, "bar", content.Services["ei-video-obj-detection-runner"]["environment"].(map[string]interface{})["FOO"]) +} diff --git a/internal/orchestrator/servicesindex/services_index.go b/internal/orchestrator/servicesindex/services_index.go new file mode 100644 index 000000000..2f4fbb5ec --- /dev/null +++ b/internal/orchestrator/servicesindex/services_index.go @@ -0,0 +1,104 @@ +// 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 servicesindex + +import ( + "fmt" + "os" + "slices" + + "github.com/arduino/go-paths-helper" + "github.com/goccy/go-yaml" +) + +type ServicesIndex struct { + Services []Service `yaml:"services"` +} + +type Service struct { + ServiceID string `yaml:"service_id"` + Name string `yaml:"name"` + Description string `yaml:"description,omitempty"` + Category string `yaml:"category"` + SupportedBoards []string `yaml:"supported_boards"` + + ComposeFile *paths.Path `yaml:"-"` // brick_compose.yaml file path, optional +} + +func Load(dir *paths.Path) (*ServicesIndex, error) { + // If assets//services does not exist, we return an empty index without error, to allow the CLI to work without services + if !dir.IsDir() { + return &ServicesIndex{}, nil + } + services, err := loadFromFolder(dir) + if err != nil { + return nil, err + } + return &ServicesIndex{Services: services}, nil +} + +func (s Service) GetComposeFile() (*paths.Path, bool) { + if s.ComposeFile == nil || s.ComposeFile.NotExist() { + return nil, false + } + return s.ComposeFile, true +} + +func (s *ServicesIndex) FindServiceByID(id string) (*Service, bool) { + idx := slices.IndexFunc(s.Services, func(service Service) bool { + return service.ServiceID == id + }) + if idx == -1 { + return nil, false + } + return &s.Services[idx], true +} + +func loadFromFolder(dir *paths.Path) ([]Service, error) { + pathsList, err := dir.ReadDirRecursiveFiltered(nil, paths.AndFilter(paths.FilterDirectories(), func(file *paths.Path) bool { + return file.Join("service_config.yaml").Exist() + })) + if err != nil { + return nil, err + } + + services := make([]Service, 0, len(pathsList)) + for _, path := range pathsList { + service, err := load(path) + if err != nil { + return nil, err + } + services = append(services, service) + } + return services, nil +} + +func load(servicePath *paths.Path) (a Service, err error) { + serviceConfigPath := servicePath.Join("service_config.yaml") + if serviceConfigPath.NotExist() { + return Service{}, fmt.Errorf("service_config.yaml does not exist: %v", serviceConfigPath) + } + serviceConfigContent, err := os.ReadFile(serviceConfigPath.String()) + if err != nil { + return Service{}, fmt.Errorf("cannot read service_config.yaml: %w", err) + } + var service Service + if err := yaml.Unmarshal(serviceConfigContent, &service); err != nil { + return Service{}, fmt.Errorf("cannot unmarshal service_config.yaml: %w", err) + } + service.ComposeFile = servicePath.Join("service_compose.yaml") + return service, nil +} diff --git a/internal/orchestrator/servicesindex/services_index_test.go b/internal/orchestrator/servicesindex/services_index_test.go new file mode 100644 index 000000000..10fc05828 --- /dev/null +++ b/internal/orchestrator/servicesindex/services_index_test.go @@ -0,0 +1,38 @@ +// 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 servicesindex + +import ( + "testing" + + "github.com/arduino/go-paths-helper" + "github.com/stretchr/testify/require" +) + +func TestLoadServicesIndex(t *testing.T) { + servicesIndex, err := Load(paths.New("testdata/services")) + require.NoError(t, err) + + service, ok := servicesIndex.FindServiceByID("arduino:foo") + require.True(t, ok) + require.Equal(t, "Foo Service", service.Name) + require.Equal(t, "test", service.Category) + require.Equal(t, []string{"foobar"}, service.SupportedBoards) + + compose, ok := service.GetComposeFile() + require.True(t, ok) + require.Equal(t, paths.New("testdata", "services", "arduino", "foo", "service_compose.yaml").String(), compose.String()) +} diff --git a/internal/orchestrator/servicesindex/testdata/services/arduino/foo/service_compose.yaml b/internal/orchestrator/servicesindex/testdata/services/arduino/foo/service_compose.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/internal/orchestrator/servicesindex/testdata/services/arduino/foo/service_config.yaml b/internal/orchestrator/servicesindex/testdata/services/arduino/foo/service_config.yaml new file mode 100644 index 000000000..7a25221c2 --- /dev/null +++ b/internal/orchestrator/servicesindex/testdata/services/arduino/foo/service_config.yaml @@ -0,0 +1,6 @@ +service_id: arduino:foo +name: Foo Service +description: | + This is a sample Foo service used for testing purposes. +category: test +supported_boards: ["foobar"] diff --git a/internal/store/store.go b/internal/store/store.go index 41ba6b231..f6085113d 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -25,17 +25,18 @@ import ( ) type StaticStore struct { - baseDir string - composePath string - assetsPath *paths.Path + baseDir string + composePath string + assetsPath *paths.Path + servicesPath string } func NewStaticStore(baseDir string) *StaticStore { return &StaticStore{ - baseDir: baseDir, - composePath: filepath.Join(baseDir, "compose"), - assetsPath: paths.New(baseDir), - } + baseDir: baseDir, + composePath: filepath.Join(baseDir, "compose"), + assetsPath: paths.New(baseDir), + servicesPath: filepath.Join(baseDir, "services")} } func (s *StaticStore) SaveComposeFolderTo(dst string) error { @@ -55,3 +56,7 @@ func (s *StaticStore) GetAssetsFolder() *paths.Path { func (s *StaticStore) GetComposeFolder() *paths.Path { return paths.New(s.composePath) } + +func (s *StaticStore) GetServicesFolder() *paths.Path { + return paths.New(s.servicesPath) +}