/** * Azure Resource Manager template serializer. * * Converts Declarable entities into ARM template JSON with: * - resources[] array (not keyed by logical name like CloudFormation) * - apiVersion per resource (from lexicon registry) * - Resource-level fields (sku, kind, identity, tags, zones, plan, location) * hoisted from properties * - ARM bracket expression references * - parameters, outputs sections */ import type { Declarable, CoreParameter } from "@intentius/chant/declarable"; import { isPropertyDeclarable, isResourceDeclarable } from "@intentius/chant/declarable"; import type { Serializer, SerializerResult, SerializeContext } from "@intentius/chant/serializer"; import { ownershipEntries, type OwnershipMarker } from "@intentius/chant/ownership"; import { AZURE_TAG_OWNERSHIP_KEYS } from "./ownership"; import type { LexiconOutput } from "@intentius/chant/lexicon-output"; import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker"; import { isChildProject, type ChildProjectInstance } from "@intentius/chant/child-project"; import { existsSync, readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { isStackOutput, type StackOutput } from "@intentius/chant/stack-output"; import { isAttrRefLike } from "@intentius/chant/utils"; import { resolveDependsOn } from "@intentius/chant/resource-attributes"; import { isDefaultTags, type TagEntry } from "./default-tags"; import { loadTaggableResources } from "./taggable"; import { findArmResourceRefs } from "./lint/post-synth/arm-refs"; import { isDeploymentScope, resolveTemplateScope, TEMPLATE_SCHEMAS, type DeployScope } from "./deploy-scopes"; /** Check if a declarable is a CoreParameter */ function isCoreParameter(entity: Declarable): entity is CoreParameter { return "parameterType" in entity; } /** ARM template structure */ interface ArmTemplate { $schema: string; contentVersion: string; parameters?: Record; resources: ArmResource[]; outputs?: Record; } interface ArmParameter { type: string; metadata?: { description?: string }; defaultValue?: unknown; allowedValues?: unknown[]; } interface ArmResource { type: string; apiVersion: string; name: string; location?: unknown; tags?: Record; sku?: unknown; kind?: unknown; identity?: unknown; zones?: unknown; plan?: unknown; properties?: Record; dependsOn?: string[]; } interface ArmOutput { type: string; value: unknown; } /** Resource-level fields that get hoisted from properties to resource level. */ const RESOURCE_LEVEL_FIELDS = new Set([ "location", "tags", "sku", "kind", "identity", "zones", "plan", ]); /** * Load apiVersion for a resource type from the lexicon registry. * * Reads the generated registry (src/generated/lexicon-azure.json in a dev * checkout, dist/meta.json in the installed package). Resolves the package * directory from import.meta.url so it works under pure ESM as well as * tsx/vitest — the old `require()`-based resolver threw under ESM and the * throw was swallowed, so every resource emitted the fallback apiVersion * (#1581). A missing registry is now an error: a uniform fallback apiVersion * silently bypasses the per-provider pins in spec/api-versions.ts. */ function resolveDir(): string { return dirname(fileURLToPath(import.meta.url)); } export function apiVersionRegistryCandidates(dir: string = resolveDir()): string[] { return [ join(dir, "generated", "lexicon-azure.json"), join(dir, "..", "dist", "meta.json"), ]; } export function loadApiVersions(candidates: string[] = apiVersionRegistryCandidates()): Map { const map = new Map(); let content: string | undefined; for (const candidate of candidates) { if (!existsSync(candidate)) continue; content = readFileSync(candidate, "utf-8"); break; } if (content === undefined) { throw new Error( `azure: apiVersion registry not found (tried ${candidates.join(", ")}). ` + "Run `npm run generate` (dev) or reinstall the package (dist/meta.json).", ); } const data = JSON.parse(content) as Record; for (const entry of Object.values(data)) { if (entry.apiVersion && entry.resourceType) { map.set(entry.resourceType, entry.apiVersion); } } if (map.size === 0) { throw new Error("azure: apiVersion registry is empty — regenerate with `npm run generate`"); } return map; } /** * Override stale apiVersions from the generated registry. * These are resource types where the registry apiVersion is known to be * too old for modern ARM features (e.g. DataActions on role assignments). */ const API_VERSION_OVERRIDES = new Map([ ["Microsoft.Authorization/roleAssignments", "2022-04-01"], ["Microsoft.Authorization/roleDefinitions", "2022-04-01"], ]); let _apiVersions: Map | undefined; /** Test hook: drop the cached registry so the next lookup reloads it. */ export function resetApiVersionCache(): void { _apiVersions = undefined; } /** * Registry lookup without the serializer's fallback: the pinned apiVersion for * `resourceType`, or `undefined` when neither the overrides nor the generated * registry know the type. ARM resource types are case-insensitive and the live * listing does not always echo the casing the registry uses, so the match is * case-insensitive too. Used by prune (#1472), which needs an apiVersion for a * type the current template no longer declares. */ export function lookupApiVersion(resourceType: string): string | undefined { const override = API_VERSION_OVERRIDES.get(resourceType); if (override) return override; if (!_apiVersions) _apiVersions = loadApiVersions(); const exact = _apiVersions.get(resourceType); if (exact) return exact; const wanted = resourceType.toLowerCase(); for (const [type, version] of _apiVersions) { if (type.toLowerCase() === wanted) return version; } return undefined; } export function getApiVersion(resourceType: string): string { const version = lookupApiVersion(resourceType); if (version) return version; console.warn( `azure: no pinned apiVersion for ${resourceType} in the lexicon registry; falling back to 2023-01-01`, ); return "2023-01-01"; } /** * ARM-specific visitor for the generic serializer walker. */ function armVisitor(entityNames: Map): SerializerVisitor { return { attrRef: (name, attr) => `[reference('${name}').${attr}]`, resourceRef: (name) => { // Look up the resource type from entityNames const entity = [...entityNames.entries()].find(([, n]) => n === name)?.[0]; if (entity) { return `[resourceId('${entity.entityType}', '${name}')]`; } return `[resourceId('${name}')]`; }, propertyDeclarable: (entity, walk) => { if (!isResourceDeclarable(entity) || typeof entity.props !== "object" || entity.props === null) { return undefined; } const props = entity.props as Record; const armProps: Record = {}; for (const [key, value] of Object.entries(props)) { if (value !== undefined) { armProps[key] = walk(value); } } return Object.keys(armProps).length > 0 ? armProps : undefined; }, }; } /** * Convert a value to ARM-compatible JSON using the generic walker. */ function toArmValue(value: unknown, entityNames: Map): unknown { return walkValue(value, entityNames, armVisitor(entityNames)); } /** * Serialize entities into an ARM template object. */ function serializeToTemplate( entities: Map, outputs?: LexiconOutput[], ownership?: OwnershipMarker, ): ArmTemplate { // Resolve the deployment scope from the resource types being emitted // (#1545): a template of tenant-only resources (management groups, policy // definitions at management-group scope, ...) must carry the matching // $schema and must not lean on resourceGroup() expressions. Mixed sets // with no common scope fall back to resource-group scope and AZR030 // reports the resources that cannot deploy there. A deploymentScope() // pin overrides the inference — policy definitions and assignments // deploy at several scopes, so a management-group guardrail project // cannot be told from a subscription one by its resource set alone //; AZR030 still flags resources the pinned scope cannot hold. let pinnedScope: DeployScope | undefined; const emittedTypes: string[] = []; for (const [, entity] of entities) { if (isStackOutput(entity) || isDefaultTags(entity) || isCoreParameter(entity)) continue; if (isDeploymentScope(entity)) { pinnedScope ??= entity.scope; } else if (isChildProject(entity)) { emittedTypes.push("Microsoft.Resources/deployments"); } else if (!isPropertyDeclarable(entity)) { emittedTypes.push(entity.entityType); } } const templateScope = pinnedScope ?? resolveTemplateScope(emittedTypes); const template: ArmTemplate = { $schema: TEMPLATE_SCHEMAS[templateScope], contentVersion: "1.0.0.0", resources: [], }; // Build reverse map: entity -> name const entityNames = new Map(); for (const [name, entity] of entities) { entityNames.set(entity, name); } // Collect default tags. The ownership marker is stamped as tags, seeded // first so user default tags (and explicit per-resource tags) win. const defaultTagEntries: TagEntry[] = []; if (ownership) { for (const [key, value] of Object.entries(ownershipEntries(AZURE_TAG_OWNERSHIP_KEYS, ownership))) { defaultTagEntries.push({ key, value }); } } for (const [, entity] of entities) { if (isDefaultTags(entity)) { defaultTagEntries.push(...entity.tags); } } // Process entities for (const [name, entity] of entities) { // Skip non-resource types if (isStackOutput(entity) || isDefaultTags(entity) || isDeploymentScope(entity)) continue; if (isCoreParameter(entity)) { if (!template.parameters) template.parameters = {}; const param: ArmParameter = { type: mapParameterType(entity.parameterType), }; if ("description" in entity && typeof entity.description === "string") { param.metadata = { description: entity.description }; } if ("defaultValue" in entity && entity.defaultValue !== undefined) { param.defaultValue = entity.defaultValue; } template.parameters[name] = param; } else if (isChildProject(entity)) { // ChildProjectInstance → linked deployment const childProject = entity as ChildProjectInstance; const childName = childProject.logicalName; const filename = `${childName}.template.json`; const resource: ArmResource = { type: "Microsoft.Resources/deployments", apiVersion: "2021-04-01", name: childName, properties: { mode: "Incremental", templateLink: { uri: filename }, }, }; template.resources.push(resource); } else if (!isPropertyDeclarable(entity)) { const resourceType = entity.entityType; // Prefer apiVersion from entity attributes (set by composites) over registry lookup const attrs0 = (isResourceDeclarable(entity) && typeof entity.attributes === "object" && entity.attributes !== null) ? entity.attributes as Record : undefined; const apiVersion = (typeof attrs0?.apiVersion === "string") ? attrs0.apiVersion : getApiVersion(resourceType); const resource: ArmResource = { type: resourceType, apiVersion, name: name, }; // Extract all props if (isResourceDeclarable(entity) && typeof entity.props === "object" && entity.props !== null) { const props = entity.props as Record; const armProperties: Record = {}; for (const [key, value] of Object.entries(props)) { if (value === undefined) continue; if (RESOURCE_LEVEL_FIELDS.has(key)) { // Hoist to resource level (resource as unknown as Record)[key] = toArmValue(value, entityNames); } else if (key === "name") { resource.name = toArmValue(value, entityNames) as string; } else { armProperties[key] = toArmValue(value, entityNames); } } if (Object.keys(armProperties).length > 0) { resource.properties = armProperties; } } // Default location to resource group location if not specified. // Above resource-group scope there is no resource group to read a // location from, and the scoped resources (management groups, policy // definitions, ...) have no location field at all (#1545). if (!resource.location && templateScope === "resourceGroup") { resource.location = "[resourceGroup().location]"; } // Handle DependsOn const attrs = (isResourceDeclarable(entity) && typeof entity.attributes === "object" && entity.attributes !== null) ? entity.attributes as Record : undefined; if (attrs?.DependsOn !== undefined) { const resolved = resolveDependsOn(attrs.DependsOn, entityNames, name); if (resolved.length > 0) { resource.dependsOn = resolved.map((dep) => { // Convert to resourceId expression const depEntity = [...entityNames.entries()].find(([, n]) => n === dep)?.[0]; if (depEntity) { return `[resourceId('${depEntity.entityType}', '${dep}')]`; } return dep; }); } } template.resources.push(resource); } } // Inject default tags into taggable resources if (defaultTagEntries.length > 0) { const taggable = loadTaggableResources(); for (const resource of template.resources) { if (!taggable.has(resource.type)) continue; const resolved: Record = {}; for (const t of defaultTagEntries) { resolved[t.key] = toArmValue(t.value, entityNames); } const existing = resource.tags ?? {}; // Explicit tags take precedence resource.tags = { ...resolved, ...(existing as Record) }; } } // Auto-infer dependsOn from resourceId()/reference() expressions in properties. // ARM only auto-infers dependencies from reference(), not resourceId(). const resourceNameSet = new Set(template.resources.map((r) => r.name)); for (const resource of template.resources) { const refs = findArmResourceRefs(resource.properties); // Also scan resource-level fields (location, tags, identity, etc.) for (const field of RESOURCE_LEVEL_FIELDS) { const val = (resource as unknown as Record)[field]; if (val !== undefined) { for (const ref of findArmResourceRefs(val)) { refs.add(ref); } } } // Also scan the name field for parent references (e.g. "vnet/subnet") if (typeof resource.name === "string") { for (const ref of findArmResourceRefs(resource.name)) { refs.add(ref); } } // Infer parent dependency for child resources (e.g. "vnet/subnet" depends on "vnet") if (typeof resource.name === "string" && resource.name.includes("/")) { const parentName = resource.name.split("/")[0]; if (resourceNameSet.has(parentName)) { refs.add(parentName); } } if (refs.size > 0) { const existing = new Set(resource.dependsOn ?? []); for (const refName of refs) { // Only add if the referenced resource exists in this template and isn't self if (refName === resource.name) continue; if (!resourceNameSet.has(refName)) continue; // Find the resource to build the resourceId expression const depResource = template.resources.find((r) => r.name === refName); if (depResource) { const depExpr = `[resourceId('${depResource.type}', '${refName}')]`; if (!existing.has(depExpr)) { existing.add(depExpr); } } } if (existing.size > 0) { resource.dependsOn = [...existing]; } } } // Emit StackOutput entities as ARM outputs for (const [name, entity] of entities) { if (isStackOutput(entity)) { if (!template.outputs) template.outputs = {}; const stackOutput = entity as StackOutput; // `unknown` so isAttrRefLike narrows cleanly across the workspace/published // type boundary. An intrinsic-wrapped output (#517) has no bare ARM // reference form, so skip it here (only aws/CFN emits those). const ref: unknown = stackOutput.sourceRef; if (isAttrRefLike(ref)) { const logicalName = ref.getLogicalName(); if (logicalName) { template.outputs[name] = { type: "string", value: `[reference('${logicalName}').${ref.attribute}]`, }; } } } } // Add LexiconOutputs if (outputs && outputs.length > 0) { template.outputs = template.outputs ?? {}; for (const output of outputs) { template.outputs[output.outputName] = { type: "string", value: `[reference('${output.sourceEntity}').${output.sourceAttribute}]`, }; } } return template; } /** * Map chant parameter types to ARM parameter types. */ function mapParameterType(paramType: string): string { switch (paramType) { case "String": return "string"; case "Number": return "int"; case "CommaDelimitedList": return "array"; default: return "string"; } } /** * Azure ARM template serializer implementation. */ export const azureSerializer: Serializer = { name: "azure", rulePrefix: "AZR", serialize(entities: Map, outputs?: LexiconOutput[], context?: SerializeContext): string | SerializerResult { // Check for child projects (linked templates) let hasChildProjects = false; const allFiles: Record = {}; for (const [, entity] of entities) { if (isChildProject(entity)) { hasChildProjects = true; const childProject = entity as ChildProjectInstance; if (childProject.buildResult) { const childOutput = childProject.buildResult.outputs.get("azure"); if (childOutput) { const childName = childProject.logicalName; const filename = `${childName}.template.json`; if (typeof childOutput === "string") { allFiles[filename] = childOutput; } else { allFiles[filename] = childOutput.primary; if (childOutput.files) { for (const [f, c] of Object.entries(childOutput.files)) { allFiles[f] = c; } } } } } } } const template = serializeToTemplate(entities, outputs, context?.ownership); const primary = JSON.stringify(template, null, 2); if (!hasChildProjects) { return primary; } return { primary, files: allFiles }; }, };