From 6477793c031567a472118d1921a41f2616ecedd7 Mon Sep 17 00:00:00 2001 From: Victor Vazquez Date: Mon, 16 Mar 2026 18:07:04 +0000 Subject: [PATCH] fix: handle ARM expression strings in preflight resource fields Bicep conditional expressions (e.g. `identity: cond ? {...} : {...}`) compile to ARM expression strings like `"[if(equals(...))]"` instead of typed objects. This caused json.Unmarshal to fail when parsing the bicep snapshot during local preflight validation. Introduce a generic `armField[T]` type that stores raw JSON bytes and defers parsing. Callers use `Value()` for typed access (returns ok=false for expressions) or `Raw()` for the underlying JSON. Applied to Tags, SKU, Plan, Identity, Copy, and Zones fields in armTemplateResource. Uses `omitzero` tag and `IsZero()` method to preserve correct marshal behavior. Fixes #7154 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/.vscode/cspell-azd-dictionary.txt | 1 + .../provisioning/bicep/local_preflight.go | 77 +++++++++++-- .../bicep/local_preflight_test.go | 107 ++++++++++++++++++ 3 files changed, 175 insertions(+), 10 deletions(-) diff --git a/cli/azd/.vscode/cspell-azd-dictionary.txt b/cli/azd/.vscode/cspell-azd-dictionary.txt index 307c698fcaa..b7203da5a79 100644 --- a/cli/azd/.vscode/cspell-azd-dictionary.txt +++ b/cli/azd/.vscode/cspell-azd-dictionary.txt @@ -204,6 +204,7 @@ nologo notrail ollama omitempty +omitzero oneauth oneline onmicrosoft diff --git a/cli/azd/pkg/infra/provisioning/bicep/local_preflight.go b/cli/azd/pkg/infra/provisioning/bicep/local_preflight.go index b9208082e53..e2b8ba642ca 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/local_preflight.go +++ b/cli/azd/pkg/infra/provisioning/bicep/local_preflight.go @@ -4,6 +4,7 @@ package bicep import ( + "bytes" "context" "encoding/json" "fmt" @@ -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")) +} + +// 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 +} + // 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 @@ -32,25 +89,25 @@ 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"` // 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"` + // 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. @@ -58,8 +115,8 @@ type armTemplateResource struct { // 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. diff --git a/cli/azd/pkg/infra/provisioning/bicep/local_preflight_test.go b/cli/azd/pkg/infra/provisioning/bicep/local_preflight_test.go index 10de576b213..ddaa2375894 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/local_preflight_test.go +++ b/cli/azd/pkg/infra/provisioning/bicep/local_preflight_test.go @@ -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