diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index a71062c3c35..96c2d68dbf1 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -431,7 +431,7 @@ func registerCommonDependencies(container *ioc.NestedContainer) { } featureCustomLanguage := alpha.MustFeatureKey("language.custom") - for sName, sConfig := range projectConfig.Services { + for sName, sConfig := range projectConfig.ServiceConfigs() { if sConfig.Language == project.ServiceLanguageCustom && !alphaManager.IsEnabled(featureCustomLanguage) { return nil, fmt.Errorf( diff --git a/cli/azd/cmd/project_extension_auto_install.go b/cli/azd/cmd/project_extension_auto_install.go index a1c26548cde..db6bb1ed3f5 100644 --- a/cli/azd/cmd/project_extension_auto_install.go +++ b/cli/azd/cmd/project_extension_auto_install.go @@ -661,16 +661,17 @@ func missingProjectExtensions( return nil } - for _, serviceName := range slices.Sorted(maps.Keys(projectConfig.Services)) { + services := projectConfig.ServiceConfigs() + for _, serviceName := range slices.Sorted(maps.Keys(services)) { if err := addProvider( extensions.ServiceTargetProviderCapability, - string(projectConfig.Services[serviceName].Host), + string(services[serviceName].Host), ); err != nil { return nil, err } } - for _, infra := range projectConfig.Infra.GetLayers() { + for _, infra := range projectConfig.InfrastructureConfigs() { if err := addProvider(extensions.ProvisioningProviderCapability, string(infra.Provider)); err != nil { return nil, err } diff --git a/cli/azd/internal/grpcserver/container_service.go b/cli/azd/internal/grpcserver/container_service.go index 1e129bafd5a..0cea5a4e940 100644 --- a/cli/azd/internal/grpcserver/container_service.go +++ b/cli/azd/internal/grpcserver/container_service.go @@ -161,7 +161,7 @@ func (c *containerService) Build( return nil, err } - serviceConfig, has := projectConfig.Services[req.ServiceName] + serviceConfig, has := projectConfig.ServiceConfigs()[req.ServiceName] if !has { return nil, status.Errorf(codes.NotFound, "service %q not found in project configuration", req.ServiceName) @@ -216,7 +216,7 @@ func (c *containerService) Package( return nil, err } - serviceConfig, has := projectConfig.Services[req.ServiceName] + serviceConfig, has := projectConfig.ServiceConfigs()[req.ServiceName] if !has { return nil, status.Errorf(codes.NotFound, "service %q not found in project configuration", req.ServiceName) @@ -271,7 +271,7 @@ func (c *containerService) Publish( return nil, err } - serviceConfig, has := projectConfig.Services[req.ServiceName] + serviceConfig, has := projectConfig.ServiceConfigs()[req.ServiceName] if !has { return nil, status.Errorf(codes.NotFound, "service %q not found in project configuration", req.ServiceName) diff --git a/cli/azd/internal/grpcserver/event_service.go b/cli/azd/internal/grpcserver/event_service.go index 75bebdbdfc2..39c8a85eb8c 100644 --- a/cli/azd/internal/grpcserver/event_service.go +++ b/cli/azd/internal/grpcserver/event_service.go @@ -231,7 +231,7 @@ func (s *eventService) onSubscribeServiceEvent( } evt := ext.Event(eventName) - for _, serviceConfig := range projectConfig.Services { + for _, serviceConfig := range projectConfig.ServiceConfigs() { if subscribeMsg.Language != "" && string(serviceConfig.Language) != subscribeMsg.Language { continue } diff --git a/cli/azd/internal/grpcserver/project_service.go b/cli/azd/internal/grpcserver/project_service.go index 1421db208af..b4eae06ab54 100644 --- a/cli/azd/internal/grpcserver/project_service.go +++ b/cli/azd/internal/grpcserver/project_service.go @@ -6,7 +6,9 @@ package grpcserver import ( "context" "fmt" + "iter" "log" + "maps" "sync" "github.com/azure/azure-dev/cli/azd/internal/mapper" @@ -109,13 +111,67 @@ func (s *projectService) validateServiceExists(ctx context.Context, serviceName return err } - if projectConfig.Services == nil || projectConfig.Services[serviceName] == nil { + if projectConfig.ServiceConfigs()[serviceName] == nil { return fmt.Errorf("service '%s' not found", serviceName) } return nil } +func allLayerServiceConfigs(layers []any) iter.Seq2[string, config.Config] { + return func(yield func(string, config.Config) bool) { + for _, rawLayer := range layers { + layer, ok := rawLayer.(map[string]any) + if !ok { + continue + } + services, ok := layer["services"].(map[string]any) + if !ok { + continue + } + for name, rawService := range services { + service, ok := rawService.(map[string]any) + if ok && !yield(name, config.NewConfig(service)) { + return + } + } + } + } +} + +func (s *projectService) serviceConfig(cfg config.Config, serviceName string) (config.Config, error) { + projectConfig, err := s.lazyProjectConfig.GetValue() + if err != nil { + return nil, err + } + + if projectConfig.Format() != project.ProjectFormatLayersV2 { + services, ok := cfg.Raw()["services"].(map[string]any) + if !ok { + return nil, fmt.Errorf("services configuration not found") + } + service, ok := services[serviceName].(map[string]any) + if !ok { + return nil, fmt.Errorf("service configuration for '%s' not found", serviceName) + } + return config.NewConfig(service), nil + } + + layers, ok := cfg.Raw()["layers"].([]any) + if !ok { + return nil, fmt.Errorf("layers configuration not found") + } + + // NOTE: this is temporary. We'll make these services layer-scoped later instead of flattening them into one map. + for name, service := range allLayerServiceConfigs(layers) { + if name == serviceName { + return service, nil + } + } + + return nil, fmt.Errorf("service '%s' not found", serviceName) +} + // Get retrieves the complete project configuration including all services and metadata. // This method resolves environment variables in configuration values using the environment // for the current session and converts the internal project configuration to the protobuf @@ -195,6 +251,9 @@ func (s *projectService) AddService(ctx context.Context, req *azdext.AddServiceR if err != nil { return nil, err } + if projectConfig.Format() == project.ProjectFormatLayersV2 { + return nil, status.Error(codes.Unimplemented, "adding services to layered projects is not supported") + } serviceConfig := &project.ServiceConfig{} if err := mapper.Convert(req.Service, &serviceConfig); err != nil { @@ -552,13 +611,15 @@ func (s *projectService) GetServiceConfigSection( return nil, err } - // Construct path to service config section: "services.." - servicePath := fmt.Sprintf("services.%s", req.ServiceName) - if req.Path != "" { - servicePath = fmt.Sprintf("%s.%s", servicePath, req.Path) + serviceConfig, err := s.serviceConfig(cfg, req.ServiceName) + if err != nil { + return nil, err } - section, found := cfg.GetMap(servicePath) + section, found := serviceConfig.GetMap(req.Path) + if req.Path == "" { + section, found = serviceConfig.Raw(), true + } if !found { return &azdext.GetServiceConfigSectionResponse{ @@ -619,10 +680,12 @@ func (s *projectService) GetServiceConfigValue( return nil, err } - // Construct path to service config value: "services.." - servicePath := fmt.Sprintf("services.%s.%s", req.ServiceName, req.Path) + serviceConfig, err := s.serviceConfig(cfg, req.ServiceName) + if err != nil { + return nil, err + } - value, ok := cfg.Get(servicePath) + value, ok := serviceConfig.Get(req.Path) if !ok { return &azdext.GetServiceConfigValueResponse{ @@ -680,15 +743,17 @@ func (s *projectService) SetServiceConfigSection( return nil, err } - // Construct path to service config section: "services.." - servicePath := fmt.Sprintf("services.%s", req.ServiceName) - if req.Path != "" { - servicePath = fmt.Sprintf("%s.%s", servicePath, req.Path) + serviceConfig, err := s.serviceConfig(cfg, req.ServiceName) + if err != nil { + return nil, err } // Convert protobuf Struct to map sectionMap := req.Section.AsMap() - if err := cfg.Set(servicePath, sectionMap); err != nil { + if req.Path == "" { + clear(serviceConfig.Raw()) + maps.Copy(serviceConfig.Raw(), sectionMap) + } else if err := serviceConfig.Set(req.Path, sectionMap); err != nil { return nil, fmt.Errorf("failed to set service config section: %w", err) } @@ -747,18 +812,14 @@ func (s *projectService) SetServiceConfigValue( return nil, err } - services, ok := cfg.Raw()["services"].(map[string]any) - if !ok { - return nil, fmt.Errorf("services configuration not found") - } - serviceConfig, ok := services[req.ServiceName].(map[string]any) - if !ok { - return nil, fmt.Errorf("service configuration for '%s' not found", req.ServiceName) + serviceConfig, err := s.serviceConfig(cfg, req.ServiceName) + if err != nil { + return nil, err } // Convert protobuf Value to interface{} value := req.Value.AsInterface() - if err := config.NewConfig(serviceConfig).Set(req.Path, value); err != nil { + if err := serviceConfig.Set(req.Path, value); err != nil { return nil, fmt.Errorf("failed to set service config value: %w", err) } @@ -817,10 +878,12 @@ func (s *projectService) UnsetServiceConfig( return nil, err } - // Construct path to service config: "services.." - servicePath := fmt.Sprintf("services.%s.%s", req.ServiceName, req.Path) + serviceConfig, err := s.serviceConfig(cfg, req.ServiceName) + if err != nil { + return nil, err + } - if err := cfg.Unset(servicePath); err != nil { + if err := serviceConfig.Unset(req.Path); err != nil { return nil, fmt.Errorf("failed to unset service config: %w", err) } @@ -920,7 +983,7 @@ func (s *projectService) GetServiceTargetResource( } // Validate the service exists - serviceConfig, exists := projectConfig.Services[req.ServiceName] + serviceConfig, exists := projectConfig.ServiceConfigs()[req.ServiceName] if !exists { return nil, status.Errorf(codes.NotFound, "service '%s' not found in project", req.ServiceName) } diff --git a/cli/azd/internal/grpcserver/project_service_test.go b/cli/azd/internal/grpcserver/project_service_test.go index 1c195fcfa90..00eca86fcc0 100644 --- a/cli/azd/internal/grpcserver/project_service_test.go +++ b/cli/azd/internal/grpcserver/project_service_test.go @@ -2623,6 +2623,124 @@ services: project: ./src/api ` +func TestProjectService_LayeredServiceConfig(t *testing.T) { + t.Parallel() + + const yamlWithLayeredService = `name: test-project +layers: + - name: app + services: + my.agent: + host: appservice + language: python + project: ./src/api + custom: + setting: original + removable: value +` + + svc := newProjectServiceWithYaml(t, yamlWithLayeredService) + + // Service names (currently) are still globally unique, even when in layers, so the layer is not + // needed for this lookup. When services become layer-scoped, this unqualified lookup should become + // ambiguous and the test should change with the API. + section, err := svc.GetServiceConfigSection(t.Context(), &azdext.GetServiceConfigSectionRequest{ + ServiceName: "my.agent", + Path: "custom", + }) + require.NoError(t, err) + require.True(t, section.Found) + require.Equal(t, "original", section.Section.AsMap()["setting"]) + + _, err = svc.SetServiceConfigValue(t.Context(), &azdext.SetServiceConfigValueRequest{ + ServiceName: "my.agent", + Path: "custom.setting", + Value: structpb.NewStringValue("updated"), + }) + require.NoError(t, err) + + _, err = svc.UnsetServiceConfig(t.Context(), &azdext.UnsetServiceConfigRequest{ + ServiceName: "my.agent", + Path: "custom.removable", + }) + require.NoError(t, err) + + value, err := svc.GetServiceConfigValue(t.Context(), &azdext.GetServiceConfigValueRequest{ + ServiceName: "my.agent", + Path: "custom.setting", + }) + require.NoError(t, err) + require.True(t, value.Found) + require.Equal(t, "updated", value.Value.AsInterface()) + + _, err = svc.AddService(t.Context(), &azdext.AddServiceRequest{Service: &azdext.ServiceConfig{Name: "web"}}) + require.Error(t, err) + require.Equal(t, codes.Unimplemented, status.Code(err)) + + projectService := svc.(*projectService) + azdContext, err := projectService.lazyAzdContext.GetValue() + require.NoError(t, err) + saved, err := project.LoadConfig(t.Context(), azdContext.ProjectPath()) + require.NoError(t, err) + _, hasFlatServices := saved.Raw()["services"] + require.False(t, hasFlatServices) + layers, ok := saved.Raw()["layers"].([]any) + require.True(t, ok) + layer, ok := layers[0].(map[string]any) + require.True(t, ok) + services, ok := layer["services"].(map[string]any) + require.True(t, ok) + serviceConfig, ok := services["my.agent"].(map[string]any) + require.True(t, ok) + custom, ok := serviceConfig["custom"].(map[string]any) + require.True(t, ok) + require.Equal(t, "updated", custom["setting"]) + require.NotContains(t, custom, "removable") +} + +func TestProjectService_SetServiceConfigValue_PreservesInfraV1(t *testing.T) { + t.Parallel() + + svc := newProjectServiceWithYaml(t, `name: test-project +infra: + provider: bicep + layers: + - name: network + path: infra/network + - name: application + provider: terraform + path: infra/application +services: + api: + host: appservice + language: python + project: ./src/api +`) + + _, err := svc.SetServiceConfigValue(t.Context(), &azdext.SetServiceConfigValueRequest{ + ServiceName: "api", + Path: "custom.setting", + Value: structpb.NewStringValue("updated"), + }) + require.NoError(t, err) + + projectService := svc.(*projectService) + azdContext, err := projectService.lazyAzdContext.GetValue() + require.NoError(t, err) + + reloaded, err := project.Load(t.Context(), azdContext.ProjectPath()) + require.NoError(t, err) + + require.Equal(t, project.ProjectFormatInfraV1, reloaded.Format()) + require.Equal(t, provisioning.Bicep, reloaded.Infra.Provider) + require.Len(t, reloaded.Infra.Layers, 2) + require.Equal(t, "network", reloaded.Infra.Layers[0].Name) + require.Equal(t, provisioning.Terraform, reloaded.Infra.Layers[1].Provider) + custom, ok := reloaded.Services["api"].AdditionalProperties["custom"].(map[string]any) + require.True(t, ok) + require.Equal(t, "updated", custom["setting"]) +} + func TestProjectService_SetServiceConfigSection_HappyPath(t *testing.T) { t.Parallel() svc := newProjectServiceWithYaml(t, yamlWithService) diff --git a/cli/azd/pkg/infra/provisioning/options_test.go b/cli/azd/pkg/infra/provisioning/options_test.go index 26fac07a768..457c2448c24 100644 --- a/cli/azd/pkg/infra/provisioning/options_test.go +++ b/cli/azd/pkg/infra/provisioning/options_test.go @@ -84,6 +84,22 @@ func TestOptionsGetLayers(t *testing.T) { assert.Equal(t, "infra", layers[0].Path) }) + // NOTE: It's 100% possible this fallback was never used by customers, but it is + // explicitly coded to work that way. + t.Run("explicit empty layers preserve legacy single entry", func(t *testing.T) { + opts := &Options{ + Provider: Bicep, + Path: "infra", + Layers: []Options{}} + + // Keep the existing fallback: an empty infra.layers list is treated the same as no list + // and returns the root infrastructure entry. + layers := opts.GetLayers() + require.Len(t, layers, 1) + require.Equal(t, Bicep, layers[0].Provider) + require.Equal(t, "infra", layers[0].Path) + }) + t.Run("with layers returns layers", func(t *testing.T) { opts := &Options{ Layers: []Options{ @@ -243,20 +259,48 @@ func TestOptionsValidate(t *testing.T) { ) }) - t.Run("layer without path is invalid", + t.Run("provider-managed legacy layer without path is invalid", func(t *testing.T) { opts := &Options{ Layers: []Options{ - {Name: "l1"}, + {Name: "foundry", Provider: ProviderKind("microsoft.foundry")}, }, } err := opts.Validate() - require.Error(t, err) - assert.Contains( - t, err.Error(), "path must be specified", - ) + require.ErrorContains(t, err, "path must be specified") }) + t.Run("provider-managed project layer without path is valid", func(t *testing.T) { + opts := &Options{Layers: []Options{ + {Name: "foundry", Provider: ProviderKind("microsoft.foundry")}, + }} + + require.NoError(t, opts.ValidateProjectLayers()) + }) + + t.Run("legacy layers allow root provider config", func(t *testing.T) { + opts := &Options{ + Config: map[string]any{"setting": "value"}, + Layers: []Options{{Name: "foundry", Path: "infra/foundry"}}, + } + + require.NoError(t, opts.Validate()) + }) + + t.Run("explicit empty layers preserve legacy root fields", func(t *testing.T) { + opts := &Options{Path: "infra", Layers: []Options{}} + + require.NoError(t, opts.Validate()) + }) + + t.Run("built-in layer without path is invalid", func(t *testing.T) { + opts := &Options{Layers: []Options{{Name: "bicep", Provider: Bicep}}} + + err := opts.Validate() + + require.ErrorContains(t, err, "path must be specified") + }) + t.Run("multiple valid layers", func(t *testing.T) { opts := &Options{ Layers: []Options{ diff --git a/cli/azd/pkg/infra/provisioning/provider.go b/cli/azd/pkg/infra/provisioning/provider.go index 2ab6a546cf1..926d8d8c7c9 100644 --- a/cli/azd/pkg/infra/provisioning/provider.go +++ b/cli/azd/pkg/infra/provisioning/provider.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "path/filepath" + "slices" "strings" "dario.cat/mergo" @@ -50,21 +51,23 @@ const ( // Options for a provisioning provider. type Options struct { - Provider ProviderKind `yaml:"provider,omitempty"` - Path string `yaml:"path,omitempty"` - Module string `yaml:"module,omitempty"` - Name string `yaml:"name,omitempty"` + Provider ProviderKind `yaml:"provider,omitempty"` + Path string `yaml:"path,omitempty"` + Module string `yaml:"module,omitempty"` + Name string `yaml:"name,omitempty"` + // Layer is assigned from the containing project layer. + Layer string `yaml:"-" json:"layer,omitempty"` Hooks HooksConfig `yaml:"hooks,omitempty"` DeploymentStacks *DeploymentStacksConfig `yaml:"deploymentStacks,omitempty"` // Config holds provider-specific configuration options Config map[string]any `yaml:"config,omitempty"` - // DependsOn lists the names of other layers this layer must wait for + // DependsOn lists the names of other infrastructure entries this entry must wait for // before being provisioned. Use this to declare hook-mediated edges - // (for example, when a postprovision hook in another layer writes an - // env var that this layer's bicepparam reads at provision time) + // (for example, when a postprovision hook in another entry writes an + // env var that this entry's bicepparam reads at provision time) // that the static analyzer cannot infer from .bicep / .bicepparam / - // .parameters.json contents alone. Only valid on layer entries under - // the `infra.layers` array. + // .parameters.json contents alone. Valid under both `infra.layers[]` + // and `layers[].infra[]`. DependsOn []string `yaml:"dependsOn,omitempty" json:"dependsOn,omitempty"` // Provisioning options for each individually defined layer. Layers []Options `yaml:"layers,omitempty"` @@ -159,6 +162,15 @@ func (o *Options) GetLayer(name string) (Options, error) { // // This should be called immediately right after Unmarshal() before any defaulting is performed. func (o *Options) Validate() error { + return o.validate(false) +} + +// ValidateProjectLayers validates infrastructure entries declared under top-level project layers. +func (o *Options) ValidateProjectLayers() error { + return o.validate(true) +} + +func (o *Options) validate(allowPathlessExtensionProviders bool) error { if len(o.Hooks) > 0 { return validateErr("infra", "'hooks' can only be declared under 'infra.layers[]'") } @@ -172,7 +184,7 @@ func (o *Options) Validate() error { return validateErr("infra", "properties on 'infra' cannot be declared when 'infra.layers' is declared") } - if err := o.validateLayers(); err != nil { + if err := o.validateLayers(allowPathlessExtensionProviders); err != nil { return wrapValidateErr("infra.layers", err) } } @@ -192,7 +204,7 @@ func validateErr(scope, format string, args ...any) error { return wrapValidateErr(scope, fmt.Errorf(format, args...)) } -func (o *Options) validateLayers() error { +func (o *Options) validateLayers(allowPathlessExtensionProviders bool) error { validateHooks := func(scope string, hooks HooksConfig) error { for hookName := range hooks { hookType, eventName := ext.InferHookType(hookName) @@ -216,7 +228,11 @@ func (o *Options) validateLayers() error { seenLayers[layer.Name] = struct{}{} - if layer.Path == "" { + // NOTE: I'm treating 'NotSpecified' as 'bicep' - there's some downstream code that does that in + // 'provisioning/manager'. + // It might be nice to think about doing this earlier, or having that validation occurring in the providers instead. + if layer.Path == "" && (!allowPathlessExtensionProviders || + layer.Provider == NotSpecified || slices.Contains(builtInProviderKinds, layer.Provider)) { return fmt.Errorf("%s: path must be specified", layer.Name) } diff --git a/cli/azd/pkg/project/importer.go b/cli/azd/pkg/project/importer.go index 223c07ef591..e8d8e6dfe8a 100644 --- a/cli/azd/pkg/project/importer.go +++ b/cli/azd/pkg/project/importer.go @@ -53,11 +53,12 @@ var ( // Retrieves the list of services in the project, in a stable ordering that is deterministic. func (im *ImportManager) ServiceStable(ctx context.Context, projectConfig *ProjectConfig) ([]*ServiceConfig, error) { allServices := make(map[string]*ServiceConfig) + configuredServices := projectConfig.ServiceConfigs() - for name, svcConfig := range projectConfig.Services { + for name, svcConfig := range configuredServices { if svcConfig.Language == ServiceLanguageDotNet { if canImport, err := im.dotNetImporter.CanImport(ctx, svcConfig.Path()); canImport { - if len(projectConfig.Services) != 1 { + if len(configuredServices) != 1 { return nil, errNoMultipleServicesWithAppHost } @@ -268,7 +269,7 @@ func (im *ImportManager) validateServiceDependencies(services []*ServiceConfig, // HasAppHost returns true when there is one AppHost (Aspire) in the project. func (im *ImportManager) HasAppHost(ctx context.Context, projectConfig *ProjectConfig) bool { - for _, svcConfig := range projectConfig.Services { + for _, svcConfig := range projectConfig.ServiceConfigs() { if svcConfig.Language == ServiceLanguageDotNet { if canImport, err := im.dotNetImporter.CanImport(ctx, svcConfig.Path()); canImport { return true @@ -292,6 +293,17 @@ var ( // The configuration can be explicitly defined on azure.yaml using path and module, or in case these values // are not explicitly defined, the project importer uses default values to find the infrastructure. func (im *ImportManager) ProjectInfrastructure(ctx context.Context, projectConfig *ProjectConfig) (*Infra, error) { + if projectConfig.Format() == ProjectFormatLayersV2 { + entries := make([]provisioning.Options, 0) + for _, layer := range projectConfig.Layers { + for _, infra := range layer.Infra { + infra.Layer = layer.Name + entries = append(entries, infra) + } + } + return &Infra{Options: provisioning.Options{Layers: entries}}, nil + } + infraOptions, err := projectConfig.Infra.GetWithDefaults() if err != nil { return nil, err @@ -316,6 +328,9 @@ func (im *ImportManager) ProjectInfrastructure(ctx context.Context, projectConfi // short-circuit: If layers are defined, we know it's an explicit infrastructure if len(infraOptions.Layers) > 0 { + for i := range infraOptions.Layers { + infraOptions.Layers[i].Layer = infraOptions.Layers[i].Name + } return &Infra{ Options: infraOptions, }, nil @@ -330,10 +345,11 @@ func (im *ImportManager) ProjectInfrastructure(ctx context.Context, projectConfi } // Temp infra from AppHost - for _, svcConfig := range projectConfig.Services { + configuredServices := projectConfig.ServiceConfigs() + for _, svcConfig := range configuredServices { if svcConfig.Language == ServiceLanguageDotNet { if canImport, err := im.dotNetImporter.CanImport(ctx, svcConfig.Path()); canImport { - if len(projectConfig.Services) != 1 { + if len(configuredServices) != 1 { return nil, errNoMultipleServicesWithAppHost } @@ -426,10 +442,11 @@ func pathHasModule(path, module string) (bool, error) { // GenerateAllInfrastructure returns a file system containing all infrastructure for the project, // rooted at the project directory. func (im *ImportManager) GenerateAllInfrastructure(ctx context.Context, projectConfig *ProjectConfig) (fs.FS, error) { - for _, svcConfig := range projectConfig.Services { + configuredServices := projectConfig.ServiceConfigs() + for _, svcConfig := range configuredServices { if svcConfig.Language == ServiceLanguageDotNet { if canImport, err := im.dotNetImporter.CanImport(ctx, svcConfig.Path()); canImport { - if len(projectConfig.Services) != 1 { + if len(configuredServices) != 1 { return nil, errNoMultipleServicesWithAppHost } diff --git a/cli/azd/pkg/project/importer_test.go b/cli/azd/pkg/project/importer_test.go index 60d09e70947..ce94f9cbde7 100644 --- a/cli/azd/pkg/project/importer_test.go +++ b/cli/azd/pkg/project/importer_test.go @@ -257,6 +257,54 @@ func TestImportManagerProjectInfrastructure(t *testing.T) { require.Equal(t, expectedDefaultModule, r.Options.Module) } +func TestImportManagerProjectInfrastructureProjectLayers(t *testing.T) { + t.Parallel() + + manager := NewImportManager(nil) + result, err := manager.ProjectInfrastructure(t.Context(), &ProjectConfig{Layers: []*LayerConfig{ + {Name: "shared", Infra: []provisioning.Options{{Name: "network", Provider: provisioning.Bicep}}}, + {Name: "application", Infra: []provisioning.Options{ + {Name: "database", Provider: provisioning.Terraform}, + {Name: "api", Provider: provisioning.Bicep}, + }}, + }}) + + require.NoError(t, err) + require.Len(t, result.Options.Layers, 3) + require.Equal(t, "network", result.Options.Layers[0].Name) + require.Equal(t, "shared", result.Options.Layers[0].Layer) + require.Equal(t, "database", result.Options.Layers[1].Name) + require.Equal(t, "application", result.Options.Layers[1].Layer) + require.Equal(t, "api", result.Options.Layers[2].Name) + require.Equal(t, "application", result.Options.Layers[2].Layer) + + selected, err := result.Options.GetLayer("api") + require.NoError(t, err) + require.Equal(t, "api", selected.Name) +} + +func TestImportManagerProjectInfrastructureServiceOnlyProjectUsesLegacyFallback(t *testing.T) { + t.Parallel() + + manager := NewImportManager(nil) + result, err := manager.ProjectInfrastructure(t.Context(), &ProjectConfig{ + Layers: LayerConfigs{ + { + Name: "application", + Services: map[string]*ServiceConfig{ + "api": {Name: "api", Host: ContainerAppTarget, Image: osutil.NewExpandableString("example/api")}, + }, + }, + }}) + + require.NoError(t, err) + require.Empty(t, result.Options.Layers) + + // TODO(https://github.com/Azure/azure-dev/issues/10018): Decide whether a layers v2 project with no + // infrastructure should provision the legacy root entry or perform no provisioning. + require.Len(t, result.Options.GetLayers(), 1) +} + //go:embed testdata/aspire-simple.json var aspireSimpleManifest []byte diff --git a/cli/azd/pkg/project/layer.go b/cli/azd/pkg/project/layer.go new file mode 100644 index 00000000000..e30961e92cb --- /dev/null +++ b/cli/azd/pkg/project/layer.go @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "errors" + "fmt" + "maps" + "slices" + + "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" +) + +// ValidateLayerGraph validates dependency and output ownership for persisted v2 layer infrastructure. +func ValidateLayerGraph(projectConfig *ProjectConfig) error { + if projectConfig == nil { + return errors.New("project config is nil") + } + if projectConfig.Format() != ProjectFormatLayersV2 { + return errors.New("layer graph validation requires a top-level layers project") + } + + layerNames := make(map[string]struct{}, len(projectConfig.Layers)) + infraEntries := make([]provisioning.Options, 0) + for _, configLayer := range projectConfig.Layers { + if configLayer == nil { + return errors.New("project layer cannot be nil") + } + if _, has := layerNames[configLayer.Name]; has { + return fmt.Errorf("duplicate project layer %q", configLayer.Name) + } + layerNames[configLayer.Name] = struct{}{} + for _, infra := range configLayer.Infra { + infra.Layer = configLayer.Name + infraEntries = append(infraEntries, infra) + } + } + + return validateLayerGraph(infraEntries) +} + +func validateLayerGraph(infraEntries []provisioning.Options) error { + infraByName := make(map[string]provisioning.Options, len(infraEntries)) + infraDependencySets := make(map[string]map[string]struct{}, len(infraEntries)) + for _, infra := range infraEntries { + if infra.Name == "" { + return errors.New("infrastructure entry name cannot be empty") + } + if _, has := infraByName[infra.Name]; has { + return fmt.Errorf("duplicate infrastructure entry %q", infra.Name) + } + infraByName[infra.Name] = infra + infraDependencySets[infra.Name] = map[string]struct{}{} + } + + for _, infra := range infraEntries { + for _, dependencyName := range infra.DependsOn { + if dependencyName == infra.Name { + return fmt.Errorf("infrastructure layer %q cannot depend on itself", infra.Name) + } + if _, has := infraByName[dependencyName]; !has { + return fmt.Errorf( + "infrastructure layer %q depends on unknown infrastructure layer %q", + infra.Name, dependencyName, + ) + } + infraDependencySets[infra.Name][dependencyName] = struct{}{} + } + } + if err := validateDependencyGraph(infraDependencySets, "infrastructure layer"); err != nil { + return err + } + + return nil +} + +func validateDependencyGraph(dependencies map[string]map[string]struct{}, subject string) error { + const ( + unvisited = iota + visiting + visited + ) + + states := make(map[string]int, len(dependencies)) + var visit func(string) error + visit = func(name string) error { + switch states[name] { + case visiting: + return fmt.Errorf("circular dependency detected at %s %q", subject, name) + case visited: + return nil + } + + states[name] = visiting + for _, dependency := range slices.Sorted(maps.Keys(dependencies[name])) { + if err := visit(dependency); err != nil { + return err + } + } + states[name] = visited + return nil + } + + for _, name := range slices.Sorted(maps.Keys(dependencies)) { + if err := visit(name); err != nil { + return err + } + } + return nil +} diff --git a/cli/azd/pkg/project/layer_config.go b/cli/azd/pkg/project/layer_config.go new file mode 100644 index 00000000000..0956a48e6d7 --- /dev/null +++ b/cli/azd/pkg/project/layer_config.go @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import "maps" + +import "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" + +// ProjectFormat identifies the persisted project layout. +type ProjectFormat int + +const ( + // ProjectFormatFlat is when you have `services:` and a single `infra:` entry at the top level: + // + // services: + // + // infra: + // provider: bicep + ProjectFormatFlat ProjectFormat = iota + + // ProjectFormatInfraV1 is when you have `services:` at the top level, similar to the flat infra, + // but you have layers in infra: + // + // services: + // + // infra: + // layers: + // - name: layer-a + // provider: bicep + // - name: layer-b + // provider: bicep + ProjectFormatInfraV1 + + // ProjectFormatLayersV2 is when you have a top-level `layers:` entry, where each layer + // can have `infra` and `services`: + // + // layers: + // - name: layer-a + // infra: + // - name: infra-a + // provider: bicep + // services: + // + // - name: layer-b + // services: + // + ProjectFormatLayersV2 +) + +// LayerConfig is one persisted project layer in azure.yaml. +type LayerConfig struct { + Name string `yaml:"name"` + Infra []provisioning.Options `yaml:"infra,omitempty"` + Services map[string]*ServiceConfig `yaml:"services,omitempty"` +} + +// LayerConfigs preserves an explicitly empty layers collection when marshaled. +type LayerConfigs []*LayerConfig + +// IsZero reports whether the layers field was absent from the project configuration. +func (layers LayerConfigs) IsZero() bool { + // we only want to omit the 'layers' field if it's non-existent. If it's + // just empty we're still a layers based project, just without any layers. + return layers == nil +} + +// Format returns the persisted layout used by the project. +func (pc *ProjectConfig) Format() ProjectFormat { + if pc.Layers != nil { + return ProjectFormatLayersV2 + } + if pc.Infra.Layers != nil { + return ProjectFormatInfraV1 + } + return ProjectFormatFlat +} + +// ServiceConfigs returns all configured services keyed by their globally unique names. +func (pc *ProjectConfig) ServiceConfigs() map[string]*ServiceConfig { + if pc.Format() != ProjectFormatLayersV2 { + return pc.Services + } + + services := make(map[string]*ServiceConfig) + for _, layer := range pc.Layers { + maps.Copy(services, layer.Services) + } + return services +} + +// InfrastructureConfigs returns all provisioning entries in declaration order. +func (pc *ProjectConfig) InfrastructureConfigs() []provisioning.Options { + if pc.Format() != ProjectFormatLayersV2 { + return pc.Infra.GetLayers() + } + + var entries []provisioning.Options + for _, layer := range pc.Layers { + for _, infra := range layer.Infra { + infra.Layer = layer.Name + entries = append(entries, infra) + } + } + return entries +} diff --git a/cli/azd/pkg/project/layer_test.go b/cli/azd/pkg/project/layer_test.go new file mode 100644 index 00000000000..6f521016d7a --- /dev/null +++ b/cli/azd/pkg/project/layer_test.go @@ -0,0 +1,599 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package project + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/ext" + "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" + "github.com/braydonk/yaml" + jsonschema "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProjectConfigCopyRuntimeStateMatchesLayersByName(t *testing.T) { + t.Parallel() + + dispatcher := ext.NewEventDispatcher[ServiceLifecycleEventArgs]() + source := &ProjectConfig{Layers: []*LayerConfig{ + {Name: "first", Services: map[string]*ServiceConfig{"api": {EventDispatcher: dispatcher}}}, + {Name: "second", Services: map[string]*ServiceConfig{"worker": {}}}, + }} + target := &ProjectConfig{Layers: []*LayerConfig{ + {Name: "second", Services: map[string]*ServiceConfig{"worker": {}}}, + {Name: "first", Services: map[string]*ServiceConfig{"api": {}}}, + }} + + source.CopyRuntimeStateTo(target) + + require.Same(t, dispatcher, target.Layers[1].Services["api"].EventDispatcher) +} + +func TestParseProjectLayers(t *testing.T) { + t.Parallel() + + projectConfig, err := Parse(t.Context(), `name: layered-project +layers: + - name: application + infra: + - name: app-infra + path: ./infra/app + provider: bicep + services: + api: + project: ./src/api + host: containerapp + language: js +`) + + require.NoError(t, err) + require.Len(t, projectConfig.Layers, 1) + assert.Equal(t, "application", projectConfig.Layers[0].Name) + require.Len(t, projectConfig.Layers[0].Infra, 1) + assert.Equal(t, "app-infra", projectConfig.Layers[0].Infra[0].Name) + assert.Equal(t, provisioning.Bicep, projectConfig.Layers[0].Infra[0].Provider) + require.Contains(t, projectConfig.Layers[0].Services, "api") + assert.Equal(t, "api", projectConfig.Layers[0].Services["api"].Name) + assert.Equal(t, "application", projectConfig.Layers[0].Infra[0].Layer) +} + +func TestParseProjectLayersRejectsMixedFormats(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + topLevel string + }{ + {name: "configured infra", topLevel: "infra:\n provider: bicep"}, + {name: "services", topLevel: "services:\n worker:\n host: containerapp\n image: example/worker:latest"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + yaml := fmt.Sprintf("name: mixed-project\n%s\n"+ + "layers:\n"+ + " - name: application\n"+ + " services:\n"+ + " api:\n"+ + " host: containerapp\n"+ + " image: example/api:latest\n", test.topLevel) + _, err := Parse(t.Context(), yaml) + + require.ErrorContains(t, err, "'layers' cannot be combined with top-level 'infra' or 'services'") + }) + } +} + +func TestParseProjectLayersRejectsResources(t *testing.T) { + t.Parallel() + + _, err := Parse(t.Context(), `name: layered-project +layers: + - name: application + services: + api: + host: containerapp + image: example/api:latest +resources: + storage: + type: storage +`) + + require.ErrorContains(t, err, "'layers' cannot be combined with top-level 'resources'") +} + +func TestParseProjectLayersAllowsEmptyTopLevelInfra(t *testing.T) { + t.Parallel() + + // This is a really small edge case, but just documenting it here to establish that it was considered + // and it's not a big enough deal to worry about at this time - we just ignore it and use the layers they've + // configured. + _, err := Parse(t.Context(), `name: layered-project +# OH NO - AN EMPTY LITERAL! +infra: {} +layers: + - name: application + services: + api: + host: containerapp + image: example/api:latest +`) + + require.NoError(t, err) +} + +func TestParseProjectLayersRejectsInvalidContainers(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yaml string + wantErr string + }{ + { + name: "empty layer", + yaml: "name: test-project\nlayers:\n - name: application\n", + wantErr: "must contain infrastructure or services", + }, + { + name: "duplicate service", + yaml: `name: test-project +layers: + - name: first + services: + api: + host: containerapp + image: example/api:latest + - name: second + services: + api: + host: containerapp + image: example/api:latest +`, + wantErr: "service 'api' is defined in both layers", + }, + { + name: "duplicate infrastructure entry", + yaml: `name: test-project +layers: + - name: first + infra: + - name: shared + provider: terraform + path: infra/first + - name: second + infra: + - name: shared + provider: terraform + path: infra/second +`, + wantErr: "infrastructure entry 'shared' is defined in both layers", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + _, err := Parse(t.Context(), test.yaml) + require.ErrorContains(t, err, test.wantErr) + }) + } +} + +func TestSaveProjectLayersPreservesV2Shape(t *testing.T) { + t.Parallel() + + projectConfig, err := Parse(t.Context(), `name: layered-project +layers: + - name: application + infra: + - name: app-infra + path: ./infra/app + provider: bicep + services: + api: + project: ./src/api + host: containerapp + language: js +`) + require.NoError(t, err) + + path := filepath.Join(t.TempDir(), "azure.yaml") + require.NoError(t, Save(t.Context(), projectConfig, path)) + contents, err := os.ReadFile(path) + require.NoError(t, err) + + yaml := string(contents) + require.Contains(t, yaml, "/schemas/alpha/azure.yaml.json") + require.Contains(t, yaml, "layers:") + require.Contains(t, yaml, "- name: application") + require.Contains(t, yaml, "infra:") + require.Contains(t, yaml, "- provider: bicep") + require.Contains(t, yaml, "services:") + require.Contains(t, yaml, "api:") + require.NotContains(t, yaml, "layer: application") + require.Equal(t, 1, strings.Count(yaml, "layers:")) +} + +func TestSaveProjectLayersPreservesEmptyLayers(t *testing.T) { + t.Parallel() + + projectConfig := &ProjectConfig{ + Name: "layered-project", + Layers: LayerConfigs{}, + } + path := filepath.Join(t.TempDir(), "azure.yaml") + require.NoError(t, Save(t.Context(), projectConfig, path)) + + contents, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(contents), "layers: []") + + reloaded, err := Load(t.Context(), path) + require.NoError(t, err) + require.Equal(t, ProjectFormatLayersV2, reloaded.Format()) + require.Empty(t, reloaded.Layers) +} + +func TestSaveProjectLayersRejectsMixedFormatsBeforeWrite(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*ProjectConfig) + }{ + { + name: "top-level services", + mutate: func(config *ProjectConfig) { + config.Services = map[string]*ServiceConfig{"api": {Name: "api"}} + }, + }, + { + name: "top-level infra", + mutate: func(config *ProjectConfig) { + config.Infra = provisioning.Options{Provider: provisioning.Bicep, Path: "infra"} + }, + }, + { + name: "top-level infra layers", + mutate: func(config *ProjectConfig) { + config.Infra = provisioning.Options{Layers: []provisioning.Options{ + {Name: "shared", Provider: provisioning.Bicep, Path: "infra"}, + }} + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "azure.yaml") + projectConfig := &ProjectConfig{ + Name: "layered-project", + Layers: LayerConfigs{}, + } + require.NoError(t, Save(t.Context(), projectConfig, path)) + + before, err := os.ReadFile(path) + require.NoError(t, err) + test.mutate(projectConfig) + + err = Save(t.Context(), projectConfig, path) + require.ErrorContains(t, err, "'layers' cannot be combined with top-level 'infra' or 'services'") + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, before, after) + }) + } +} + +func TestProjectLayersAlphaSchema(t *testing.T) { + t.Parallel() + + rawSchema, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "schemas", "alpha", "azure.yaml.json")) + require.NoError(t, err) + var schemaDocument map[string]any + require.NoError(t, json.Unmarshal(rawSchema, &schemaDocument)) + + const resourceURI = "mem://azure.yaml.json" + compiler := jsonschema.NewCompiler() + require.NoError(t, compiler.AddResource(resourceURI, schemaDocument)) + repositoryRoot := filepath.Join("..", "..", "..", "..") + jsonGlob := filepath.Join(repositoryRoot, "cli", "azd", "extensions", "*", "schemas", "*.json") + extensionSchemas, err := filepath.Glob(jsonGlob) + require.NoError(t, err) + for _, schemaPath := range extensionSchemas { + rawExtensionSchema, err := os.ReadFile(schemaPath) + require.NoError(t, err) + var extensionSchema map[string]any + require.NoError(t, json.Unmarshal(rawExtensionSchema, &extensionSchema)) + relativePath, err := filepath.Rel(repositoryRoot, schemaPath) + require.NoError(t, err) + require.NoError(t, compiler.AddResource( + "https://raw.githubusercontent.com/Azure/azure-dev/main/"+filepath.ToSlash(relativePath), + extensionSchema, + )) + } + schema, err := compiler.Compile(resourceURI) + require.NoError(t, err) + + layer := map[string]any{ + "name": "application", + "infra": []any{map[string]any{ + "name": "app-infra", "provider": "bicep", "path": "./infra/app", + }}, + "services": map[string]any{ + "api": map[string]any{"host": "containerapp", "project": "./src/api"}, + }, + } + require.NoError(t, schema.Validate(map[string]any{ + "name": "layered-project", + "layers": []any{layer}, + })) + + // bicep and terraform require a 'path' attribute + for _, provider := range []string{"bicep", "terraform"} { + require.Error(t, schema.Validate(map[string]any{ + "name": "layered-project", + "layers": []any{map[string]any{ + "name": "application", + "infra": []any{map[string]any{ + "name": "app-infra", + "provider": provider, + }}, + }}, + }), provider) + } + + // Custom providers don't. If they need a path, they can define one in their provider-specific config. + require.NoError(t, schema.Validate(map[string]any{ + "name": "layered-project", + "layers": []any{map[string]any{ + "name": "application", + "infra": []any{map[string]any{ + "name": "foundry", + "provider": "microsoft.foundry", + }}, + }}, + })) + + for _, test := range []struct { + property string + value any + }{ + {property: "infra", value: []any{}}, + {property: "services", value: map[string]any{}}, + } { + projectDocument := map[string]any{ + "name": "layered-project", + "layers": []any{map[string]any{ + "name": "application", + test.property: test.value, + }}, + } + require.Error(t, schema.Validate(projectDocument), test.property) + } + + for _, incompatibleProperty := range []string{"infra", "resources", "services"} { + projectDocument := map[string]any{ + "name": "layered-project", + "layers": []any{layer}, + incompatibleProperty: map[string]any{}, + } + require.Error(t, schema.Validate(projectDocument), incompatibleProperty) + } +} + +func TestProjectFormatPreservesExplicitEmptyLayerCollections(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config *ProjectConfig + want ProjectFormat + }{ + {name: "flat", config: &ProjectConfig{}, want: ProjectFormatFlat}, + {name: "empty infra layers", config: &ProjectConfig{ + Infra: provisioning.Options{Layers: []provisioning.Options{}}, + }, want: ProjectFormatInfraV1}, + {name: "empty project layers", config: &ProjectConfig{ + Layers: LayerConfigs{}, + }, want: ProjectFormatLayersV2}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, test.want, test.config.Format()) + }) + } +} + +func TestExplicitEmptyInfraLayersUsesV1Format(t *testing.T) { + t.Parallel() + + projectConfig := map[string]any{ + "name": "test-project", + "infra": map[string]any{ + "path": "infra", + "layers": []any{}, + }, + } + projectYaml, err := yaml.Marshal(projectConfig) + require.NoError(t, err) + require.Contains(t, string(projectYaml), "layers: []") + + config, err := Parse(t.Context(), string(projectYaml)) + require.NoError(t, err) + + // The presence of infra.layers identifies the legacy v1 format, even when the list is empty. + require.Equal(t, ProjectFormatInfraV1, config.Format()) + require.Empty(t, config.Infra.Layers) + + // A zero-length legacy layer list falls back to the root infra entry. + entries := config.InfrastructureConfigs() + require.Len(t, entries, 1) + require.Equal(t, "infra", entries[0].Path) +} + +func TestProjectConfigAccessorsPreserveNonV2Formats(t *testing.T) { + t.Parallel() + + service := &ServiceConfig{Name: "api"} + projectConfig := &ProjectConfig{ + Services: map[string]*ServiceConfig{"api": service}, + Infra: provisioning.Options{ + Provider: provisioning.Bicep, + Layers: []provisioning.Options{ + {Name: "network", Provider: provisioning.Terraform}, + {Name: "application", Provider: provisioning.Bicep}, + }, + }, + } + + require.Equal(t, ProjectFormatInfraV1, projectConfig.Format()) + require.Same(t, service, projectConfig.ServiceConfigs()["api"]) + require.Equal(t, projectConfig.Infra.Layers, projectConfig.InfrastructureConfigs()) +} + +func TestSaveProjectInfraV1PreservesFormat(t *testing.T) { + t.Parallel() + + const projectYaml = `name: test-project +infra: + provider: bicep + layers: + - name: network + path: infra/network + module: network + - name: application + provider: terraform + path: infra/application +services: + api: + host: appservice + language: python + project: src/api +` + + projectConfig, err := Parse(t.Context(), projectYaml) + require.NoError(t, err) + require.Equal(t, ProjectFormatInfraV1, projectConfig.Format()) + + projectFile := filepath.Join(t.TempDir(), "azure.yaml") + require.NoError(t, Save(t.Context(), projectConfig, projectFile)) + + rawConfig, err := LoadConfig(t.Context(), projectFile) + require.NoError(t, err) + require.NoError(t, rawConfig.Set("metadata.compatibilityTest", true)) + require.NoError(t, SaveConfig(t.Context(), rawConfig, projectFile)) + + reloaded, err := Load(t.Context(), projectFile) + require.NoError(t, err) + require.Equal(t, ProjectFormatInfraV1, reloaded.Format()) + require.Equal(t, provisioning.Bicep, reloaded.Infra.Provider) + require.Len(t, reloaded.Infra.Layers, 2) + require.Equal(t, "network", reloaded.Infra.Layers[0].Name) + require.Equal(t, provisioning.Terraform, reloaded.Infra.Layers[1].Provider) + require.Contains(t, reloaded.Services, "api") + + contents, err := os.ReadFile(projectFile) + require.NoError(t, err) + require.Contains(t, string(contents), "schemas/v1.0/azure.yaml.json") + require.NotContains(t, string(contents), "\nlayers:") +} + +func TestValidateLayerGraph_AcceptsV2Project(t *testing.T) { + t.Parallel() + + projectConfig := &ProjectConfig{Layers: []*LayerConfig{ + { + Name: "foundry", + Infra: []provisioning.Options{ + {Name: "foundry-account", Provider: provisioning.Bicep}, + {Name: "foundry-project", Provider: "microsoft.foundry", DependsOn: []string{"foundry-account"}}, + }, + Services: map[string]*ServiceConfig{"ai-project": {Name: "ai-project"}}, + }, + { + Name: "agents", + Infra: []provisioning.Options{ + {Name: "agent-resources", Provider: provisioning.Bicep, DependsOn: []string{"foundry-project"}}, + }, + Services: map[string]*ServiceConfig{ + "writer-agent": {Name: "writer-agent", Uses: []string{"ai-project"}}, + }, + }, + }} + + require.NoError(t, ValidateLayerGraph(projectConfig)) +} + +func TestValidateLayerGraph_RejectsLayerCycle(t *testing.T) { + t.Parallel() + + projectConfig := &ProjectConfig{Layers: []*LayerConfig{ + {Name: "a", Infra: []provisioning.Options{{Name: "a-infra", DependsOn: []string{"b-infra"}}}}, + {Name: "b", Infra: []provisioning.Options{{Name: "b-infra", DependsOn: []string{"a-infra"}}}}, + }} + + err := ValidateLayerGraph(projectConfig) + + require.ErrorContains(t, err, "circular dependency") +} + +func TestValidateLayerGraph_AcceptsAcyclicEntriesAcrossLayers(t *testing.T) { + t.Parallel() + + projectConfig := &ProjectConfig{Layers: []*LayerConfig{ + {Name: "a", Infra: []provisioning.Options{ + {Name: "a1"}, + {Name: "a2", DependsOn: []string{"b1"}}, + }}, + {Name: "b", Infra: []provisioning.Options{ + {Name: "b1"}, + {Name: "b2", DependsOn: []string{"a1"}}, + }}, + }} + + require.NoError(t, ValidateLayerGraph(projectConfig)) +} + +func TestValidateLayerGraph_RejectsIntraLayerInfrastructureCycle(t *testing.T) { + t.Parallel() + + projectConfig := &ProjectConfig{Layers: []*LayerConfig{ + { + Name: "application", + Infra: []provisioning.Options{ + {Name: "api", DependsOn: []string{"worker"}}, + {Name: "worker", DependsOn: []string{"api"}}, + }, + }, + }} + + err := ValidateLayerGraph(projectConfig) + + require.ErrorContains(t, err, "circular dependency detected at infrastructure layer") +} + +func TestValidateLayerGraph_RejectsUnknownInfraDependency(t *testing.T) { + t.Parallel() + + projectConfig := &ProjectConfig{Layers: []*LayerConfig{ + {Name: "application", Infra: []provisioning.Options{{Name: "application", DependsOn: []string{"missing"}}}}, + }} + + err := ValidateLayerGraph(projectConfig) + + require.ErrorContains(t, err, "depends on unknown infrastructure layer") +} diff --git a/cli/azd/pkg/project/mapper_registry.go b/cli/azd/pkg/project/mapper_registry.go index 9b33fdd4ac6..ad1be69246b 100644 --- a/cli/azd/pkg/project/mapper_registry.go +++ b/cli/azd/pkg/project/mapper_registry.go @@ -724,8 +724,9 @@ func registerProjectMappings() { return nil, fmt.Errorf("failed resolving ResourceGroupName, %w", err) } - services := make(map[string]*azdext.ServiceConfig, len(src.Services)) - for i, svc := range src.Services { + serviceConfigs := src.ServiceConfigs() + services := make(map[string]*azdext.ServiceConfig, len(serviceConfigs)) + for i, svc := range serviceConfigs { var serviceConfig *azdext.ServiceConfig if err := mapper.WithContext(ctx).Convert(svc, &serviceConfig); err != nil { return nil, fmt.Errorf("converting service %q: %w", i, err) diff --git a/cli/azd/pkg/project/mapper_registry_test.go b/cli/azd/pkg/project/mapper_registry_test.go index 9ee85252e57..30f3fbca53e 100644 --- a/cli/azd/pkg/project/mapper_registry_test.go +++ b/cli/azd/pkg/project/mapper_registry_test.go @@ -1169,6 +1169,28 @@ func TestProjectConfigMapping(t *testing.T) { }, protoConfig.Services["web"].Environment) }) + t.Run("project layers services", func(t *testing.T) { + projectConfig := &ProjectConfig{ + Layers: LayerConfigs{ + { + Name: "application", + Services: map[string]*ServiceConfig{ + "api": { + Name: "api", + Host: ContainerAppTarget, + Language: ServiceLanguagePython, + }, + }, + }, + }, + } + + var protoConfig *azdext.ProjectConfig + err := mapper.WithContext(t.Context()).Convert(projectConfig, &protoConfig) + require.NoError(t, err) + require.Contains(t, protoConfig.Services, "api") + }) + t.Run("without envsubst", func(t *testing.T) { projectConfig := &ProjectConfig{ ResourceGroupName: osutil.NewExpandableString("rg-${ENV}"), diff --git a/cli/azd/pkg/project/project.go b/cli/azd/pkg/project/project.go index 03ca5838cf7..68bae8c0e53 100644 --- a/cli/azd/pkg/project/project.go +++ b/cli/azd/pkg/project/project.go @@ -17,10 +17,10 @@ import ( "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/internal/tracing" "github.com/azure/azure-dev/cli/azd/internal/tracing/fields" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/ext" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" - "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/blang/semver/v4" "github.com/braydonk/yaml" ) @@ -54,7 +54,7 @@ func Parse(ctx context.Context, yamlContent string) (*ProjectConfig, error) { ) } - if err := validateParsedConfig(&projectConfig); err != nil { + if err := projectConfig.Validate(); err != nil { return nil, err } @@ -75,10 +75,6 @@ func Parse(ctx context.Context, yamlContent string) (*ProjectConfig, error) { } } - if err := projectConfig.Infra.Validate(); err != nil { - return nil, err - } - var err error projectConfig.Infra.Provider, err = provisioning.ParseProvider(projectConfig.Infra.Provider) if err != nil { @@ -99,57 +95,34 @@ func Parse(ctx context.Context, yamlContent string) (*ProjectConfig, error) { } } - if strings.Contains(projectConfig.Infra.Path, "\\") && !strings.Contains(projectConfig.Infra.Path, "/") { - projectConfig.Infra.Path = strings.ReplaceAll(projectConfig.Infra.Path, "\\", "/") - } - - projectConfig.Infra.Path = filepath.FromSlash(projectConfig.Infra.Path) - - for key, svc := range projectConfig.Services { - svc.Name = key - svc.Project = &projectConfig - svc.EventDispatcher = ext.NewEventDispatcher[ServiceLifecycleEventArgs]() - - var err error - svc.Language, err = parseServiceLanguage(svc.Language) - if err != nil { - return nil, fmt.Errorf("parsing service %s: %w", svc.Name, err) - } - - svc.Host, err = parseServiceHost(svc.Host) - if err != nil { - return nil, fmt.Errorf("parsing service %s: %w", svc.Name, err) - } - - svc.Infra.Provider, err = provisioning.ParseProvider(svc.Infra.Provider) - if err != nil { - return nil, fmt.Errorf("parsing service %s: %w", svc.Name, err) - } - - if strings.Contains(svc.Infra.Path, "\\") && !strings.Contains(svc.Infra.Path, "/") { - svc.Infra.Path = strings.ReplaceAll(svc.Infra.Path, "\\", "/") + for _, layer := range projectConfig.Layers { + for i := range layer.Infra { + layer.Infra[i].Provider, err = provisioning.ParseProvider(layer.Infra[i].Provider) + if err != nil { + return nil, fmt.Errorf("parsing layer %q infrastructure %q provider: %w", + layer.Name, layer.Infra[i].Name, err) + } + layer.Infra[i].Layer = layer.Name + layer.Infra[i].Path = filepath.FromSlash(strings.ReplaceAll(layer.Infra[i].Path, "\\", "/")) } - svc.Infra.Path = filepath.FromSlash(svc.Infra.Path) - - // TODO: Move parsing/validation requirements for service targets into their respective components. - // When working within container based applications users may be using external/pre-built images instead of source - // In this case it is valid to have not specified a language but would be required to specify a source image - if svc.Host == ContainerAppTarget && svc.Language == ServiceLanguageNone && svc.Image.Empty() { - return nil, fmt.Errorf("parsing service %s: must specify language or image", svc.Name) + for name, service := range layer.Services { + if err := parseServiceConfig(&projectConfig, name, service); err != nil { + return nil, err + } } + } - if strings.ContainsRune(svc.RelativePath, '\\') && !strings.ContainsRune(svc.RelativePath, '/') { - svc.RelativePath = strings.ReplaceAll(svc.RelativePath, "\\", "/") - } + if strings.Contains(projectConfig.Infra.Path, "\\") && !strings.Contains(projectConfig.Infra.Path, "/") { + projectConfig.Infra.Path = strings.ReplaceAll(projectConfig.Infra.Path, "\\", "/") + } - svc.RelativePath = filepath.FromSlash(svc.RelativePath) + projectConfig.Infra.Path = filepath.FromSlash(projectConfig.Infra.Path) - if strings.ContainsRune(svc.OutputPath, '\\') && !strings.ContainsRune(svc.OutputPath, '/') { - svc.OutputPath = strings.ReplaceAll(svc.OutputPath, "\\", "/") + for key, service := range projectConfig.Services { + if err := parseServiceConfig(&projectConfig, key, service); err != nil { + return nil, err } - - svc.OutputPath = filepath.FromSlash(svc.OutputPath) } for key, svc := range projectConfig.Resources { @@ -160,6 +133,36 @@ func Parse(ctx context.Context, yamlContent string) (*ProjectConfig, error) { return &projectConfig, nil } +func parseServiceConfig(projectConfig *ProjectConfig, name string, service *ServiceConfig) error { + service.Name = name + service.Project = projectConfig + service.EventDispatcher = ext.NewEventDispatcher[ServiceLifecycleEventArgs]() + + var err error + service.Language, err = parseServiceLanguage(service.Language) + if err != nil { + return fmt.Errorf("parsing service %s: %w", service.Name, err) + } + service.Host, err = parseServiceHost(service.Host) + if err != nil { + return fmt.Errorf("parsing service %s: %w", service.Name, err) + } + service.Infra.Provider, err = provisioning.ParseProvider(service.Infra.Provider) + if err != nil { + return fmt.Errorf("parsing service %s: %w", service.Name, err) + } + + service.Infra.Path = filepath.FromSlash(strings.ReplaceAll(service.Infra.Path, "\\", "/")) + service.RelativePath = filepath.FromSlash(strings.ReplaceAll(service.RelativePath, "\\", "/")) + service.OutputPath = filepath.FromSlash(strings.ReplaceAll(service.OutputPath, "\\", "/")) + + if service.Host == ContainerAppTarget && service.Language == ServiceLanguageNone && service.Image.Empty() { + return fmt.Errorf("parsing service %s: must specify language or image", service.Name) + } + + return nil +} + // Load hydrates the azure.yaml configuring into an viewable structure // This does not evaluate any tooling func Load(ctx context.Context, projectFilePath string) (*ProjectConfig, error) { @@ -227,11 +230,12 @@ func Load(ctx context.Context, projectFilePath string) (*ProjectConfig, error) { tracing.SetUsageAttributes(fields.StringHashed(fields.ProjectNameKey, projectConfig.Name)) } - if projectConfig.Services != nil { - hosts := make([]string, len(projectConfig.Services)) - languages := make([]string, len(projectConfig.Services)) + services := projectConfig.ServiceConfigs() + if len(services) > 0 { + hosts := make([]string, len(services)) + languages := make([]string, len(services)) i := 0 - for _, svcConfig := range projectConfig.Services { + for _, svcConfig := range services { hosts[i] = string(svcConfig.Host) languages[i] = string(svcConfig.Language) i++ @@ -285,6 +289,10 @@ func SaveConfig(ctx context.Context, config config.Config, projectFilePath strin // Saves the current instance back to the azure.yaml file func Save(ctx context.Context, projectConfig *ProjectConfig, projectFilePath string) error { + if err := projectConfig.Validate(); err != nil { + return fmt.Errorf("file failed validation, before saving: %w", err) + } + // We store paths at runtime with os native separators, but want to normalize paths to use forward slashes // before saving so `azure.yaml` is consistent across platforms. To avoid mutating the original projectConfig, // we make a copy. @@ -302,6 +310,26 @@ func Save(ctx context.Context, projectConfig *ProjectConfig, projectFilePath str copy.Services[name] = &svcCopy } + if projectConfig.Layers != nil { + copy.Layers = make(LayerConfigs, len(projectConfig.Layers)) + for i, layer := range projectConfig.Layers { + layerCopy := *layer + layerCopy.Infra = slices.Clone(layer.Infra) + for j := range layerCopy.Infra { + layerCopy.Infra[j].Path = filepath.ToSlash(layerCopy.Infra[j].Path) + } + layerCopy.Services = make(map[string]*ServiceConfig, len(layer.Services)) + for name, service := range layer.Services { + serviceCopy := *service + serviceCopy.Project = © + serviceCopy.Infra.Path = filepath.ToSlash(service.Infra.Path) + serviceCopy.RelativePath = filepath.ToSlash(service.RelativePath) + serviceCopy.OutputPath = filepath.ToSlash(service.OutputPath) + layerCopy.Services[name] = &serviceCopy + } + copy.Layers[i] = &layerCopy + } + } projectBytes, err := yaml.Marshal(copy) if err != nil { @@ -309,7 +337,9 @@ func Save(ctx context.Context, projectConfig *ProjectConfig, projectFilePath str } version := "v1.0" - if projectConfig.MetaSchemaVersion != "" { + if projectConfig.Format() == ProjectFormatLayersV2 { + version = "alpha" + } else if projectConfig.MetaSchemaVersion != "" { version = projectConfig.MetaSchemaVersion } @@ -322,7 +352,8 @@ func Save(ctx context.Context, projectConfig *ProjectConfig, projectFilePath str return fmt.Errorf("preparing new project file contents: %w", err) } - err = os.WriteFile(projectFilePath, projectFileContents.Bytes(), osutil.PermissionFile) + // Atomic write so readers never observe a partially written azure.yaml. + err = azdext.WriteFileAtomic(projectFilePath, projectFileContents.Bytes(), 0) if err != nil { return fmt.Errorf("saving project file: %w", err) } diff --git a/cli/azd/pkg/project/project_config.go b/cli/azd/pkg/project/project_config.go index cf46bcf419f..06feb6a7416 100644 --- a/cli/azd/pkg/project/project_config.go +++ b/cli/azd/pkg/project/project_config.go @@ -32,6 +32,7 @@ type ProjectConfig struct { Metadata *ProjectMetadata `yaml:"metadata,omitempty"` Services map[string]*ServiceConfig `yaml:"services,omitempty"` Infra provisioning.Options `yaml:"infra,omitempty"` + Layers LayerConfigs `yaml:"layers,omitempty"` Pipeline PipelineOptions `yaml:"pipeline,omitempty"` Hooks HooksConfig `yaml:"hooks,omitempty"` State *state.Config `yaml:"state,omitempty"` @@ -91,11 +92,17 @@ func (pc *ProjectConfig) CopyRuntimeStateTo(target *ProjectConfig) { } if pc.Services == nil || target.Services == nil { + copyLayerRuntimeState(pc.Layers, target.Layers) return } - for serviceName, sourceService := range pc.Services { - targetService, has := target.Services[serviceName] + copyServiceRuntimeState(pc.Services, target.Services) + copyLayerRuntimeState(pc.Layers, target.Layers) +} + +func copyServiceRuntimeState(source, target map[string]*ServiceConfig) { + for serviceName, sourceService := range source { + targetService, has := target[serviceName] if !has { continue } @@ -103,3 +110,15 @@ func (pc *ProjectConfig) CopyRuntimeStateTo(target *ProjectConfig) { sourceService.CopyRuntimeStateTo(targetService) } } + +func copyLayerRuntimeState(source, target []*LayerConfig) { + targetByName := make(map[string]*LayerConfig, len(target)) + for _, layer := range target { + targetByName[layer.Name] = layer + } + for _, sourceLayer := range source { + if targetLayer, has := targetByName[sourceLayer.Name]; has { + copyServiceRuntimeState(sourceLayer.Services, targetLayer.Services) + } + } +} diff --git a/cli/azd/pkg/project/project_test.go b/cli/azd/pkg/project/project_test.go index c1b4e69c70b..4fcd6428f22 100644 --- a/cli/azd/pkg/project/project_test.go +++ b/cli/azd/pkg/project/project_test.go @@ -6,6 +6,7 @@ package project import ( "os" "path/filepath" + "runtime" "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" @@ -1232,6 +1233,19 @@ func Test_Save(t *testing.T) { assert.Equal(t, dir, prjConfig.Path) } +func Test_Save_PreservesPermissions(t *testing.T) { + filePath := filepath.Join(t.TempDir(), "azure.yaml") + require.NoError(t, os.WriteFile(filePath, []byte("name: original"), 0o600)) + + require.NoError(t, Save(t.Context(), &ProjectConfig{Name: "updated"}, filePath)) + + info, err := os.Stat(filePath) + require.NoError(t, err) + if runtime.GOOS != "windows" { + require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + } +} + func Test_Save_OmitsEmptyServiceSourceFields(t *testing.T) { dir := t.TempDir() filePath := filepath.Join(dir, "azure.yaml") diff --git a/cli/azd/pkg/project/validate.go b/cli/azd/pkg/project/validate.go index e674bdbbe2f..a952033e99b 100644 --- a/cli/azd/pkg/project/validate.go +++ b/cli/azd/pkg/project/validate.go @@ -5,8 +5,11 @@ package project import ( "fmt" + "reflect" "slices" "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" ) // ConfigValidationError is returned when the azure.yaml configuration contains @@ -29,6 +32,13 @@ func (e *ConfigValidationError) Error() string { // All problems are collected and returned in a single error so the user can fix them at once. func validateParsedConfig(config *ProjectConfig) error { var problems []string + if config.Layers != nil && + (len(config.Services) > 0 || !reflect.ValueOf(config.Infra).IsZero()) { + problems = append(problems, "'layers' cannot be combined with top-level 'infra' or 'services'") + } + if config.Layers != nil && len(config.Resources) > 0 { + problems = append(problems, "'layers' cannot be combined with top-level 'resources'") + } for key, svc := range config.Services { if svc == nil { @@ -40,9 +50,67 @@ func validateParsedConfig(config *ProjectConfig) error { continue } + if svc.Infra.Layers != nil { + problems = append(problems, fmt.Sprintf("service '%s' infrastructure cannot declare layers", key)) + } problems = append(problems, validateHooks(svc.Hooks, "service '"+key+"'")...) } + layerNames := make(map[string]struct{}, len(config.Layers)) + serviceNames := make(map[string]string) + infraNames := make(map[string]string) + for i, layer := range config.Layers { + if layer == nil { + problems = append(problems, fmt.Sprintf("layer entry %d has an empty definition", i+1)) + continue + } + if layer.Name == "" { + problems = append(problems, fmt.Sprintf("layer entry %d has an empty name", i+1)) + } else if _, has := layerNames[layer.Name]; has { + problems = append(problems, fmt.Sprintf("duplicate layer name '%s'", layer.Name)) + } else { + layerNames[layer.Name] = struct{}{} + } + if len(layer.Infra) == 0 && len(layer.Services) == 0 { + problems = append(problems, fmt.Sprintf("layer '%s' must contain infrastructure or services", layer.Name)) + } + + for _, infra := range layer.Infra { + if owner, has := infraNames[infra.Name]; has { + if owner == layer.Name { + problems = append(problems, + fmt.Sprintf("duplicate infrastructure entry '%s' in layer '%s'", infra.Name, layer.Name)) + } else { + problems = append(problems, fmt.Sprintf( + "infrastructure entry '%s' is defined in both layers '%s' and '%s'", + infra.Name, owner, layer.Name)) + } + } else { + infraNames[infra.Name] = layer.Name + } + } + + for name, service := range layer.Services { + if service == nil { + problems = append(problems, + fmt.Sprintf("layer '%s' service '%s' has an empty definition", layer.Name, name)) + continue + } + if owner, has := serviceNames[name]; has { + problems = append(problems, fmt.Sprintf( + "service '%s' is defined in both layers '%s' and '%s'", name, owner, layer.Name)) + } else { + serviceNames[name] = layer.Name + } + if service.Infra.Layers != nil { + problems = append(problems, fmt.Sprintf( + "layer '%s' service '%s' infrastructure cannot declare layers", layer.Name, name)) + } + problems = append(problems, + validateHooks(service.Hooks, "layer '"+layer.Name+"' service '"+name+"'")...) + } + } + for key, res := range config.Resources { if res == nil { problems = append(problems, @@ -62,6 +130,45 @@ func validateParsedConfig(config *ProjectConfig) error { return nil } +// Validate checks a project configuration before it is persisted. +func (config *ProjectConfig) Validate() error { + if err := validateParsedConfig(config); err != nil { + return err + } + if err := config.Infra.Validate(); err != nil { + return err + } + for _, layer := range config.Layers { + for _, entry := range layer.Infra { + if entry.Layers != nil { + return fmt.Errorf( + "layer %q infrastructure entry %q cannot declare nested layers", + layer.Name, + entry.Name, + ) + } + // NOTE: this is a new constraint - the previous layer provider assumed bicep. + if entry.Provider == provisioning.NotSpecified { + return fmt.Errorf( + "layer %q infrastructure entry %q must specify a provider", + layer.Name, + entry.Name, + ) + } + } + infra := provisioning.Options{Layers: layer.Infra} + if err := infra.ValidateProjectLayers(); err != nil { + return fmt.Errorf("validating layer %q: %w", layer.Name, err) + } + } + if config.Format() == ProjectFormatLayersV2 { + if err := ValidateLayerGraph(config); err != nil { + return fmt.Errorf("validating layer graph: %w", err) + } + } + return nil +} + // validateHooks checks a HooksConfig for nil entries. When scope is non-empty it is // prepended to each problem description to identify the parent (e.g., "service 'web'"). func validateHooks(hooks HooksConfig, scope string) []string { diff --git a/cli/azd/pkg/project/validate_test.go b/cli/azd/pkg/project/validate_test.go index 7d0e95f0722..7f60c966590 100644 --- a/cli/azd/pkg/project/validate_test.go +++ b/cli/azd/pkg/project/validate_test.go @@ -130,6 +130,43 @@ func TestValidateParsedConfigSortedOutput(t *testing.T) { } } +func TestProjectLayerInfrastructureRequiresProvider(t *testing.T) { + _, err := Parse(t.Context(), "name: test-proj\n"+ + "layers:\n"+ + " - name: application\n"+ + " infra:\n"+ + " - name: app\n"+ + " path: infra/app\n") + + require.ErrorContains(t, err, `layer "application" infrastructure entry "app" must specify a provider`) +} + +func TestProjectLayerInfrastructureRejectsNestedLayers(t *testing.T) { + _, err := Parse(t.Context(), "name: test-proj\n"+ + "layers:\n"+ + " - name: application\n"+ + " infra:\n"+ + " - name: app\n"+ + " provider: bicep\n"+ + " layers: []\n") + + require.ErrorContains(t, err, `layer "application" infrastructure entry "app" cannot declare nested layers`) +} + +func TestProjectLayerServiceInfrastructureRejectsNestedLayers(t *testing.T) { + _, err := Parse(t.Context(), "name: test-proj\n"+ + "layers:\n"+ + " - name: application\n"+ + " services:\n"+ + " api:\n"+ + " host: containerapp\n"+ + " image: example/api:latest\n"+ + " infra:\n"+ + " layers: []\n") + + require.ErrorContains(t, err, "layer 'application' service 'api' infrastructure cannot declare layers") +} + // TestValidateHooksNilSlice directly exercises the hookList == nil branch in validateHooks // by constructing a ProjectConfig with a nil hook slice (as opposed to a slice containing nil entries). // This path is reachable when a *.hooks.yaml infra module file defines a hook name with no body. diff --git a/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index 35c8de50023..72a5b108ab4 100644 --- a/schemas/alpha/azure.yaml.json +++ b/schemas/alpha/azure.yaml.json @@ -105,9 +105,9 @@ "microsoft.foundry" ] }, - "deploymentStacks": { - "$ref": "#/definitions/deploymentStacksConfig" - }, + "deploymentStacks": { + "$ref": "#/definitions/deploymentStacksConfig" + }, "config": { "type": "object", "title": "Provider-specific configuration", @@ -182,7 +182,9 @@ }, { "if": { - "required": ["layers"], + "required": [ + "layers" + ], "properties": { "layers": { "type": "array", @@ -200,6 +202,14 @@ } ] }, + "layers": { + "type": "array", + "title": "Layers", + "description": "Layers containing infrastructure and services.", + "items": { + "$ref": "#/definitions/layer" + } + }, "services": { "type": "object", "title": "Definition of services that comprise the application", @@ -378,18 +388,32 @@ "comment": "ContainerApp host - supports image OR project, docker config, and apiVersion", "if": { "properties": { - "host": { "const": "containerapp" } + "host": { + "const": "containerapp" + } } }, "then": { "anyOf": [ { - "required": ["image"], - "not": { "required": ["project"] } + "required": [ + "image" + ], + "not": { + "required": [ + "project" + ] + } }, { - "required": ["project"], - "not": { "required": ["image"] } + "required": [ + "project" + ], + "not": { + "required": [ + "image" + ] + } } ], "properties": { @@ -401,7 +425,9 @@ "comment": "AKS host - project is optional, supports docker and k8s config", "if": { "properties": { - "host": { "const": "aks" } + "host": { + "const": "aks" + } } }, "then": { @@ -416,11 +442,16 @@ "comment": "AI Endpoint host - requires project and config, supports docker", "if": { "properties": { - "host": { "const": "ai.endpoint" } + "host": { + "const": "ai.endpoint" + } } }, "then": { - "required": ["project", "config"], + "required": [ + "project", + "config" + ], "properties": { "config": { "$ref": "#/definitions/aiEndpointConfig", @@ -438,13 +469,19 @@ "comment": "Azure AI Agent host - agent schema composed at the service level; keeps project/runtime/docker/image. config is deprecated: agent settings moved to service level but the old shape stays valid", "if": { "properties": { - "host": { "const": "azure.ai.agent" } + "host": { + "const": "azure.ai.agent" + } } }, "then": { - "required": ["project"], + "required": [ + "project" + ], "allOf": [ - { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json" } + { + "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.agents/schemas/azure.ai.agent.json" + } ], "properties": { "config": { @@ -461,12 +498,16 @@ "comment": "Azure AI Foundry project host - code-less resource service; composes the project schema at the service level and disables source/container properties", "if": { "properties": { - "host": { "const": "azure.ai.project" } + "host": { + "const": "azure.ai.project" + } } }, "then": { "allOf": [ - { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json" } + { + "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.projects/schemas/azure.ai.project.json" + } ], "properties": { "project": false, @@ -481,12 +522,16 @@ "comment": "Azure AI Foundry connection host - code-less resource service; the service key is the connection name", "if": { "properties": { - "host": { "const": "azure.ai.connection" } + "host": { + "const": "azure.ai.connection" + } } }, "then": { "allOf": [ - { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json" } + { + "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.connections/schemas/azure.ai.connection.json" + } ], "properties": { "project": false, @@ -501,12 +546,16 @@ "comment": "Azure AI Foundry toolbox host - code-less resource service; the service key is the toolbox name", "if": { "properties": { - "host": { "const": "azure.ai.toolbox" } + "host": { + "const": "azure.ai.toolbox" + } } }, "then": { "allOf": [ - { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json" } + { + "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.toolboxes/schemas/azure.ai.toolbox.json" + } ], "properties": { "project": false, @@ -521,12 +570,16 @@ "comment": "Azure AI Foundry skill host - code-less resource service; the service key is the skill name", "if": { "properties": { - "host": { "const": "azure.ai.skill" } + "host": { + "const": "azure.ai.skill" + } } }, "then": { "allOf": [ - { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.skills/schemas/azure.ai.skill.json" } + { + "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.skills/schemas/azure.ai.skill.json" + } ], "properties": { "project": false, @@ -541,12 +594,16 @@ "comment": "Azure AI Foundry routine host - code-less resource service; the service key is the routine name and it uses: the agent it invokes", "if": { "properties": { - "host": { "const": "azure.ai.routine" } + "host": { + "const": "azure.ai.routine" + } } }, "then": { "allOf": [ - { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json" } + { + "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.routines/schemas/azure.ai.routine.json" + } ], "properties": { "project": false, @@ -561,12 +618,16 @@ "comment": "Legacy Microsoft Foundry host - compatibility for old non-network files; new provisioning uses azure.ai.project", "if": { "properties": { - "host": { "const": "microsoft.foundry" } + "host": { + "const": "microsoft.foundry" + } } }, "then": { "allOf": [ - { "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.agents/schemas/microsoft.foundry.json" } + { + "$ref": "https://raw.githubusercontent.com/Azure/azure-dev/main/cli/azd/extensions/azure.ai.agents/schemas/microsoft.foundry.json" + } ], "properties": { "project": false, @@ -582,22 +643,38 @@ "comment": "Function host - supports code or container deployment from project or image", "if": { "properties": { - "host": { "const": "function" } + "host": { + "const": "function" + } } }, "then": { "anyOf": [ { - "required": ["image"], - "not": { "required": ["project"] }, + "required": [ + "image" + ], + "not": { + "required": [ + "project" + ] + }, "properties": { - "language": { "const": "docker" }, + "language": { + "const": "docker" + }, "remoteBuild": false } }, { - "required": ["project"], - "not": { "required": ["image"] } + "required": [ + "project" + ], + "not": { + "required": [ + "image" + ] + } } ], "allOf": [ @@ -605,15 +682,25 @@ "if": { "anyOf": [ { - "required": ["language"], + "required": [ + "language" + ], "properties": { - "language": { "const": "docker" } + "language": { + "const": "docker" + } } }, { - "required": ["docker"], + "required": [ + "docker" + ], "properties": { - "docker": { "required": ["path"] } + "docker": { + "required": [ + "path" + ] + } } } ] @@ -636,11 +723,18 @@ "comment": "Traditional non-container hosts - require project and disable container-specific properties", "if": { "properties": { - "host": { "enum": ["springapp", "staticwebapp"] } + "host": { + "enum": [ + "springapp", + "staticwebapp" + ] + } } }, "then": { - "required": ["project"], + "required": [ + "project" + ], "properties": { "image": false, "docker": false, @@ -654,7 +748,9 @@ "comment": "App Service supports docker/image/env for containers but not Kubernetes-specific properties", "if": { "properties": { - "host": { "const": "appservice" } + "host": { + "const": "appservice" + } } }, "then": { @@ -668,7 +764,11 @@ "comment": "remoteBuild is only valid for function host", "if": { "properties": { - "host": { "not": { "const": "function" } } + "host": { + "not": { + "const": "function" + } + } } }, "then": { @@ -680,22 +780,32 @@ { "comment": "imagePassthrough requires a service image and cannot be combined with docker.remoteBuild", "if": { - "required": ["docker"], + "required": [ + "docker" + ], "properties": { "docker": { - "required": ["imagePassthrough"], + "required": [ + "imagePassthrough" + ], "properties": { - "imagePassthrough": { "const": true } + "imagePassthrough": { + "const": true + } } } } }, "then": { - "required": ["image"], + "required": [ + "image" + ], "properties": { "docker": { "properties": { - "remoteBuild": { "const": false } + "remoteBuild": { + "const": false + } } } } @@ -749,20 +859,174 @@ } }, "allOf": [ - { "if": { "properties": { "type": { "const": "host.appservice" } } }, "then": { "$ref": "#/definitions/appServiceResource" } }, - { "if": { "properties": { "type": { "const": "host.containerapp" }}}, "then": { "$ref": "#/definitions/containerAppResource" } }, - { "if": { "properties": { "type": { "const": "ai.openai.model" }}}, "then": { "$ref": "#/definitions/aiModelResource" } }, - { "if": { "properties": { "type": { "const": "ai.project" }}}, "then": { "$ref": "#/definitions/aiProjectResource" } }, - { "if": { "properties": { "type": { "const": "ai.search" }}}, "then": { "$ref": "#/definitions/aiSearchResource" } }, - { "if": { "properties": { "type": { "const": "db.postgres" }}}, "then": { "$ref": "#/definitions/genericDbResource"} }, - { "if": { "properties": { "type": { "const": "db.mysql" }}}, "then": { "$ref": "#/definitions/genericDbResource"} }, - { "if": { "properties": { "type": { "const": "db.redis" }}}, "then": { "$ref": "#/definitions/genericDbResource"} }, - { "if": { "properties": { "type": { "const": "db.mongo" }}}, "then": { "$ref": "#/definitions/genericDbResource"} }, - { "if": { "properties": { "type": { "const": "db.cosmos" }}}, "then": { "$ref": "#/definitions/cosmosDbResource"} }, - { "if": { "properties": { "type": { "const": "messaging.eventhubs" }}}, "then": { "$ref": "#/definitions/eventHubsResource" } }, - { "if": { "properties": { "type": { "const": "messaging.servicebus" }}}, "then": { "$ref": "#/definitions/serviceBusResource" } }, - { "if": { "properties": { "type": { "const": "storage" }}}, "then": { "$ref": "#/definitions/storageAccountResource"} }, - { "if": { "properties": { "type": { "const": "keyvault" }}}, "then": { "$ref": "#/definitions/keyVaultResource"} } + { + "if": { + "properties": { + "type": { + "const": "host.appservice" + } + } + }, + "then": { + "$ref": "#/definitions/appServiceResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "host.containerapp" + } + } + }, + "then": { + "$ref": "#/definitions/containerAppResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "ai.openai.model" + } + } + }, + "then": { + "$ref": "#/definitions/aiModelResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "ai.project" + } + } + }, + "then": { + "$ref": "#/definitions/aiProjectResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "ai.search" + } + } + }, + "then": { + "$ref": "#/definitions/aiSearchResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "db.postgres" + } + } + }, + "then": { + "$ref": "#/definitions/genericDbResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "db.mysql" + } + } + }, + "then": { + "$ref": "#/definitions/genericDbResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "db.redis" + } + } + }, + "then": { + "$ref": "#/definitions/genericDbResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "db.mongo" + } + } + }, + "then": { + "$ref": "#/definitions/genericDbResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "db.cosmos" + } + } + }, + "then": { + "$ref": "#/definitions/cosmosDbResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "messaging.eventhubs" + } + } + }, + "then": { + "$ref": "#/definitions/eventHubsResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "messaging.servicebus" + } + } + }, + "then": { + "$ref": "#/definitions/serviceBusResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "storage" + } + } + }, + "then": { + "$ref": "#/definitions/storageAccountResource" + } + }, + { + "if": { + "properties": { + "type": { + "const": "keyvault" + } + } + }, + "then": { + "$ref": "#/definitions/keyVaultResource" + } + } ] } }, @@ -1048,7 +1312,148 @@ } } }, + "allOf": [ + { + "if": { + "required": [ + "layers" + ] + }, + "then": { + "properties": { + "infra": false, + "resources": false, + "services": false + } + } + } + ], "definitions": { + "layer": { + "type": "object", + "title": "Application layer", + "additionalProperties": false, + "required": [ + "name" + ], + "anyOf": [ + { + "required": [ + "infra" + ] + }, + { + "required": [ + "services" + ] + } + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "title": "Layer name" + }, + "infra": { + "type": "array", + "title": "Infrastructure entries in the layer", + "minItems": 1, + "items": { + "$ref": "#/definitions/layerInfrastructure" + } + }, + "services": { + "type": "object", + "title": "Services in the layer", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/properties/services/additionalProperties" + } + } + } + }, + "layerInfrastructure": { + "type": "object", + "title": "Infrastructure entry", + "additionalProperties": false, + "required": [ + "name", + "provider" + ], + "allOf": [ + { + "if": { + "properties": { + "provider": { + "enum": [ + "bicep", + "terraform" + ] + } + } + }, + "then": { + "required": [ + "path" + ] + } + } + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "title": "Infrastructure entry name" + }, + "provider": { + "type": "string", + "title": "Type of infrastructure provisioning provider", + "pattern": "^[a-z0-9.]+$", + "examples": [ + "bicep", + "terraform", + "microsoft.foundry" + ] + }, + "path": { + "type": "string", + "title": "Path to the provisioning templates" + }, + "module": { + "type": "string", + "title": "Name of the default provisioning module" + }, + "deploymentStacks": { + "$ref": "#/definitions/deploymentStacksConfig" + }, + "config": { + "type": "object", + "title": "Provider-specific configuration", + "additionalProperties": true + }, + "dependsOn": { + "type": "array", + "title": "Infrastructure entries this entry depends on", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "hooks": { + "type": "object", + "title": "Infrastructure entry hooks", + "additionalProperties": false, + "properties": { + "preprovision": { + "$ref": "#/definitions/hooks" + }, + "postprovision": { + "$ref": "#/definitions/hooks" + } + } + } + } + }, "hooks": { "anyOf": [ { @@ -1221,9 +1626,16 @@ { "if": { "properties": { - "kind": { "enum": ["js", "ts"] } + "kind": { + "enum": [ + "js", + "ts" + ] + } }, - "required": ["kind"] + "required": [ + "kind" + ] }, "then": { "properties": { @@ -1236,9 +1648,13 @@ { "if": { "properties": { - "kind": { "const": "python" } + "kind": { + "const": "python" + } }, - "required": ["kind"] + "required": [ + "kind" + ] }, "then": { "properties": { @@ -1251,9 +1667,13 @@ { "if": { "properties": { - "kind": { "const": "dotnet" } + "kind": { + "const": "dotnet" + } }, - "required": ["kind"] + "required": [ + "kind" + ] }, "then": { "properties": { @@ -1266,9 +1686,16 @@ { "if": { "properties": { - "kind": { "enum": ["sh", "pwsh"] } + "kind": { + "enum": [ + "sh", + "pwsh" + ] + } }, - "required": ["kind"] + "required": [ + "kind" + ] }, "then": { "properties": { @@ -2021,7 +2448,12 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["name", "version", "format", "sku"], + "required": [ + "name", + "version", + "format", + "sku" + ], "properties": { "name": { "type": "string", @@ -2043,7 +2475,11 @@ "title": "The SKU configuration for the AI model.", "description": "Required. The SKU details for the AI model.", "additionalProperties": false, - "required": ["name", "usageName", "capacity"], + "required": [ + "name", + "usageName", + "capacity" + ], "properties": { "name": { "type": "string", @@ -2251,7 +2687,11 @@ "type": "string", "title": "Package manager", "description": "The package manager to use for dependency installation. Overrides auto-detection from lock files.", - "enum": ["npm", "pnpm", "yarn"] + "enum": [ + "npm", + "pnpm", + "yarn" + ] } } },