feat(azure-policy): add alias normalization and denormalization (#635)

* feat: add Azure Policy alias normalization/denormalization

Add normalizer and denormalizer for ARM JSON resources, enabling Azure
Policy alias short names to become direct paths into a flat structure.

- Normalizer: flattens properties wrappers, lowercases keys, resolves
  per-alias versioned ARM paths, handles sub-resource array flattening,
  element-level field remaps, and array base renames
- Denormalizer: reverses all transformations with casing restoration
- AliasRegistry: loads production alias catalogs and data policy manifests
- Types: serde deserialization for ARM provider alias formats
- YAML test suite: 13 test files covering normalize, denormalize, round-trip,
  data-plane, edge cases, malformed input, sub-resources, and registry API
- Benchmark suite for normalization performance

* feat: add FFI and C# bindings for alias normalization

- FFI: alias_registry.rs with C-compatible API for loading catalogs,
  normalizing resources, and denormalizing back to ARM JSON
- C#: AliasRegistry wrapper class with NativeMethods P/Invoke bindings
  and integration tests
- Updated Cargo.lock files for new serde_json dependency
This commit is contained in:
Anand Krishnamoorthi
2026-03-30 18:44:36 -05:00
committed by GitHub
parent 35fb5d5953
commit d36f952133
46 changed files with 11265 additions and 316 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,230 @@
[
{
"namespace": "Microsoft.Test",
"resourceTypes": [
{
"resourceType": "versionedResources",
"aliases": [
{
"name": "Microsoft.Test/versionedResources/simpleProp",
"defaultPath": "properties.simpleProp",
"paths": []
},
{
"name": "Microsoft.Test/versionedResources/accountType",
"defaultPath": "properties.accountType",
"paths": [
{
"path": "properties.accountType",
"apiVersions": ["2015-06-15", "2015-01-01"]
},
{
"path": "sku.name",
"apiVersions": ["2020-01-01", "2019-06-01"]
}
]
},
{
"name": "Microsoft.Test/versionedResources/config.threshold",
"defaultPath": "properties.config.threshold",
"paths": [
{
"path": "properties.config.properties.threshold",
"apiVersions": ["2015-06-15", "2015-01-01"]
},
{
"path": "properties.config.threshold",
"apiVersions": ["2020-01-01", "2019-06-01"]
}
]
},
{
"name": "Microsoft.Test/versionedResources/tier",
"defaultPath": "sku.tier",
"paths": [
{
"path": "properties.pricingTier",
"apiVersions": ["2015-06-15", "2015-01-01"]
},
{
"path": "sku.tier",
"apiVersions": ["2020-01-01", "2019-06-01"]
}
]
},
{
"name": "Microsoft.Test/versionedResources/operationMode",
"defaultPath": "properties.mode",
"paths": [
{
"path": "properties.legacyMode",
"apiVersions": ["2015-01-01"]
},
{
"path": "properties.settings.mode",
"apiVersions": ["2017-06-01"]
},
{
"path": "properties.mode",
"apiVersions": ["2020-01-01"]
}
]
}
]
},
{
"resourceType": "complexResources",
"aliases": [
{
"name": "Microsoft.Test/complexResources/enabled",
"defaultPath": "properties.enabled",
"paths": []
},
{
"name": "Microsoft.Test/complexResources/tags",
"defaultPath": "tags",
"paths": []
},
{
"name": "Microsoft.Test/complexResources/rules",
"defaultPath": "properties.rules",
"paths": []
},
{
"name": "Microsoft.Test/complexResources/rules[*]",
"defaultPath": "properties.rules[*]",
"paths": []
},
{
"name": "Microsoft.Test/complexResources/rules[*].name",
"defaultPath": "properties.rules[*].name",
"paths": []
},
{
"name": "Microsoft.Test/complexResources/rules[*].priority",
"defaultPath": "properties.rules[*].properties.priority",
"paths": [
{
"path": "properties.rules[*].properties.priority",
"apiVersions": ["2015-01-01"]
},
{
"path": "properties.rules[*].properties.prio",
"apiVersions": ["2020-01-01"]
}
]
},
{
"name": "Microsoft.Test/complexResources/rules[*].action",
"defaultPath": "properties.rules[*].properties.action",
"paths": []
},
{
"name": "Microsoft.Test/complexResources/rules[*].protocol",
"defaultPath": "properties.rules[*].properties.protocol",
"paths": []
},
{
"name": "Microsoft.Test/complexResources/rules[*].sourcePort",
"defaultPath": "properties.rules[*].properties.sourcePort",
"paths": []
},
{
"name": "Microsoft.Test/complexResources/rules[*].destPort",
"defaultPath": "properties.rules[*].properties.destPort",
"paths": []
},
{
"name": "Microsoft.Test/complexResources/rules[*].direction",
"defaultPath": "properties.rules[*].properties.direction",
"paths": []
},
{
"name": "Microsoft.Test/complexResources/rules[*].targets",
"defaultPath": "properties.rules[*].properties.targets",
"paths": []
},
{
"name": "Microsoft.Test/complexResources/rules[*].targets[*]",
"defaultPath": "properties.rules[*].properties.targets[*]",
"paths": []
},
{
"name": "Microsoft.Test/complexResources/rules[*].filters",
"defaultPath": "properties.rules[*].properties.filters",
"paths": []
},
{
"name": "Microsoft.Test/complexResources/rules[*].filters[*]",
"defaultPath": "properties.rules[*].properties.filters[*]",
"paths": []
},
{
"name": "Microsoft.Test/complexResources/rules[*].filters[*].name",
"defaultPath": "properties.rules[*].properties.filters[*].properties.name",
"paths": []
},
{
"name": "Microsoft.Test/complexResources/rules[*].filters[*].value",
"defaultPath": "properties.rules[*].properties.filters[*].properties.value",
"paths": []
},
{
"name": "Microsoft.Test/complexResources/allowedIPs",
"defaultPath": "properties.allowedIPs",
"paths": []
},
{
"name": "Microsoft.Test/complexResources/allowedIPs[*]",
"defaultPath": "properties.allowedIPs[*]",
"paths": []
},
{
"name": "Microsoft.Test/complexResources/config.retryCount",
"defaultPath": "properties.config.retryCount",
"paths": [
{
"path": "properties.config.properties.retryCount",
"apiVersions": ["2015-01-01"]
},
{
"path": "properties.config.retryCount",
"apiVersions": ["2020-01-01"]
}
]
},
{
"name": "Microsoft.Test/complexResources/config.timeout",
"defaultPath": "properties.config.timeout",
"paths": []
},
{
"name": "Microsoft.Test/complexResources/settings.encryption.enabled",
"defaultPath": "properties.settings.encryption.enabled",
"paths": [
{
"path": "properties.settings.encryption.enabled",
"apiVersions": ["2020-01-01"]
},
{
"path": "properties.encryptionEnabled",
"apiVersions": ["2015-01-01"]
}
]
},
{
"name": "Microsoft.Test/complexResources/settings.encryption.keyVaultId",
"defaultPath": "properties.settings.encryption.keyVaultId",
"paths": []
}
]
}
]
}
]
+4
View File
@@ -0,0 +1,4 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
mod normalization;
@@ -0,0 +1,170 @@
# Extended data-plane manifest tests covering shapes beyond the basic
# KeyVault case: multiple resource types, top-level aliases, nested
# objects, array aliases, and schemaVersions.
data_manifest_json: |
{
"dataNamespace": "Microsoft.DataFactory.Data",
"aliases": [
{
"name": "Microsoft.DataFactory.Data/factories/pipelines/enabled",
"paths": [
{
"path": "enabled",
"apiVersions": ["2018-06-01"]
}
]
}
],
"resourceTypeAliases": [
{
"resourceType": "factories/pipelines",
"aliases": [
{
"name": "Microsoft.DataFactory.Data/factories/pipelines/activities[*].type",
"paths": [
{
"path": "activities[*].type",
"apiVersions": ["2018-06-01"]
}
]
},
{
"name": "Microsoft.DataFactory.Data/factories/pipelines/activities[*].name",
"paths": [
{
"path": "activities[*].name",
"apiVersions": ["2018-06-01"]
}
]
},
{
"name": "Microsoft.DataFactory.Data/factories/pipelines/concurrency",
"paths": [
{
"path": "concurrency",
"apiVersions": ["2018-06-01"]
}
]
},
{
"name": "Microsoft.DataFactory.Data/factories/pipelines/folder.name",
"paths": [
{
"path": "folder.name",
"apiVersions": ["2018-06-01"]
}
]
}
]
},
{
"resourceType": "factories/datasets",
"aliases": [
{
"name": "Microsoft.DataFactory.Data/factories/datasets/linkedServiceName.referenceName",
"paths": [
{
"path": "linkedServiceName.referenceName",
"schemaVersions": ["1"]
}
]
},
{
"name": "Microsoft.DataFactory.Data/factories/datasets/description",
"paths": [
{
"path": "description",
"schemaVersions": ["1"]
}
]
}
]
}
]
}
cases:
- note: data-plane pipeline with top-level alias and scalar fields
use_registry_api: true
input:
type: "Microsoft.DataFactory.Data/factories/pipelines"
enabled: true
concurrency: 5
folder:
name: "etl-jobs"
expected_normalized:
type: "Microsoft.DataFactory.Data/factories/pipelines"
enabled: true
concurrency: 5
folder:
name: "etl-jobs"
round_trip: true
- note: data-plane pipeline with array alias
use_registry_api: true
input:
type: "Microsoft.DataFactory.Data/factories/pipelines"
activities:
- name: CopyActivity1
type: Copy
- name: WaitActivity1
type: Wait
expected_normalized:
type: "Microsoft.DataFactory.Data/factories/pipelines"
activities:
- name: CopyActivity1
type: Copy
- name: WaitActivity1
type: Wait
round_trip: true
- note: data-plane dataset with nested alias path
use_registry_api: true
input:
type: "Microsoft.DataFactory.Data/factories/datasets"
description: "Sales data"
linkedServiceName:
referenceName: "AzureBlobStorage1"
type: LinkedServiceReference
expected_normalized:
type: "Microsoft.DataFactory.Data/factories/datasets"
description: "Sales data"
linkedservicename:
referencename: "AzureBlobStorage1"
type: LinkedServiceReference
round_trip: true
expected_round_trip:
type: "Microsoft.DataFactory.Data/factories/datasets"
description: "Sales data"
linkedServiceName:
referenceName: "AzureBlobStorage1"
type: LinkedServiceReference
- note: data-plane without aliases (unknown resource type in namespace)
use_registry_api: true
input:
type: "Microsoft.DataFactory.Data/factories/triggers"
recurrence:
frequency: Day
interval: 1
expected_normalized:
type: "Microsoft.DataFactory.Data/factories/triggers"
recurrence:
frequency: Day
interval: 1
round_trip: true
- note: data-plane denormalize dataset restores casing
use_registry_api: true
input:
type: "Microsoft.DataFactory.Data/factories/datasets"
description: "Sales data"
linkedservicename:
referencename: "AzureBlobStorage1"
expected_denormalized:
type: "Microsoft.DataFactory.Data/factories/datasets"
description: "Sales data"
linkedServiceName:
referenceName: "AzureBlobStorage1"
reverse_round_trip: true
@@ -0,0 +1,154 @@
# Broad data-plane compatibility tests covering multiple Azure data-plane
# namespaces and resource shapes. Each case exercises a distinct namespace
# to ensure the manifest-loading and data-plane normalization paths handle
# a variety of real-world patterns.
data_manifest_json: |
{
"dataNamespace": "Microsoft.Kubernetes.Data",
"aliases": [],
"resourceTypeAliases": [
{
"resourceType": "namespaces",
"aliases": [
{
"name": "Microsoft.Kubernetes.Data/namespaces/labels",
"paths": [{ "path": "labels", "apiVersions": ["v1"] }]
},
{
"name": "Microsoft.Kubernetes.Data/namespaces/annotations",
"paths": [{ "path": "annotations", "apiVersions": ["v1"] }]
}
]
},
{
"resourceType": "pods",
"aliases": [
{
"name": "Microsoft.Kubernetes.Data/pods/containers[*].image",
"paths": [{ "path": "containers[*].image", "apiVersions": ["v1"] }]
},
{
"name": "Microsoft.Kubernetes.Data/pods/containers[*].name",
"paths": [{ "path": "containers[*].name", "apiVersions": ["v1"] }]
},
{
"name": "Microsoft.Kubernetes.Data/pods/containers[*].resources.limits.cpu",
"paths": [{ "path": "containers[*].resources.limits.cpu", "apiVersions": ["v1"] }]
},
{
"name": "Microsoft.Kubernetes.Data/pods/hostNetwork",
"paths": [{ "path": "hostNetwork", "apiVersions": ["v1"] }]
}
]
}
]
}
cases:
# ── Kubernetes namespaces: flat labels/annotations ──
- note: "k8s namespace with labels and annotations"
use_registry_api: true
input:
type: "Microsoft.Kubernetes.Data/namespaces"
labels:
app: web-frontend
version: "v2"
annotations:
owner: team-alpha
expected_normalized:
type: "Microsoft.Kubernetes.Data/namespaces"
labels:
app: web-frontend
version: "v2"
annotations:
owner: team-alpha
round_trip: true
# ── Kubernetes pods: arrays with deeply nested fields ──
- note: "k8s pod with containers array and resource limits"
use_registry_api: true
input:
type: "Microsoft.Kubernetes.Data/pods"
hostNetwork: false
containers:
- name: app
image: "myregistry.azurecr.io/app:latest"
resources:
limits:
cpu: "500m"
memory: "256Mi"
- name: sidecar
image: "myregistry.azurecr.io/sidecar:v1"
resources:
limits:
cpu: "100m"
expected_normalized:
type: "Microsoft.Kubernetes.Data/pods"
hostnetwork: false
containers:
- name: app
image: "myregistry.azurecr.io/app:latest"
resources:
limits:
cpu: "500m"
memory: "256Mi"
- name: sidecar
image: "myregistry.azurecr.io/sidecar:v1"
resources:
limits:
cpu: "100m"
round_trip: true
- note: "k8s pod denormalize restores casing"
use_registry_api: true
input:
type: "Microsoft.Kubernetes.Data/pods"
hostnetwork: true
containers:
- name: app
image: "nginx"
expected_denormalized:
type: "Microsoft.Kubernetes.Data/pods"
hostNetwork: true
containers:
- name: app
image: "nginx"
reverse_round_trip: true
# ── Unknown resource type in known namespace ──
- note: "k8s unknown resource type passes through unchanged"
use_registry_api: true
input:
type: "Microsoft.Kubernetes.Data/services"
clusterIP: "10.0.0.1"
ports:
- port: 80
targetPort: 8080
expected_normalized:
type: "Microsoft.Kubernetes.Data/services"
clusterip: "10.0.0.1"
ports:
- port: 80
targetport: 8080
round_trip: true
# No aliases for this type, so casing is lost on round-trip.
expected_round_trip:
type: "Microsoft.Kubernetes.Data/services"
clusterip: "10.0.0.1"
ports:
- port: 80
targetport: 8080
# ── Empty containers array ──
- note: "k8s pod with empty containers array"
use_registry_api: true
input:
type: "Microsoft.Kubernetes.Data/pods"
hostNetwork: false
containers: []
expected_normalized:
type: "Microsoft.Kubernetes.Data/pods"
hostnetwork: false
containers: []
round_trip: true
@@ -0,0 +1,64 @@
# Tests that exercise data-plane manifest loading and the data-plane
# normalization path (resource type contains ".Data/").
# This covers load_data_policy_manifest_json() and the data-plane
# normalization branch in normalizer.rs.
data_manifest_json: |
{
"dataNamespace": "Microsoft.KeyVault.Data",
"aliases": [],
"resourceTypeAliases": [
{
"resourceType": "vaults/certificates",
"aliases": [
{
"name": "Microsoft.KeyVault.Data/vaults/certificates/keySize",
"paths": [
{
"path": "keySize",
"apiVersions": ["7.0"]
}
]
},
{
"name": "Microsoft.KeyVault.Data/vaults/certificates/attributes.expiresOn",
"paths": [
{
"path": "attributes.expiresOn",
"apiVersions": ["7.0"]
}
]
}
]
}
]
}
cases:
- note: data-plane manifest normalize via registry API
use_registry_api: true
input:
type: "Microsoft.KeyVault.Data/vaults/certificates"
keySize: 2048
attributes:
expiresOn: "2025-01-01T00:00:00Z"
expected_normalized:
type: "Microsoft.KeyVault.Data/vaults/certificates"
keysize: 2048
attributes:
expireson: "2025-01-01T00:00:00Z"
round_trip: true
- note: data-plane manifest round-trip via registry API
use_registry_api: true
input:
type: "Microsoft.KeyVault.Data/vaults/certificates"
keySize: 2048
attributes:
expiresOn: "2025-01-01T00:00:00Z"
round_trip: true
expected_round_trip:
type: "Microsoft.KeyVault.Data/vaults/certificates"
keySize: 2048
attributes:
expiresOn: "2025-01-01T00:00:00Z"
@@ -0,0 +1,319 @@
aliases_json: |
[
{
"namespace": "Microsoft.Storage",
"resourceTypes": [
{
"resourceType": "storageAccounts",
"aliases": [
{
"name": "Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly",
"defaultPath": "properties.supportsHttpsTrafficOnly",
"paths": []
},
{
"name": "Microsoft.Storage/storageAccounts/accessTier",
"defaultPath": "properties.accessTier",
"paths": []
},
{
"name": "Microsoft.Storage/storageAccounts/sku.name",
"defaultPath": "sku.name",
"paths": []
}
]
}
]
},
{
"namespace": "Microsoft.Network",
"resourceTypes": [
{
"resourceType": "networkSecurityGroups",
"aliases": [
{
"name": "Microsoft.Network/networkSecurityGroups/securityRules[*].protocol",
"defaultPath": "properties.securityRules[*].properties.protocol",
"paths": []
},
{
"name": "Microsoft.Network/networkSecurityGroups/securityRules[*].access",
"defaultPath": "properties.securityRules[*].properties.access",
"paths": []
},
{
"name": "Microsoft.Network/networkSecurityGroups/securityRules[*].name",
"defaultPath": "properties.securityRules[*].name",
"paths": []
}
]
},
{
"resourceType": "virtualNetworks",
"aliases": [
{
"name": "Microsoft.Network/virtualNetworks/subnets[*].addressPrefix",
"defaultPath": "properties.subnets[*].properties.addressPrefix",
"paths": []
},
{
"name": "Microsoft.Network/virtualNetworks/subnets[*].name",
"defaultPath": "properties.subnets[*].name",
"paths": []
},
{
"name": "Microsoft.Network/virtualNetworks/subnets[*].ipConfigurations[*].privateIPAddress",
"defaultPath": "properties.subnets[*].properties.ipConfigurations[*].properties.privateIPAddress",
"paths": []
},
{
"name": "Microsoft.Network/virtualNetworks/subnets[*].ipConfigurations[*].name",
"defaultPath": "properties.subnets[*].properties.ipConfigurations[*].name",
"paths": []
}
]
}
]
},
{
"namespace": "Microsoft.Web",
"resourceTypes": [
{
"resourceType": "sites",
"aliases": [
{
"name": "Microsoft.Web/sites/isEnabled",
"defaultPath": "properties.isEnabled",
"paths": [
{
"path": "properties.enabled",
"apiVersions": ["2020-01-01"]
}
]
}
]
}
]
},
{
"namespace": "test",
"resourceTypes": [
{
"resourceType": "resource",
"aliases": [
{
"name": "test/resource/type",
"defaultPath": "properties.type",
"paths": []
}
]
},
{
"resourceType": "versionedEnvelope",
"aliases": [
{
"name": "test/versionedEnvelope/items[*].status",
"defaultPath": "properties.items[*].properties.status",
"paths": [
{
"path": "properties.items[*].status",
"apiVersions": ["2025-01-01"]
}
]
},
{
"name": "test/versionedEnvelope/items[*].name",
"defaultPath": "properties.items[*].name",
"paths": []
}
]
}
]
}
]
cases:
- note: restores casing from aliases
input:
name: myStorage
type: "Microsoft.Storage/storageAccounts"
supportshttpstrafficonly: true
accesstier: Hot
expected_denormalized:
name: myStorage
type: "Microsoft.Storage/storageAccounts"
properties:
supportsHttpsTrafficOnly: true
accessTier: Hot
reverse_round_trip: true
- note: versioned path - default
input:
type: "Microsoft.Web/sites"
isenabled: true
expected_denormalized:
type: "Microsoft.Web/sites"
properties:
isEnabled: true
reverse_round_trip: true
- note: versioned path - matching version
input:
type: "Microsoft.Web/sites"
isenabled: true
api_version: "2020-01-01"
expected_denormalized:
type: "Microsoft.Web/sites"
properties:
enabled: true
reverse_round_trip: true
expected_reverse_round_trip:
type: "Microsoft.Web/sites"
enabled: true
isenabled: true
- note: collision safe key
input:
type: "test/resource"
_p_type: SubType
expected_denormalized:
type: "test/resource"
properties:
type: SubType
reverse_round_trip: true
- note: sub-resource array
input:
name: myNsg
type: "Microsoft.Network/networkSecurityGroups"
securityrules:
- name: rule1
protocol: Tcp
access: Allow
- name: rule2
protocol: "*"
access: Deny
expected_denormalized:
name: myNsg
type: "Microsoft.Network/networkSecurityGroups"
properties:
securityRules:
- name: rule1
properties:
protocol: Tcp
access: Allow
- name: rule2
properties:
protocol: "*"
access: Deny
reverse_round_trip: true
- note: nested sub-resource arrays
input:
name: myVnet
type: "Microsoft.Network/virtualNetworks"
subnets:
- name: subnet1
addressprefix: "10.0.0.0/24"
ipconfigurations:
- name: ipconfig1
privateipaddress: "10.0.0.4"
expected_denormalized:
name: myVnet
type: "Microsoft.Network/virtualNetworks"
properties:
subnets:
- name: subnet1
properties:
addressPrefix: "10.0.0.0/24"
ipConfigurations:
- name: ipconfig1
properties:
privateIPAddress: "10.0.0.4"
reverse_round_trip: true
- note: root level alias (sku.name)
input:
type: "Microsoft.Storage/storageAccounts"
sku:
name: Standard_LRS
expected_denormalized:
type: "Microsoft.Storage/storageAccounts"
sku:
name: Standard_LRS
reverse_round_trip: true
- note: versioned envelope classification — default (status under properties)
resource_type: "test/versionedEnvelope"
sub_resource_arrays: ["items"]
input:
type: "test/versionedEnvelope"
items:
- name: item1
status: active
expected_denormalized:
type: "test/versionedEnvelope"
properties:
items:
- name: item1
properties:
status: active
- note: versioned envelope classification — version promotes status to envelope
resource_type: "test/versionedEnvelope"
sub_resource_arrays: ["items"]
api_version: "2025-01-01"
input:
type: "test/versionedEnvelope"
items:
- name: item1
status: active
expected_denormalized:
type: "test/versionedEnvelope"
properties:
items:
- name: item1
status: active
- note: versioned casing restoration from versioned alias paths
aliases_json: |
[
{
"namespace": "Microsoft.Test",
"resourceTypes": [
{
"resourceType": "widgets",
"aliases": [
{
"name": "Microsoft.Test/widgets/items[*].rating",
"defaultPath": "properties.items[*].properties.oldField",
"paths": [
{
"path": "properties.items[*].properties.RenamedField",
"apiVersions": ["2025-06-01"]
}
]
},
{
"name": "Microsoft.Test/widgets/items[*].name",
"defaultPath": "properties.items[*].name",
"paths": []
}
]
}
]
}
]
api_version: "2025-06-01"
input:
type: "Microsoft.Test/widgets"
items:
- name: w1
rating: 5
expected_denormalized:
type: "Microsoft.Test/widgets"
properties:
items:
- name: w1
properties:
RenamedField: 5
@@ -0,0 +1,83 @@
cases:
- note: wraps properties
input:
name: myStorage
type: "Microsoft.Storage/storageAccounts"
location: westus2
supportshttpstrafficonly: true
ishnsenabled: false
expected_denormalized:
name: myStorage
type: "Microsoft.Storage/storageAccounts"
location: westus2
properties:
supportshttpstrafficonly: true
ishnsenabled: false
reverse_round_trip: true
- note: non-object returns clone
input: "just a string"
expected_denormalized: "just a string"
reverse_round_trip: true
- note: empty normalized
input: {}
expected_denormalized: {}
reverse_round_trip: true
- note: preserves root fields
input:
name: r
type: t
location: l
kind: k
id: "/sub/rg/r"
tags:
env: prod
identity:
type: SystemAssigned
principalid: pid-123
userassignedidentities:
/subscriptions/Sub/resourceGroups/Rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/Uai1: {}
sku:
name: Basic
plan:
name: p1
zones: ["1", "2"]
managedby: "/sub/other"
etag: "W/\"abc\""
apiversion: "2023-01-01"
fullname: parent/child
systemdata:
createdby: admin
extendedlocation:
name: edge1
type: EdgeZone
expected_denormalized:
name: r
type: t
location: l
kind: k
id: "/sub/rg/r"
tags:
env: prod
identity:
type: SystemAssigned
principalId: pid-123
userAssignedIdentities:
/subscriptions/Sub/resourceGroups/Rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/Uai1: {}
sku:
name: Basic
plan:
name: p1
zones: ["1", "2"]
managedBy: "/sub/other"
etag: "W/\"abc\""
apiVersion: "2023-01-01"
fullName: parent/child
systemData:
createdBy: admin
extendedLocation:
name: edge1
type: EdgeZone
reverse_round_trip: true
@@ -0,0 +1,201 @@
# Tests pinning behavior for ambiguous or malformed inputs that could
# be produced by external callers rather than the normalizer itself.
#
# The normalizer / denormalizer make assumptions about their input shape
# (e.g., keys are fully lowercased after normalization). These tests
# document what happens when those assumptions are violated, without
# asserting the behavior is "correct" per se — rather, they pin it so
# regressions are detected.
aliases_json: |
[
{
"namespace": "Microsoft.Storage",
"resourceTypes": [
{
"resourceType": "storageAccounts",
"aliases": [
{
"name": "Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly",
"defaultPath": "properties.supportsHttpsTrafficOnly",
"paths": []
},
{
"name": "Microsoft.Storage/storageAccounts/accessTier",
"defaultPath": "properties.accessTier",
"paths": []
}
]
}
]
},
{
"namespace": "Microsoft.Network",
"resourceTypes": [
{
"resourceType": "networkSecurityGroups",
"aliases": [
{
"name": "Microsoft.Network/networkSecurityGroups/securityRules[*].protocol",
"defaultPath": "properties.securityRules[*].properties.protocol",
"paths": []
},
{
"name": "Microsoft.Network/networkSecurityGroups/securityRules[*].access",
"defaultPath": "properties.securityRules[*].properties.access",
"paths": []
},
{
"name": "Microsoft.Network/networkSecurityGroups/securityRules[*].name",
"defaultPath": "properties.securityRules[*].name",
"paths": []
}
]
}
]
},
{
"namespace": "Microsoft.Web",
"resourceTypes": [
{
"resourceType": "sites",
"aliases": [
{
"name": "Microsoft.Web/sites/isEnabled",
"defaultPath": "properties.isEnabled",
"paths": [
{
"path": "properties.enabled",
"apiVersions": ["2020-01-01"]
}
]
}
]
}
]
}
]
cases:
# ── Normalize: ARM input with both root and properties having same field ──
- note: "normalize: both root 'name' and properties.name (root wins)"
input:
name: root-name
type: "Microsoft.Storage/storageAccounts"
properties:
name: props-name
accessTier: Hot
expected_normalized:
name: root-name
type: "Microsoft.Storage/storageAccounts"
accesstier: Hot
# ── Normalize: extra fields not in alias catalog or ROOT_FIELDS ──
- note: "normalize: extra non-aliased properties are kept (lowercased)"
input:
name: test
type: "Microsoft.Storage/storageAccounts"
properties:
supportsHttpsTrafficOnly: true
customUnknownField: 42
expected_normalized:
name: test
type: "Microsoft.Storage/storageAccounts"
supportshttpstrafficonly: true
customunknownfield: 42
# ── Denormalize: input has mixed casing (externally constructed) ──
- note: "denormalize: mixed-case keys still resolve via aliases"
input:
name: test
type: "Microsoft.Storage/storageAccounts"
SupportsHttpsTrafficOnly: true
expected_denormalized:
name: test
type: "Microsoft.Storage/storageAccounts"
properties:
supportsHttpsTrafficOnly: true
# ── Denormalize: input has extra fields not in alias catalog ──
- note: "denormalize: unknown fields go under properties (control-plane)"
input:
name: test
type: "Microsoft.Storage/storageAccounts"
unknownfield: 123
accesstier: Hot
expected_denormalized:
name: test
type: "Microsoft.Storage/storageAccounts"
properties:
unknownfield: 123
accessTier: Hot
# ── Denormalize with versioned path: both alias-named and ARM-named present ──
- note: "denormalize: both isenabled and enabled present, alias wins"
input:
type: "Microsoft.Web/sites"
isenabled: true
enabled: false
api_version: "2020-01-01"
expected_denormalized:
type: "Microsoft.Web/sites"
properties:
enabled: true
- note: "denormalize: both isenabled and enabled present, default path"
input:
type: "Microsoft.Web/sites"
isenabled: true
enabled: false
expected_denormalized:
type: "Microsoft.Web/sites"
properties:
isEnabled: true
enabled: false
# ── Normalize: ARM resource with empty type ──
- note: "normalize: empty type field, no alias match"
input:
name: test
type: ""
properties:
foo: bar
expected_normalized:
name: test
type: ""
foo: bar
# ── Normalize: ARM resource with null properties ──
- note: "normalize: null properties value"
input:
name: test
properties: null
expected_normalized:
name: test
# ── Denormalize: sub-resource array with elements already containing properties ──
- note: "denormalize: elements already have properties wrapper (double-wrap)"
input:
name: myNsg
type: "Microsoft.Network/networkSecurityGroups"
securityrules:
- name: rule1
properties:
protocol: Tcp
expected_denormalized:
name: myNsg
type: "Microsoft.Network/networkSecurityGroups"
properties:
securityRules:
- name: rule1
properties:
properties:
protocol: Tcp
@@ -0,0 +1,46 @@
aliases_json: |
[
{
"namespace": "Microsoft.Network",
"resourceTypes": [
{
"resourceType": "networkSecurityGroups",
"aliases": [
{
"name": "Microsoft.Network/networkSecurityGroups/securityRules[*].protocol",
"defaultPath": "properties.securityRules[*].properties.protocol",
"paths": []
}
]
}
]
}
]
cases:
- note: normalize-and-wrap full pipeline
input:
name: myNsg
type: "Microsoft.Network/networkSecurityGroups"
properties:
securityRules:
- name: rule1
properties:
protocol: Tcp
context:
resourceGroup:
name: rg1
parameters:
env: prod
expected_envelope:
resource:
name: myNsg
type: "Microsoft.Network/networkSecurityGroups"
securityrules:
- name: rule1
protocol: Tcp
context:
resourceGroup:
name: rg1
parameters:
env: prod
@@ -0,0 +1,233 @@
# Tests for malformed or unexpected *normalized* input fed to the
# denormalizer. These pin the current behavior when the denormalizer
# receives externally-produced JSON that violates the normalizer's
# implicit contract (e.g., keys not lowercased, wrong value types,
# missing type field, non-object input).
#
# These are NOT correctness assertions -- they document what the
# denormalizer actually produces so regressions are caught.
aliases_json: |
[
{
"namespace": "Microsoft.Storage",
"resourceTypes": [
{
"resourceType": "storageAccounts",
"aliases": [
{
"name": "Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly",
"defaultPath": "properties.supportsHttpsTrafficOnly",
"paths": []
},
{
"name": "Microsoft.Storage/storageAccounts/accessTier",
"defaultPath": "properties.accessTier",
"paths": []
}
]
}
]
},
{
"namespace": "Microsoft.Network",
"resourceTypes": [
{
"resourceType": "networkSecurityGroups",
"aliases": [
{
"name": "Microsoft.Network/networkSecurityGroups/securityRules[*].protocol",
"defaultPath": "properties.securityRules[*].properties.protocol",
"paths": []
},
{
"name": "Microsoft.Network/networkSecurityGroups/securityRules[*].name",
"defaultPath": "properties.securityRules[*].name",
"paths": []
}
]
}
]
}
]
cases:
# ── Non-object input ──
# The denormalizer should return the value as-is when it's not an object.
- note: "denormalize: string input returned as-is"
input: "just a string"
expected_denormalized: "just a string"
- note: "denormalize: integer input returned as-is"
input: 42
expected_denormalized: 42
- note: "denormalize: array input returned as-is"
input: [1, 2, 3]
expected_denormalized: [1, 2, 3]
- note: "denormalize: null input returned as-is"
input: null
expected_denormalized: null
- note: "denormalize: boolean input returned as-is"
input: true
expected_denormalized: true
# ── Missing type field ──
# Without a type, no alias resolution occurs; fields are still placed
# under `properties` (control-plane default) but casing is not restored.
- note: "denormalize: no type field, fields go under properties"
input:
name: test
unknownfield: 42
expected_denormalized:
name: test
properties:
unknownfield: 42
# ── Empty object ──
- note: "denormalize: empty object produces empty object"
input: {}
expected_denormalized: {}
# ── Object with only type field ──
- note: "denormalize: only type field, empty properties"
input:
type: "Microsoft.Storage/storageAccounts"
expected_denormalized:
type: "Microsoft.Storage/storageAccounts"
# ── Normalize: non-object input ──
- note: "normalize: string input returned as-is"
input: "just a string"
expected_normalized: "just a string"
- note: "normalize: integer input returned as-is"
input: 42
expected_normalized: 42
- note: "normalize: null input returned as-is"
input: null
expected_normalized: null
# ── Normalize: empty properties object ──
- note: "normalize: empty properties object"
input:
name: test
type: "Microsoft.Storage/storageAccounts"
properties: {}
expected_normalized:
name: test
type: "Microsoft.Storage/storageAccounts"
# ── Normalize: properties is a non-object value ──
- note: "normalize: properties is a string (treated as non-object, ignored)"
input:
name: test
type: "Microsoft.Storage/storageAccounts"
properties: "not an object"
expected_normalized:
name: test
type: "Microsoft.Storage/storageAccounts"
- note: "normalize: properties is an array (treated as non-object, ignored)"
input:
name: test
type: "Microsoft.Storage/storageAccounts"
properties: [1, 2, 3]
expected_normalized:
name: test
type: "Microsoft.Storage/storageAccounts"
# ── Denormalize: value types preserved through round trip ──
- note: "denormalize: numeric value zero"
input:
type: "Microsoft.Storage/storageAccounts"
accesstier: 0
expected_denormalized:
type: "Microsoft.Storage/storageAccounts"
properties:
accessTier: 0
- note: "denormalize: boolean false aliased field"
input:
type: "Microsoft.Storage/storageAccounts"
supportshttpstrafficonly: false
expected_denormalized:
type: "Microsoft.Storage/storageAccounts"
properties:
supportsHttpsTrafficOnly: false
- note: "denormalize: null-valued aliased field"
input:
type: "Microsoft.Storage/storageAccounts"
supportshttpstrafficonly: null
expected_denormalized:
type: "Microsoft.Storage/storageAccounts"
properties:
supportsHttpsTrafficOnly: null
# ── Sub-resource array with non-array value ──
- note: "denormalize: expected sub-resource array is a string"
input:
name: myNsg
type: "Microsoft.Network/networkSecurityGroups"
securityrules: "not an array"
expected_denormalized:
name: myNsg
type: "Microsoft.Network/networkSecurityGroups"
properties:
securityRules: "not an array"
- note: "denormalize: expected sub-resource array is null"
input:
name: myNsg
type: "Microsoft.Network/networkSecurityGroups"
securityrules: null
expected_denormalized:
name: myNsg
type: "Microsoft.Network/networkSecurityGroups"
properties:
securityRules: null
# ── Sub-resource array with non-object elements ──
- note: "denormalize: sub-resource contains scalar elements"
input:
name: myNsg
type: "Microsoft.Network/networkSecurityGroups"
securityrules:
- "just a string"
- 42
expected_denormalized:
name: myNsg
type: "Microsoft.Network/networkSecurityGroups"
properties:
securityRules:
- "just a string"
- 42
# ── Deeply nested null in normalize path ──
- note: "normalize: deeply nested null value preserved"
input:
name: test
type: "Microsoft.Storage/storageAccounts"
properties:
supportsHttpsTrafficOnly: null
accessTier: null
expected_normalized:
name: test
type: "Microsoft.Storage/storageAccounts"
supportshttpstrafficonly: null
accesstier: null
@@ -0,0 +1,258 @@
cases:
- note: flattens root properties
input:
name: myStorage
type: "Microsoft.Storage/storageAccounts"
location: westus2
properties:
supportsHttpsTrafficOnly: true
isHnsEnabled: false
expected_normalized:
name: myStorage
type: "Microsoft.Storage/storageAccounts"
location: westus2
supportshttpstrafficonly: true
ishnsenabled: false
round_trip: true
expected_round_trip:
name: myStorage
type: "Microsoft.Storage/storageAccounts"
location: westus2
properties:
supportshttpstrafficonly: true
ishnsenabled: false
- note: preserves root level precedence
input:
name: root-name
properties:
name: props-name
expected_normalized:
name: root-name
round_trip: true
expected_round_trip:
name: root-name
- note: leaves plain arrays alone
input:
name: test
properties:
networkAcls:
ipRules:
- value: "10.0.0.1"
action: Allow
- value: "10.0.0.2"
action: Deny
expected_normalized:
name: test
networkacls:
iprules:
- value: "10.0.0.1"
action: Allow
- value: "10.0.0.2"
action: Deny
round_trip: true
expected_round_trip:
name: test
properties:
networkacls:
iprules:
- value: "10.0.0.1"
action: Allow
- value: "10.0.0.2"
action: Deny
- note: handles sku at root
input:
name: test
sku:
name: Standard_LRS
tier: Standard
properties:
supportsHttpsTrafficOnly: true
expected_normalized:
name: test
sku:
name: Standard_LRS
tier: Standard
supportshttpstrafficonly: true
round_trip: true
expected_round_trip:
name: test
sku:
name: Standard_LRS
tier: Standard
properties:
supportshttpstrafficonly: true
- note: non-object returns clone
input: "just a string"
expected_normalized: "just a string"
round_trip: true
- note: empty properties
input:
name: test
properties: {}
expected_normalized:
name: test
round_trip: true
expected_round_trip:
name: test
- note: missing properties
input:
name: test
location: eastus
expected_normalized:
name: test
location: eastus
round_trip: true
- note: preserves all root fields
input:
name: r
type: t
location: l
kind: k
id: "/sub/rg/r"
tags:
env: prod
identity:
type: SystemAssigned
principalId: pid-123
userAssignedIdentities:
/subscriptions/Sub/resourceGroups/Rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/Uai1: {}
sku:
name: Basic
plan:
name: p1
zones: ["1", "2"]
managedBy: "/sub/other"
etag: "W/\"abc\""
apiVersion: "2023-01-01"
fullName: parent/child
systemData:
createdBy: admin
extendedLocation:
name: edge1
type: EdgeZone
properties:
someProp: true
expected_normalized:
name: r
type: t
location: l
kind: k
id: "/sub/rg/r"
tags:
env: prod
identity:
type: SystemAssigned
principalid: pid-123
userassignedidentities:
/subscriptions/Sub/resourceGroups/Rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/Uai1: {}
sku:
name: Basic
plan:
name: p1
zones: ["1", "2"]
managedby: "/sub/other"
etag: "W/\"abc\""
apiversion: "2023-01-01"
fullname: parent/child
systemdata:
createdby: admin
extendedlocation:
name: edge1
type: EdgeZone
someprop: true
round_trip: true
expected_round_trip:
name: r
type: t
location: l
kind: k
id: "/sub/rg/r"
tags:
env: prod
identity:
type: SystemAssigned
principalId: pid-123
userAssignedIdentities:
/subscriptions/Sub/resourceGroups/Rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/Uai1: {}
sku:
name: Basic
plan:
name: p1
zones: ["1", "2"]
managedBy: "/sub/other"
etag: "W/\"abc\""
apiVersion: "2023-01-01"
fullName: parent/child
systemData:
createdBy: admin
extendedLocation:
name: edge1
type: EdgeZone
properties:
someprop: true
- note: primitive array pass through
input:
name: test
properties:
allowedIPs: ["10.0.0.1", "10.0.0.2", "10.0.0.3"]
expected_normalized:
name: test
allowedips: ["10.0.0.1", "10.0.0.2", "10.0.0.3"]
round_trip: true
expected_round_trip:
name: test
properties:
allowedips: ["10.0.0.1", "10.0.0.2", "10.0.0.3"]
- note: deeply nested object no sub-resource
input:
name: test
properties:
networkAcls:
defaultAction: Deny
virtualNetworkRules:
- id: "/vnet/subnet1"
action: Allow
expected_normalized:
name: test
networkacls:
defaultaction: Deny
virtualnetworkrules:
- id: "/vnet/subnet1"
action: Allow
round_trip: true
expected_round_trip:
name: test
properties:
networkacls:
defaultaction: Deny
virtualnetworkrules:
- id: "/vnet/subnet1"
action: Allow
- note: unknown root fields are dropped (not in ROOT_FIELDS)
input:
name: test
type: "Microsoft.Foo/bars"
fooExtension:
barBaz: 1
properties:
enabled: true
expected_normalized:
name: test
type: "Microsoft.Foo/bars"
enabled: true
round_trip: true
expected_round_trip:
name: test
type: "Microsoft.Foo/bars"
properties:
enabled: true
@@ -0,0 +1,26 @@
cases:
- note: envelope defaults
input:
name: x
expected_envelope:
resource:
name: x
context: {}
parameters: {}
- note: envelope with context and parameters
input:
name: x
context:
resourceGroup:
name: rg1
parameters:
env: prod
expected_envelope:
resource:
name: x
context:
resourceGroup:
name: rg1
parameters:
env: prod
@@ -0,0 +1,123 @@
cases:
- note: flattens sub-resource arrays
input:
name: myNsg
type: "Microsoft.Network/networkSecurityGroups"
properties:
securityRules:
- name: rule1
properties:
protocol: Tcp
access: Allow
- name: rule2
properties:
protocol: "*"
access: Deny
sub_resource_arrays: ["securityRules"]
resource_type: "Microsoft.Network/networkSecurityGroups"
expected_normalized:
name: myNsg
type: "Microsoft.Network/networkSecurityGroups"
securityrules:
- name: rule1
protocol: Tcp
access: Allow
- name: rule2
protocol: "*"
access: Deny
round_trip: true
expected_round_trip:
name: myNsg
type: "Microsoft.Network/networkSecurityGroups"
properties:
securityrules:
- name: rule1
properties:
protocol: Tcp
access: Allow
- name: rule2
properties:
protocol: "*"
access: Deny
- note: nested sub-resource arrays
input:
name: myVnet
properties:
subnets:
- name: subnet1
properties:
addressPrefix: "10.0.0.0/24"
ipConfigurations:
- name: ipconfig1
properties:
privateIPAddress: "10.0.0.4"
sub_resource_arrays: ["subnets", "subnets.ipConfigurations"]
resource_type: "Microsoft.Network/virtualNetworks"
expected_normalized:
name: myVnet
subnets:
- name: subnet1
addressprefix: "10.0.0.0/24"
ipconfigurations:
- name: ipconfig1
privateipaddress: "10.0.0.4"
round_trip: true
expected_round_trip:
name: myVnet
properties:
subnets:
- name: subnet1
properties:
addressprefix: "10.0.0.0/24"
ipconfigurations:
- name: ipconfig1
properties:
privateipaddress: "10.0.0.4"
- note: sub-resource element without properties
input:
name: test
properties:
items:
- name: plain-object
enabled: true
sub_resource_arrays: ["items"]
resource_type: test
expected_normalized:
name: test
items:
- name: plain-object
enabled: true
round_trip: true
expected_round_trip:
name: test
properties:
items:
- name: plain-object
properties:
enabled: true
- note: case-insensitive sub-resource match
input:
name: test
properties:
securityRules:
- name: r1
properties:
protocol: Tcp
sub_resource_arrays: ["SecurityRules"]
resource_type: test
expected_normalized:
name: test
securityrules:
- name: r1
protocol: Tcp
round_trip: true
expected_round_trip:
name: test
properties:
securityrules:
- name: r1
properties:
protocol: Tcp
@@ -0,0 +1,127 @@
# Tests that exercise the public AliasRegistry API (normalize, denormalize,
# normalize_and_wrap) rather than the low-level *_with_aliases functions.
# This ensures the registry lookup path and public entry points are covered.
aliases_json: |
[
{
"namespace": "Microsoft.Storage",
"resourceTypes": [
{
"resourceType": "storageAccounts",
"aliases": [
{
"name": "Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly",
"defaultPath": "properties.supportsHttpsTrafficOnly",
"paths": []
},
{
"name": "Microsoft.Storage/storageAccounts/isHnsEnabled",
"defaultPath": "properties.isHnsEnabled",
"paths": []
}
]
}
]
},
{
"namespace": "Microsoft.Network",
"resourceTypes": [
{
"resourceType": "networkSecurityGroups",
"aliases": [
{
"name": "Microsoft.Network/networkSecurityGroups/securityRules[*].protocol",
"defaultPath": "properties.securityRules[*].properties.protocol",
"paths": []
},
{
"name": "Microsoft.Network/networkSecurityGroups/securityRules[*].name",
"defaultPath": "properties.securityRules[*].name",
"paths": []
}
]
}
]
}
]
cases:
- note: registry API normalize
use_registry_api: true
input:
name: myStorage
type: "Microsoft.Storage/storageAccounts"
location: westus2
properties:
supportsHttpsTrafficOnly: true
isHnsEnabled: false
expected_normalized:
name: myStorage
type: "Microsoft.Storage/storageAccounts"
location: westus2
supportshttpstrafficonly: true
ishnsenabled: false
round_trip: true
- note: registry API denormalize
use_registry_api: true
input:
name: myStorage
type: "Microsoft.Storage/storageAccounts"
location: westus2
supportshttpstrafficonly: true
ishnsenabled: false
expected_denormalized:
name: myStorage
type: "Microsoft.Storage/storageAccounts"
location: westus2
properties:
supportsHttpsTrafficOnly: true
isHnsEnabled: false
reverse_round_trip: true
- note: registry API normalize_and_wrap (envelope)
use_registry_api: true
input:
name: myNsg
type: "Microsoft.Network/networkSecurityGroups"
properties:
securityRules:
- name: rule1
properties:
protocol: Tcp
context:
resourceGroup:
name: rg1
expected_envelope:
resource:
name: myNsg
type: "Microsoft.Network/networkSecurityGroups"
securityrules:
- name: rule1
protocol: Tcp
context:
resourceGroup:
name: rg1
parameters: {}
- note: registry API round-trip sub-resource
use_registry_api: true
input:
name: myNsg
type: "Microsoft.Network/networkSecurityGroups"
properties:
securityRules:
- name: rule1
properties:
protocol: Tcp
round_trip: true
expected_round_trip:
name: myNsg
type: "Microsoft.Network/networkSecurityGroups"
properties:
securityRules:
- name: rule1
properties:
protocol: Tcp
@@ -0,0 +1,449 @@
aliases_json: |
[
{
"namespace": "Microsoft.Storage",
"resourceTypes": [
{
"resourceType": "storageAccounts",
"aliases": [
{
"name": "Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly",
"defaultPath": "properties.supportsHttpsTrafficOnly",
"paths": []
},
{
"name": "Microsoft.Storage/storageAccounts/isHnsEnabled",
"defaultPath": "properties.isHnsEnabled",
"paths": []
}
]
}
]
},
{
"namespace": "Microsoft.Network",
"resourceTypes": [
{
"resourceType": "networkSecurityGroups",
"aliases": [
{
"name": "Microsoft.Network/networkSecurityGroups/securityRules[*].protocol",
"defaultPath": "properties.securityRules[*].properties.protocol",
"paths": []
},
{
"name": "Microsoft.Network/networkSecurityGroups/securityRules[*].access",
"defaultPath": "properties.securityRules[*].properties.access",
"paths": []
},
{
"name": "Microsoft.Network/networkSecurityGroups/securityRules[*].name",
"defaultPath": "properties.securityRules[*].name",
"paths": []
}
]
}
]
}
]
cases:
- note: round-trip simple resource
input:
name: myStorage
type: "Microsoft.Storage/storageAccounts"
location: westus2
sku:
name: Standard_LRS
properties:
supportsHttpsTrafficOnly: true
isHnsEnabled: false
round_trip: true
expected_round_trip:
name: myStorage
type: "Microsoft.Storage/storageAccounts"
location: westus2
sku:
name: Standard_LRS
properties:
supportsHttpsTrafficOnly: true
isHnsEnabled: false
- note: round-trip sub-resource
input:
name: myNsg
type: "Microsoft.Network/networkSecurityGroups"
properties:
securityRules:
- name: rule1
properties:
protocol: Tcp
access: Allow
round_trip: true
expected_round_trip:
name: myNsg
type: "Microsoft.Network/networkSecurityGroups"
properties:
securityRules:
- name: rule1
properties:
protocol: Tcp
access: Allow
- note: round-trip versioned array alias (element field remap)
aliases_json: |
[
{
"namespace": "Microsoft.Network",
"resourceTypes": [
{
"resourceType": "firewallPolicies",
"aliases": [
{
"name": "Microsoft.Network/firewallPolicies/rules[*].priority",
"defaultPath": "properties.rules[*].properties.priority",
"paths": [
{
"path": "properties.rules[*].properties.prio",
"apiVersions": ["2021-01-01"]
}
]
},
{
"name": "Microsoft.Network/firewallPolicies/rules[*].name",
"defaultPath": "properties.rules[*].name",
"paths": []
}
]
}
]
}
]
input:
name: myPolicy
type: "Microsoft.Network/firewallPolicies"
properties:
rules:
- name: rule1
properties:
prio: 100
api_version: "2021-01-01"
round_trip: true
expected_round_trip:
name: myPolicy
type: "Microsoft.Network/firewallPolicies"
properties:
rules:
- name: rule1
properties:
prio: 100
# Regression: default and versioned paths both produce exactly one element
# remap (same count), but the source field differs. A length-only comparison
# would silently reuse the default aggregate, producing incorrect results for
# the versioned API version.
- note: round-trip versioned remap with same count but different source field
aliases_json: |
[
{
"namespace": "Microsoft.Network",
"resourceTypes": [
{
"resourceType": "firewallPolicies",
"aliases": [
{
"name": "Microsoft.Network/firewallPolicies/rules[*].priority",
"defaultPath": "properties.rules[*].properties.oldA",
"paths": [
{
"path": "properties.rules[*].properties.oldB",
"apiVersions": ["2023-06-01"]
}
]
},
{
"name": "Microsoft.Network/firewallPolicies/rules[*].name",
"defaultPath": "properties.rules[*].name",
"paths": []
}
]
}
]
}
]
input:
name: myPolicy
type: "Microsoft.Network/firewallPolicies"
properties:
rules:
- name: rule1
properties:
oldB: 100
api_version: "2023-06-01"
round_trip: true
expected_round_trip:
name: myPolicy
type: "Microsoft.Network/firewallPolicies"
properties:
rules:
- name: rule1
properties:
oldB: 100
- note: round-trip systemData and extendedLocation
input:
name: myStorage
type: "Microsoft.Storage/storageAccounts"
location: westus2
identity:
type: SystemAssigned
principalId: pid-123
userAssignedIdentities:
/subscriptions/Sub/resourceGroups/Rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/Uai1: {}
systemData:
createdBy: user@example.com
createdByType: User
createdAt: "2023-01-01T00:00:00Z"
extendedLocation:
name: edge-site-1
type: EdgeZone
properties:
supportsHttpsTrafficOnly: true
expected_normalized:
name: myStorage
type: "Microsoft.Storage/storageAccounts"
location: westus2
identity:
type: SystemAssigned
principalid: pid-123
userassignedidentities:
/subscriptions/Sub/resourceGroups/Rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/Uai1: {}
systemdata:
createdby: user@example.com
createdbytype: User
createdat: "2023-01-01T00:00:00Z"
extendedlocation:
name: edge-site-1
type: EdgeZone
supportshttpstrafficonly: true
round_trip: true
expected_round_trip:
name: myStorage
type: "Microsoft.Storage/storageAccounts"
location: westus2
identity:
type: SystemAssigned
principalId: pid-123
userAssignedIdentities:
/subscriptions/Sub/resourceGroups/Rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/Uai1: {}
systemData:
createdBy: user@example.com
createdByType: User
createdAt: "2023-01-01T00:00:00Z"
extendedLocation:
name: edge-site-1
type: EdgeZone
properties:
supportsHttpsTrafficOnly: true
# Regression: element remap must remove the stale ARM source field from the
# normalized output. Without cleanup, both the alias short name AND the
# original ARM leaf key survive; casing restoration during denormalization
# then produces a duplicate key (e.g. both "prio" and "priority").
- note: element remap removes stale ARM source field
aliases_json: |
[
{
"namespace": "Microsoft.Network",
"resourceTypes": [
{
"resourceType": "firewallPolicies",
"aliases": [
{
"name": "Microsoft.Network/firewallPolicies/rules[*].priority",
"defaultPath": "properties.rules[*].properties.prio",
"paths": []
},
{
"name": "Microsoft.Network/firewallPolicies/rules[*].name",
"defaultPath": "properties.rules[*].name",
"paths": []
}
]
}
]
}
]
input:
name: myPolicy
type: "Microsoft.Network/firewallPolicies"
properties:
rules:
- name: rule1
properties:
prio: 42
# Normalize: "prio" (ARM leaf) → "priority" (alias short name).
# The stale "prio" key must be removed from the normalized element.
expected_normalized:
name: myPolicy
type: "Microsoft.Network/firewallPolicies"
rules:
- name: rule1
priority: 42
# Round-trip: normalize → denormalize should restore ARM structure.
round_trip: true
expected_round_trip:
name: myPolicy
type: "Microsoft.Network/firewallPolicies"
properties:
rules:
- name: rule1
properties:
prio: 42
# Regression: same stale-field-cleanup scenario but with a dotted ARM leaf
# path (e.g. "config.value" rather than a single segment "prio").
# The remove_element_field helper must navigate to the parent object and
# remove the leaf key via remove_at_dotted_path.
- note: element remap removes stale dotted ARM source field
aliases_json: |
[
{
"namespace": "Microsoft.Test",
"resourceTypes": [
{
"resourceType": "widgets",
"aliases": [
{
"name": "Microsoft.Test/widgets/items[*].rating",
"defaultPath": "properties.items[*].properties.config.score",
"paths": []
},
{
"name": "Microsoft.Test/widgets/items[*].name",
"defaultPath": "properties.items[*].name",
"paths": []
}
]
}
]
}
]
input:
type: "Microsoft.Test/widgets"
properties:
items:
- name: w1
properties:
config:
score: 5
# Normalize: "config.score" (dotted ARM leaf) → "rating" (alias short name).
# The stale "config.score" path must be removed; only "rating" should remain.
expected_normalized:
type: "Microsoft.Test/widgets"
items:
- name: w1
config: {}
rating: 5
# Round-trip: normalize → denormalize should restore ARM structure.
round_trip: true
expected_round_trip:
type: "Microsoft.Test/widgets"
properties:
items:
- name: w1
properties:
config:
score: 5
# Regression: array base rename must move (not clone) the value so that
# the stale ARM base key does not survive in the normalized output.
# Without the removal, denormalization produces a duplicate key.
- note: array base rename removes stale ARM base key
aliases_json: |
[
{
"namespace": "Microsoft.Test",
"resourceTypes": [
{
"resourceType": "widgets",
"aliases": [
{
"name": "Microsoft.Test/widgets/items[*].label",
"defaultPath": "properties.entries[*].label",
"paths": []
}
]
}
]
}
]
input:
type: "Microsoft.Test/widgets"
properties:
entries:
- label: hello
# Normalize: ARM base "entries" → alias base "items".
# The stale "entries" key must be removed; only "items" should remain.
expected_normalized:
type: "Microsoft.Test/widgets"
items:
- label: hello
# Round-trip: normalize → denormalize should restore ARM structure.
round_trip: true
expected_round_trip:
type: "Microsoft.Test/widgets"
properties:
entries:
- label: hello
# Regression: denormalize reverse element remap with a dotted ARM target
# must preserve restored casing (e.g. "Config.Score") rather than
# re-lowercasing it.
- note: round-trip dotted element remap preserves ARM casing
aliases_json: |
[
{
"namespace": "Microsoft.Test",
"resourceTypes": [
{
"resourceType": "widgets",
"aliases": [
{
"name": "Microsoft.Test/widgets/items[*].rating",
"defaultPath": "properties.items[*].properties.Config.Score",
"paths": []
},
{
"name": "Microsoft.Test/widgets/items[*].name",
"defaultPath": "properties.items[*].name",
"paths": []
}
]
}
]
}
]
input:
type: "Microsoft.Test/widgets"
properties:
items:
- name: w1
properties:
Config:
Score: 9
expected_normalized:
type: "Microsoft.Test/widgets"
items:
- name: w1
config: {}
rating: 9
round_trip: true
expected_round_trip:
type: "Microsoft.Test/widgets"
properties:
items:
- name: w1
properties:
Config:
Score: 9
+322
View File
@@ -0,0 +1,322 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! YAML-driven normalization / denormalization tests.
//!
//! Each YAML file contains a `cases` array. Every case specifies an `input`
//! JSON value plus optional `aliases` and `api_version`. The runner then
//! checks whichever of the following fields are present:
//!
//! * `expected_normalized` result of normalizing `input`
//! * `expected_denormalized` result of denormalizing `input`
//! * `round_trip` normalize then denormalize; compare with `expected_round_trip`
//! * `reverse_round_trip` denormalize then normalize; compare with
//! `expected_reverse_round_trip`
use std::path::Path;
use anyhow::{bail, Result};
use serde::Deserialize;
use test_generator::test_resources;
use regorus::languages::azure_policy::aliases::normalizer;
use regorus::languages::azure_policy::aliases::types::ResolvedAliases;
use regorus::languages::azure_policy::aliases::{denormalizer, AliasRegistry};
use regorus::Value;
// ── YAML schema ──────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct YamlTest {
#[serde(default)]
aliases_json: Option<String>,
#[serde(default)]
aliases_file: Option<String>,
#[serde(default)]
data_manifest_json: Option<String>,
#[serde(default)]
data_manifest_file: Option<String>,
cases: Vec<TestCase>,
}
#[derive(Deserialize)]
struct TestCase {
note: String,
input: serde_json::Value,
#[serde(default)]
api_version: Option<String>,
#[serde(default)]
context: Option<serde_json::Value>,
#[serde(default)]
parameters: Option<serde_json::Value>,
#[serde(default)]
expected_normalized: Option<serde_json::Value>,
#[serde(default)]
expected_denormalized: Option<serde_json::Value>,
#[serde(default)]
expected_envelope: Option<serde_json::Value>,
#[serde(default)]
round_trip: bool,
#[serde(default)]
expected_round_trip: Option<serde_json::Value>,
#[serde(default)]
reverse_round_trip: bool,
#[serde(default)]
expected_reverse_round_trip: Option<serde_json::Value>,
#[serde(default)]
aliases_json: Option<String>,
#[serde(default)]
sub_resource_arrays: Option<Vec<String>>,
#[serde(default)]
resource_type: Option<String>,
#[serde(default)]
use_registry_api: bool,
}
// ── Helpers ──────────────────────────────────────────────────────────────
/// Convert a `serde_json::Value` to `regorus::Value`.
fn to_regorus(v: &serde_json::Value) -> Value {
Value::from(v.clone())
}
fn load_registry(yaml_file: &str, test: &YamlTest) -> Result<Option<AliasRegistry>> {
let mut reg = AliasRegistry::new();
let mut loaded = false;
if let Some(ref inline) = test.aliases_json {
reg.load_from_json(inline)?;
loaded = true;
}
if let Some(ref relpath) = test.aliases_file {
let base = Path::new(yaml_file).parent().unwrap_or(Path::new("."));
let path = base.join(relpath);
let json = std::fs::read_to_string(&path)?;
reg.load_from_json(&json)?;
loaded = true;
}
if let Some(ref inline) = test.data_manifest_json {
reg.load_data_policy_manifest_json(inline)?;
loaded = true;
}
if let Some(ref relpath) = test.data_manifest_file {
let base = Path::new(yaml_file).parent().unwrap_or(Path::new("."));
let path = base.join(relpath);
let json = std::fs::read_to_string(&path)?;
reg.load_data_policy_manifest_json(&json)?;
loaded = true;
}
Ok(if loaded { Some(reg) } else { None })
}
fn case_override_registry(case: &TestCase) -> Result<Option<AliasRegistry>> {
if let Some(ref inline) = case.aliases_json {
let mut reg = AliasRegistry::new();
reg.load_from_json(inline)?;
return Ok(Some(reg));
}
Ok(None)
}
fn resolve_aliases(
registry: Option<&AliasRegistry>,
case: &TestCase,
input: &serde_json::Value,
) -> Option<ResolvedAliases> {
let resource_type = case
.resource_type
.clone()
.or_else(|| input.get("type").and_then(|v| v.as_str()).map(String::from));
if let (Some(reg), Some(rt)) = (registry, resource_type.as_deref()) {
if let Some(resolved) = reg.get(rt) {
let mut r = resolved.clone();
if let Some(ref subs) = case.sub_resource_arrays {
r.sub_resource_arrays = subs.iter().map(|s| s.to_ascii_lowercase()).collect();
}
return Some(r);
}
}
if let Some(ref subs) = case.sub_resource_arrays {
return Some(ResolvedAliases {
resource_type: resource_type.unwrap_or_default(),
entries: Default::default(),
sub_resource_arrays: subs.iter().map(|s| s.to_ascii_lowercase()).collect(),
default_aggregates: Default::default(),
versioned_aggregates: Default::default(),
});
}
None
}
fn pretty_regorus(v: &Value) -> String {
v.to_json_str().unwrap_or_else(|_| format!("{v:?}"))
}
// ── Runner ───────────────────────────────────────────────────────────────
fn run_yaml_test(file: &str) -> Result<()> {
let yaml_str = std::fs::read_to_string(file)?;
let test: YamlTest = serde_yaml::from_str(&yaml_str)?;
let file_registry = load_registry(file, &test)?;
for case in &test.cases {
print!(" case: {}", case.note);
let case_override = case_override_registry(case)?;
let registry = case_override.as_ref().or(file_registry.as_ref());
let resolved = resolve_aliases(registry, case, &case.input);
let api_ver = case.api_version.as_deref();
let input = to_regorus(&case.input);
// ── normalize ────────────────────────────────────────────────
if let Some(ref expected) = case.expected_normalized {
let expected = to_regorus(expected);
let actual = if case.use_registry_api {
normalizer::normalize(&input, registry, api_ver)
} else {
normalizer::normalize_with_aliases(&input, resolved.as_ref(), api_ver)
};
if actual != expected {
bail!(
"normalize mismatch in '{}':\nexpected:\n{}\nactual:\n{}",
case.note,
pretty_regorus(&expected),
pretty_regorus(&actual),
);
}
}
// ── denormalize ──────────────────────────────────────────────
if let Some(ref expected) = case.expected_denormalized {
let expected = to_regorus(expected);
let actual = if case.use_registry_api {
denormalizer::denormalize(&input, registry, api_ver)
} else {
denormalizer::denormalize_with_aliases(&input, resolved.as_ref(), api_ver)
};
if actual != expected {
bail!(
"denormalize mismatch in '{}':\nexpected:\n{}\nactual:\n{}",
case.note,
pretty_regorus(&expected),
pretty_regorus(&actual),
);
}
}
// ── envelope ─────────────────────────────────────────────────
if let Some(ref expected) = case.expected_envelope {
let expected = to_regorus(expected);
let actual = if case.use_registry_api {
if let Some(reg) = registry {
reg.normalize_and_wrap(
&input,
api_ver,
case.context.as_ref().map(to_regorus),
case.parameters.as_ref().map(to_regorus),
)
} else {
let norm = normalizer::normalize(&input, None, api_ver);
normalizer::build_input_envelope(
norm,
case.context.as_ref().map(to_regorus),
case.parameters.as_ref().map(to_regorus),
)
}
} else {
let norm = normalizer::normalize_with_aliases(&input, resolved.as_ref(), api_ver);
normalizer::build_input_envelope(
norm,
case.context.as_ref().map(to_regorus),
case.parameters.as_ref().map(to_regorus),
)
};
if actual != expected {
bail!(
"envelope mismatch in '{}':\nexpected:\n{}\nactual:\n{}",
case.note,
pretty_regorus(&expected),
pretty_regorus(&actual),
);
}
}
// ── round-trip ───────────────────────────────────────────────
if case.round_trip {
let (normalized, denormalized) = if case.use_registry_api {
let n = normalizer::normalize(&input, registry, api_ver);
let d = denormalizer::denormalize(&n, registry, api_ver);
(n, d)
} else {
let n = normalizer::normalize_with_aliases(&input, resolved.as_ref(), api_ver);
let d = denormalizer::denormalize_with_aliases(&n, resolved.as_ref(), api_ver);
(n, d)
};
let _ = normalized;
if let Some(ref expected) = case.expected_round_trip {
let expected = to_regorus(expected);
if denormalized != expected {
bail!(
"round-trip mismatch in '{}':\nexpected:\n{}\nactual:\n{}",
case.note,
pretty_regorus(&expected),
pretty_regorus(&denormalized),
);
}
} else if denormalized != input {
bail!(
"round-trip mismatch in '{}' (expected original input):\ninput:\n{}\nresult:\n{}",
case.note,
pretty_regorus(&input),
pretty_regorus(&denormalized),
);
}
}
// ── reverse round-trip ─────────────────────────────────────
if case.reverse_round_trip {
let (denormalized, renormalized) = if case.use_registry_api {
let d = denormalizer::denormalize(&input, registry, api_ver);
let n = normalizer::normalize(&d, registry, api_ver);
(d, n)
} else {
let d = denormalizer::denormalize_with_aliases(&input, resolved.as_ref(), api_ver);
let n = normalizer::normalize_with_aliases(&d, resolved.as_ref(), api_ver);
(d, n)
};
let _ = denormalized;
if let Some(ref expected) = case.expected_reverse_round_trip {
let expected = to_regorus(expected);
if renormalized != expected {
bail!(
"reverse round-trip mismatch in '{}':\nexpected:\n{}\nactual:\n{}",
case.note,
pretty_regorus(&expected),
pretty_regorus(&renormalized),
);
}
} else if renormalized != input {
bail!(
"reverse round-trip mismatch in '{}' (expected original input):\ninput:\n{}\nresult:\n{}",
case.note,
pretty_regorus(&input),
pretty_regorus(&renormalized),
);
}
}
println!("ok");
}
println!(" {} cases passed in {file}", test.cases.len());
Ok(())
}
#[test_resources("tests/azure_policy/normalization/cases/**/*.yaml")]
fn run(path: &str) {
run_yaml_test(path).unwrap()
}
+3
View File
@@ -12,5 +12,8 @@ mod lexer;
mod parser;
mod value;
#[cfg(feature = "azure_policy")]
mod azure_policy;
#[cfg(feature = "rvm")]
mod rvm;