Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions cli/azd/pkg/infra/provisioning/bicep/layer_deps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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+`)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
118 changes: 118 additions & 0 deletions cli/azd/pkg/infra/provisioning/bicep/layer_deps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
Comment thread
jongio marked this conversation as resolved.
}
14 changes: 12 additions & 2 deletions cli/azd/pkg/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Comment thread
jongio marked this conversation as resolved.

if strings.Contains(projectConfig.Infra.Path, "\\") && !strings.Contains(projectConfig.Infra.Path, "/") {
Expand Down
43 changes: 43 additions & 0 deletions cli/azd/pkg/project/project_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
}
Loading