Skip to content

[BUG] Cross-scope existing resource reference via symbolic name (languageVersion 2.0) resolves to a malformed Mock, breaking concat()/map() during pre-flight expansion #3920

Description

@oWretch

Describe the bug

When a Bicep-compiled ARM template uses languageVersion: "2.0" (symbolic name codegen — triggered e.g. by any compile-time import statement) and contains a cross-scope existing resource, reference() calls against that resource use the bare symbolic name as the first argument (e.g. reference('vnet').addressSpace.addressPrefixes) instead of reference(extensionResourceId(...), apiVersion, 'full').

This is valid, standard ARM syntax for languageVersion 2.0 templates — real Azure deployments resolve it fine because ARM evaluates symbolic references against the resource's declared scope.

However, PSRule's offline expansion (used by GetBicepParamResources / pre-flight bicep(param) expansion, e.g. via ps-rule-assert@2) fails to resolve this form. Downstream use of the result in a strict-typed function such as concat()/map() then throws:

An error occurred evaluating expression '[map(concat(reference('resourceA', ...), reference('resourceB', ...)), ...)]' ... The function 'map' failed. The function 'concat' failed. The arguments for 'Concat' are not in the expected format or type.

Scalar property assignments elsewhere in the same template hit the identical broken resolution but don't surface as hard errors, since plain string/object fields tolerate an unresolved/mock value — making this easy to miss until it feeds an array function.

Root cause (traced in source)

  1. DeploymentVisitor.Emit() (src/PSRule.Rules.Azure/Arm/Deployments/DeploymentVisitor.cs) explicitly skips registering existing resources:
    protected virtual void Emit(TemplateContext context, IResourceValue resource)
    {
        if (resource == null || resource.IsExisting())
            return;
        ...
        context.AddResource(resource);
    }
  2. For the symbolic-name (languageVersion 2.0) resources-as-object form, DeploymentVisitor.Reference(context, symbolicName, resource) creates an ExistingResourceValue and registers it only as a symbol (context.AddSymbol(symbol)), never via context.AddResource(...). So the resource's id is never added to TemplateContext._ResourceIds.
  3. TemplateContext.TryGetResource(nameOrResourceId, ...) resolves a symbolic name to an id via symbol.GetId(0), then looks that id up in _ResourceIds — which never contains it for existing resources, so the lookup fails.
  4. Functions.Reference(context, args) then falls back to a placeholder:
    return context.TryGetResource(resourceId, out var resourceValue)
        ? GetReferenceResult(resourceValue, full)
        : full ? new Mock.MockResource(resourceId) : new Mock.MockResource(resourceId)["properties"];
  5. Mock.MockResource(resourceId) (src/PSRule.Rules.Azure/Arm/Mocks/Mock.cs) expects resourceId to be a full ARM resource ID and parses it via ResourceHelper.TryResourceIdComponents(...) to build id/type/name/properties. When resourceId is actually just the bare symbolic name (e.g. "vnet"), that parsing produces a malformed/incomplete mock object whose nested property values are not usable as arrays/strings by strict functions like concat()/map().

Note that ObjectDeploymentSymbol/ArrayDeploymentSymbol already retain a reference to the real IResourceValue via Configure(resource) — it's discarded after computing the id string, which is what forces the failed _ResourceIds round-trip in step 3.

To Reproduce

other.bicep

@export()
var unused = 'unused'

main.bicep

targetScope = 'resourceGroup'

import { unused } from 'other.bicep'

resource vnet 'Microsoft.Network/virtualNetworks@2023-11-01' existing = {
  scope: resourceGroup('other-rg')
  name: 'vnet1'
}

output addressPrefixes array = vnet.properties.addressSpace.addressPrefixes

bicep build main.bicep --stdout (with the import present) produces languageVersion 2.0 output using the bare symbolic form:

"addressPrefixes": {
  "type": "array",
  "value": "[reference('vnet').addressSpace.addressPrefixes]"
}

Running this through PSRule's pre-flight expansion (Assert-PSRule/ps-rule-assert@2 against the compiled template, or directly via GetBicepParamResources) fails to resolve addressPrefixes correctly and can throw when the value feeds concat()/map() elsewhere in the template.

Removing the import line removes the trigger for languageVersion 2.0 and restores the extensionResourceId(...)-based codegen, which — while still ultimately routed through the same Mock.MockResource fallback for existing resources — receives a well-formed resource ID and therefore avoids the malformed mock.

Expected behavior

PSRule's expansion of existing cross-scope resources should resolve consistently regardless of whether the compiled template uses the resourceId(...)/extensionResourceId(...) form or the bare symbolic-name form used by languageVersion 2.0 templates, so that property access (including through concat()/map()) does not fail or silently produce malformed values.

Suggested fix direction

Rather than round-tripping symbolic names through TemplateContext._ResourceIds (which by design never contains existing resources), TryGetResource could resolve the actual IResourceValue directly from the matched symbol — e.g. by extending IDeploymentSymbol with a GetResource(int index) accessor alongside the existing GetId(int index), and having ObjectDeploymentSymbol/ArrayDeploymentSymbol return the IResourceValue they already hold via Configure(...). Functions.Reference/Functions.References would then get the real ExistingResourceValue (which already computes scope-aware Id/Name) instead of falling back to Mock.MockResource with an unparsable identifier.

This looks like a contained change scoped to Arm/Symbols/*, Arm/Deployments/TemplateContext.cs, and Arm/Expressions/Functions.cs, testable with the existing Arm/Deployments/Arm/Expressions unit test suites — not a large architectural change.

Environment

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions