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
1 change: 1 addition & 0 deletions cli/azd/.vscode/cspell-azd-dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ nologo
notrail
ollama
omitempty
omitzero
oneauth
oneline
onmicrosoft
Expand Down
77 changes: 67 additions & 10 deletions cli/azd/pkg/infra/provisioning/bicep/local_preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package bicep

import (
"bytes"
"context"
"encoding/json"
"fmt"
Expand All @@ -20,6 +21,62 @@ import (
"github.com/azure/azure-dev/cli/azd/pkg/tools/bicep"
)

// armField is a generic JSON field that gracefully handles both structured values and
// ARM template expression strings. ARM templates compiled from Bicep may emit fields as
// expression strings (e.g. "[if(equals(...))]") when conditional logic is used, instead
// of the expected typed object.
//
// Use [armField.Value] for typed access (returns ok=false if the raw JSON cannot be parsed
// as T), and [armField.Raw] for the underlying JSON regardless of shape.
type armField[T any] struct {
raw json.RawMessage
}

// HasValue reports whether the field was present and non-null in the JSON input.
func (f armField[T]) HasValue() bool {
return len(f.raw) > 0 && !bytes.Equal(f.raw, []byte("null"))
}
Comment thread
vhvb1989 marked this conversation as resolved.

// Value attempts to unmarshal the raw JSON into the typed representation T.
// It returns the parsed value and true on success, or the zero value and false
// if the field is absent, null, or not representable as T (e.g. an ARM expression string).
func (f armField[T]) Value() (T, bool) {
var v T
if !f.HasValue() {
return v, false
}
if err := json.Unmarshal(f.raw, &v); err != nil {
return v, false
}
return v, true
}

// Raw returns the underlying JSON bytes exactly as they appeared in the input.
// Returns nil if the field was absent from the JSON.
func (f armField[T]) Raw() json.RawMessage {
return f.raw
}

// UnmarshalJSON stores the raw JSON bytes for deferred parsing.
func (f *armField[T]) UnmarshalJSON(data []byte) error {
f.raw = append(json.RawMessage(nil), data...)
return nil
}

// IsZero reports whether the field is absent (no raw JSON stored).
// This is used by encoding/json's omitzero tag to omit the field during marshaling.
func (f armField[T]) IsZero() bool {
return f.raw == nil
}

// MarshalJSON writes the stored raw JSON bytes, or null if no value was stored.
func (f armField[T]) MarshalJSON() ([]byte, error) {
if f.raw == nil {
return []byte("null"), nil
}
return f.raw, nil
}
Comment thread
vhvb1989 marked this conversation as resolved.

// armTemplateResource represents a single resource declaration within an ARM template.
// It follows the schema defined at:
// https://learn.microsoft.com/azure/azure-resource-manager/templates/resource-declaration
Expand All @@ -32,34 +89,34 @@ type armTemplateResource struct {
Name string `json:"name"`
// Location is the deployment location for the resource.
Location string `json:"location,omitempty"`
// Tags are resource tags. Stored as json.RawMessage because the value can be either
// a map[string]string literal or an ARM expression string (e.g. "[variables('tags')]").
Tags json.RawMessage `json:"tags,omitempty"`
// Tags holds resource tags, which may be a map[string]string literal or an ARM expression string.
Tags armField[map[string]string] `json:"tags,omitzero"`
// DependsOn lists symbolic names or resource IDs of resources that must be deployed first.
DependsOn []string `json:"dependsOn,omitempty"`
// Kind is the resource kind (e.g. "StorageV2" for storage or "app,linux" for web apps).
Kind string `json:"kind,omitempty"`
// SKU is the pricing tier / SKU for the resource.
SKU *armTemplateSKU `json:"sku,omitempty"`
SKU armField[armTemplateSKU] `json:"sku,omitzero"`
Comment thread
vhvb1989 marked this conversation as resolved.
// Plan is the marketplace plan for the resource.
Plan *armTemplatePlan `json:"plan,omitempty"`
Plan armField[armTemplatePlan] `json:"plan,omitzero"`
// Identity is the managed identity configuration for the resource.
Identity *armTemplateIdentity `json:"identity,omitempty"`
// Properties is the resource-specific configuration.
Identity armField[armTemplateIdentity] `json:"identity,omitzero"`
Comment thread
vhvb1989 marked this conversation as resolved.
// Properties is the resource-specific configuration. Kept as json.RawMessage because each
// resource type has a different properties schema with no single typed representation.
Properties json.RawMessage `json:"properties,omitempty"`
// Condition is an expression that evaluates to true/false controlling whether the resource is deployed.
Condition any `json:"condition,omitempty"`
// Copy defines iteration for deploying multiple instances.
Copy *armTemplateCopy `json:"copy,omitempty"`
Copy armField[armTemplateCopy] `json:"copy,omitzero"`
// Comments are optional authoring comments.
Comments string `json:"comments,omitempty"`
// Scope is used when deploying extension resources or cross-scope resources.
Scope string `json:"scope,omitempty"`
// Resources are child resources nested inside this resource declaration.
// Uses armTemplateResources to handle both array and symbolic-name map formats.
Resources armTemplateResources `json:"resources,omitempty"`
// Zones lists Availability Zones for the resource (e.g. ["1","2","3"]).
Zones []string `json:"zones,omitempty"`
// Zones lists Availability Zones for the resource.
Zones armField[[]string] `json:"zones,omitzero"`
}

// armTemplateSKU represents the SKU block of an ARM resource.
Expand Down
107 changes: 107 additions & 0 deletions cli/azd/pkg/infra/provisioning/bicep/local_preflight_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,113 @@ func TestRegisteredChecks_RunInOrder(t *testing.T) {
require.Equal(t, "this is an error", results[1].Message)
}

func TestArmField_TypedValue(t *testing.T) {
input := `{"sku": {"name": "Standard_LRS", "tier": "Standard"}}`
var res armTemplateResource
require.NoError(t, json.Unmarshal([]byte(input), &res))

sku, ok := res.SKU.Value()
require.True(t, ok)
require.Equal(t, "Standard_LRS", sku.Name)
require.Equal(t, "Standard", sku.Tier)
require.True(t, res.SKU.HasValue())
}

func TestArmField_ExpressionString(t *testing.T) {
// Bicep conditional expressions compile to ARM expression strings.
input := `{"identity":` +
`"[if(equals(parameters('id'), ''),` +
` createObject('type', 'SystemAssigned'),` +
` createObject('type', 'UserAssigned'))]"}`
var res armTemplateResource
require.NoError(t, json.Unmarshal([]byte(input), &res))

// Typed parse should fail gracefully — it's an expression string, not an object.
_, ok := res.Identity.Value()
require.False(t, ok)

// Raw access should return the expression string.
require.True(t, res.Identity.HasValue())
require.Contains(t, string(res.Identity.Raw()), "[if(equals(")
}

func TestArmField_Null(t *testing.T) {
input := `{"sku": null}`
var res armTemplateResource
require.NoError(t, json.Unmarshal([]byte(input), &res))

require.False(t, res.SKU.HasValue())
_, ok := res.SKU.Value()
require.False(t, ok)
}

func TestArmField_Absent(t *testing.T) {
input := `{"type": "Microsoft.Web/sites"}`
var res armTemplateResource
require.NoError(t, json.Unmarshal([]byte(input), &res))

require.False(t, res.SKU.HasValue())
require.False(t, res.Identity.HasValue())
require.False(t, res.Tags.HasValue())
require.Nil(t, res.SKU.Raw())
}

func TestArmField_Tags(t *testing.T) {
input := `{"tags": {"env": "dev", "team": "platform"}}`
var res armTemplateResource
require.NoError(t, json.Unmarshal([]byte(input), &res))

tags, ok := res.Tags.Value()
require.True(t, ok)
require.Equal(t, "dev", tags["env"])
require.Equal(t, "platform", tags["team"])
}

func TestArmField_TagsExpression(t *testing.T) {
input := `{"tags": "[variables('tags')]"}`
var res armTemplateResource
require.NoError(t, json.Unmarshal([]byte(input), &res))

_, ok := res.Tags.Value()
require.False(t, ok)
require.True(t, res.Tags.HasValue())
require.Contains(t, string(res.Tags.Raw()), "variables('tags')")
}

func TestArmField_RoundTrip(t *testing.T) {
input := `{"type":"Microsoft.Web/sites","sku":{"name":"S1"},"identity":{"type":"SystemAssigned"}}`
var res armTemplateResource
require.NoError(t, json.Unmarshal([]byte(input), &res))

data, err := json.Marshal(res)
require.NoError(t, err)

var res2 armTemplateResource
require.NoError(t, json.Unmarshal(data, &res2))

sku, ok := res2.SKU.Value()
require.True(t, ok)
require.Equal(t, "S1", sku.Name)

id, ok := res2.Identity.Value()
require.True(t, ok)
require.Equal(t, "SystemAssigned", id.Type)
}

func TestArmField_OmitZero(t *testing.T) {
// Absent armField fields should be omitted from marshaled JSON via omitzero.
res := armTemplateResource{Type: "Microsoft.Web/sites", APIVersion: "2020-06-01", Name: "test"}
data, err := json.Marshal(res)
require.NoError(t, err)

raw := string(data)
require.NotContains(t, raw, "sku")
require.NotContains(t, raw, "tags")
require.NotContains(t, raw, "identity")
require.NotContains(t, raw, "zones")
require.Contains(t, raw, `"type":"Microsoft.Web/sites"`)
}

func TestAnalyzeResources(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading