diff --git a/cli/azd/pkg/infra/provisioning/bicep/layer_deps.go b/cli/azd/pkg/infra/provisioning/bicep/layer_deps.go index a10782f7b1c..c8ad6e0372a 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/layer_deps.go +++ b/cli/azd/pkg/infra/provisioning/bicep/layer_deps.go @@ -16,6 +16,16 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" ) +// isBicepLayer reports whether a layer uses the Bicep provider (or defaults +// to it). Non-Bicep layers (Terraform, Pulumi, etc.) are opaque to the +// static Bicep analyzer: they produce no discoverable outputs and have no +// .bicep/.bicepparam/.parameters.json files to scan. Those layers still +// participate in the dependency graph via explicit dependsOn edges. +func isBicepLayer(layer provisioning.Options) bool { + return layer.Provider == provisioning.NotSpecified || + layer.Provider == provisioning.Bicep +} + // Package-level compiled regexes for output and env-var reference extraction. var ( bicepOutputRe = regexp.MustCompile(`(?m)^\s*output\s+(\w+)\s+`) @@ -130,10 +140,15 @@ func AnalyzeLayerDependencies( } // Phase 1 — Discover outputs from each layer's Bicep file. + // Non-Bicep layers (Terraform, Pulumi, etc.) are skipped: they have + // no .bicep file and their outputs cannot be statically discovered. // Iterate layers (not resolved) so the loop index i is clearly bounded by // len(layers) for static analyzers; resolved has the same length by // construction above. for i, layer := range layers { + if !isBicepLayer(layer) { + continue + } opts := resolved[i] bicepPath := resolveBicepPath(opts, projectPath) outputs, err := extractBicepOutputs(ctx, bicepPath) @@ -164,9 +179,15 @@ func AnalyzeLayerDependencies( } // Phase 2 — Discover input env-var references and build edges. + // Non-Bicep layers are skipped: they have no .bicep/.bicepparam/ + // .parameters.json files to scan for env-var references. Their + // dependencies must be declared via explicit dependsOn in azure.yaml. var safeFallback []int - for i, opts := range resolved { - refs, hasUnknown := discoverParamEnvRefs(ctx, opts, projectPath) + for i, layer := range layers { + if !isBicepLayer(layer) { + continue + } + refs, hasUnknown := discoverParamEnvRefs(ctx, resolved[i], projectPath) for _, ref := range refs { if provider, ok := g.outputProviders[ref]; ok && provider != i { // Always keep intra-graph edges, even when the ref is diff --git a/cli/azd/pkg/infra/provisioning/bicep/layer_deps_test.go b/cli/azd/pkg/infra/provisioning/bicep/layer_deps_test.go index 2be789f0e6b..e8731de9d0b 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/layer_deps_test.go +++ b/cli/azd/pkg/infra/provisioning/bicep/layer_deps_test.go @@ -768,3 +768,121 @@ func TestExtractBicepParamReadEnvRefs_IgnoresComments(t *testing.T) { refs, _ := extractBicepParamReadEnvRefs(content) require.Equal(t, []string{"ACTIVE"}, refs) } + +// --- Non-Bicep (Terraform/Pulumi) layer support --- + +// TestAnalyzeLayerDependencies_TerraformLayers verifies that Terraform layers +// are skipped by the Bicep-specific output/input analysis (no .bicep files +// required) and that explicit dependsOn edges are still honored. +func TestAnalyzeLayerDependencies_TerraformLayers(t *testing.T) { + dir := t.TempDir() + + // Create layer directories with NO .bicep files — only Terraform files. + backendDir := filepath.Join(dir, "backend") + mkTestDir(t, backendDir) + writeTestFile(t, filepath.Join(backendDir, "main.tf"), "# terraform backend\n") + + resourcesDir := filepath.Join(dir, "resources") + mkTestDir(t, resourcesDir) + writeTestFile(t, filepath.Join(resourcesDir, "main.tf"), "# terraform resources\n") + + layers := []provisioning.Options{ + { + Name: "backend", + Path: "backend", + Module: "main", + Provider: provisioning.Terraform, + }, + { + Name: "resources", + Path: "resources", + Module: "main", + Provider: provisioning.Terraform, + DependsOn: []string{"backend"}, + }, + } + + result, err := AnalyzeLayerDependencies(t.Context(), layers, dir) + require.NoError(t, err) + // backend runs first, resources depends on it via dependsOn. + require.Equal(t, [][]int{{0}, {1}}, result.Levels) + require.Contains(t, result.Edges[1], 0) +} + +// TestAnalyzeLayerDependencies_TerraformLayersParallel verifies that two +// independent Terraform layers with no dependsOn run in parallel. +func TestAnalyzeLayerDependencies_TerraformLayersParallel(t *testing.T) { + dir := t.TempDir() + + mkTestDir(t, filepath.Join(dir, "a")) + mkTestDir(t, filepath.Join(dir, "b")) + + layers := []provisioning.Options{ + {Name: "a", Path: "a", Module: "main", Provider: provisioning.Terraform}, + {Name: "b", Path: "b", Module: "main", Provider: provisioning.Terraform}, + } + + result, err := AnalyzeLayerDependencies(t.Context(), layers, dir) + require.NoError(t, err) + require.Equal(t, [][]int{{0, 1}}, result.Levels) + require.Empty(t, result.Edges) +} + +// TestAnalyzeLayerDependencies_MixedProviders verifies that a project with +// both Bicep and Terraform layers works: Bicep layers get static analysis, +// Terraform layers are skipped for output/input scanning, and dependsOn +// edges connect them correctly. +func TestAnalyzeLayerDependencies_MixedProviders(t *testing.T) { + dir := t.TempDir() + + // Bicep layer 0 — has a .bicep file with outputs. + bicepDir := filepath.Join(dir, "networking") + mkTestDir(t, bicepDir) + writeTestFile(t, filepath.Join(bicepDir, "main.bicep"), + "param location string\noutput VNET_ID string = 'id'\n") + + // Terraform layer 1 — no .bicep files, depends on bicep layer. + tfDir := filepath.Join(dir, "compute") + mkTestDir(t, tfDir) + writeTestFile(t, filepath.Join(tfDir, "main.tf"), "# terraform\n") + + layers := []provisioning.Options{ + {Name: "networking", Path: "networking", Module: "main"}, + { + Name: "compute", + Path: "compute", + Module: "main", + Provider: provisioning.Terraform, + DependsOn: []string{"networking"}, + }, + } + + result, err := AnalyzeLayerDependencies(t.Context(), layers, dir) + require.NoError(t, err) + require.Equal(t, [][]int{{0}, {1}}, result.Levels) + require.Contains(t, result.Edges[1], 0) +} + +// TestIsBicepLayer verifies the helper classifies providers correctly. +func TestIsBicepLayer(t *testing.T) { + t.Parallel() + tests := []struct { + name string + provider provisioning.ProviderKind + expected bool + }{ + {"NotSpecified", provisioning.NotSpecified, true}, + {"Bicep", provisioning.Bicep, true}, + {"Terraform", provisioning.Terraform, false}, + {"Pulumi", provisioning.Pulumi, false}, + {"Arm", provisioning.Arm, false}, + {"custom-ext", provisioning.ProviderKind("custom-ext"), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := isBicepLayer(provisioning.Options{Provider: tt.provider}) + require.Equal(t, tt.expected, got) + }) + } +} diff --git a/cli/azd/pkg/project/project.go b/cli/azd/pkg/project/project.go index 40b89953f8d..03ca5838cf7 100644 --- a/cli/azd/pkg/project/project.go +++ b/cli/azd/pkg/project/project.go @@ -85,8 +85,18 @@ func Parse(ctx context.Context, yamlContent string) (*ProjectConfig, error) { return nil, fmt.Errorf("parsing project %s: %w", projectConfig.Name, err) } - for _, layer := range projectConfig.Infra.Layers { - layer.Provider = projectConfig.Infra.Provider + for i := range projectConfig.Infra.Layers { + projectConfig.Infra.Layers[i].Provider, err = provisioning.ParseProvider( + projectConfig.Infra.Layers[i].Provider, + ) + if err != nil { + return nil, fmt.Errorf("parsing layer %q provider: %w", + projectConfig.Infra.Layers[i].Name, err) + } + + if projectConfig.Infra.Layers[i].Provider == provisioning.NotSpecified { + projectConfig.Infra.Layers[i].Provider = projectConfig.Infra.Provider + } } if strings.Contains(projectConfig.Infra.Path, "\\") && !strings.Contains(projectConfig.Infra.Path, "/") { diff --git a/cli/azd/pkg/project/project_config_test.go b/cli/azd/pkg/project/project_config_test.go index 13f7cb2fde1..8cbd5f80ea6 100644 --- a/cli/azd/pkg/project/project_config_test.go +++ b/cli/azd/pkg/project/project_config_test.go @@ -586,3 +586,46 @@ resources: require.Equal(t, "FOO", cap.Env[0].Name) require.Equal(t, "BAR", cap.Env[0].Value) } + +func TestProjectConfigLayerProviderInheritance(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + + t.Run("root provider propagates to layers without explicit provider", func(t *testing.T) { + const proj = ` +name: test-proj +infra: + provider: terraform + layers: + - name: backend + path: ./infra/backend + - name: resources + path: ./infra/resources +` + cfg, err := Parse(*mockContext.Context, proj) + require.NoError(t, err) + for _, layer := range cfg.Infra.Layers { + require.Equal(t, "terraform", string(layer.Provider), + "layer %q should inherit root provider", layer.Name) + } + }) + + t.Run("per-layer provider overrides root", func(t *testing.T) { + const proj = ` +name: test-proj +infra: + provider: terraform + layers: + - name: backend + path: ./infra/backend + provider: bicep + - name: resources + path: ./infra/resources +` + cfg, err := Parse(*mockContext.Context, proj) + require.NoError(t, err) + require.Equal(t, "bicep", string(cfg.Infra.Layers[0].Provider), + "layer with explicit provider should keep it") + require.Equal(t, "terraform", string(cfg.Infra.Layers[1].Provider), + "layer without explicit provider should inherit root") + }) +}