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
+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()
}