@intentius/chant-lexicon-azure 0.124.34 → 0.124.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,7 @@
19
19
  * token acquisition lands here.
20
20
  */
21
21
 
22
+ import { armResourcePath } from "../arm-type";
22
23
  import { type AzHttp } from "../op/activities/az-apply";
23
24
 
24
25
  /** Default ARM host, matching the applier's. */
@@ -78,7 +79,7 @@ export function armResourceReadUrl(
78
79
  ): string {
79
80
  const base = (options.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, "");
80
81
  const subscription = options.subscriptionId ?? DEFAULT_SUBSCRIPTION;
81
- return `${base}/subscriptions/${subscription}/resourceGroups/${options.resourceGroup}/providers/${type}/${name}?api-version=${apiVersion}`;
82
+ return `${base}/subscriptions/${subscription}/resourceGroups/${options.resourceGroup}/providers/${armResourcePath(type, name)}?api-version=${apiVersion}`;
82
83
  }
83
84
 
84
85
  /**
@@ -0,0 +1,47 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { armResourcePath, armType } from "./arm-type";
3
+ import { azureSerializer } from "./serializer";
4
+ import { armResourceReadUrl } from "./api/read-client";
5
+ import { armResourceUrl, evalArmString, type ArmEvalCtx } from "./op/activities/az-apply";
6
+ import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
7
+ import { AttrRef } from "@intentius/chant";
8
+
9
+ describe("ARM child types (#4230)", () => {
10
+ test("armType joins child types with /", () => {
11
+ expect(armType("Microsoft.Network/virtualNetworks_subnets")).toBe("Microsoft.Network/virtualNetworks/subnets");
12
+ expect(armType("Microsoft.Storage/storageAccounts_blobServices_containers")).toBe("Microsoft.Storage/storageAccounts/blobServices/containers");
13
+ expect(armType("Microsoft.Storage/storageAccounts")).toBe("Microsoft.Storage/storageAccounts");
14
+ expect(armType("Microsoft.Network/virtualNetworks/subnets")).toBe("Microsoft.Network/virtualNetworks/subnets");
15
+ });
16
+
17
+ test("armResourcePath interleaves type and name segments", () => {
18
+ expect(armResourcePath("Microsoft.Network/virtualNetworks_subnets", "vnet/apps")).toBe("Microsoft.Network/virtualNetworks/vnet/subnets/apps");
19
+ expect(armResourcePath("Microsoft.Storage/storageAccounts", "st1")).toBe("Microsoft.Storage/storageAccounts/st1");
20
+ });
21
+
22
+ test("a Subnet serializes type, resourceId and dependsOn with /", () => {
23
+ const entity = (entityType: string, props: Record<string, unknown>): Declarable =>
24
+ ({ [DECLARABLE_MARKER]: true, lexicon: "azure", entityType, kind: "resource", props }) as unknown as Declarable;
25
+ const subnet = entity("Microsoft.Network/virtualNetworks_subnets", { name: "vnet/apps", addressPrefix: "10.0.1.0/24" });
26
+ const subnetId = new AttrRef(subnet, "id");
27
+ subnetId._setLogicalName("apps");
28
+ const nic = entity("Microsoft.Network/networkInterfaces", { name: "nic1", subnetId });
29
+ const entities = new Map<string, Declarable>([["apps", subnet], ["nic", nic]]);
30
+ const template = JSON.parse(azureSerializer.serialize(entities) as string);
31
+ const [s, n] = template.resources;
32
+ expect(s.type).toBe("Microsoft.Network/virtualNetworks/subnets");
33
+ expect(JSON.stringify(template)).not.toContain("virtualNetworks_subnets");
34
+ expect(n.properties.subnetId).toBe("[resourceId('Microsoft.Network/virtualNetworks/subnets', 'vnet', 'apps')]");
35
+ expect(n.dependsOn).toContain("[resourceId('Microsoft.Network/virtualNetworks/subnets', 'vnet', 'apps')]");
36
+ });
37
+
38
+ test("apply and read address a child resource by its ARM path", async () => {
39
+ const ctx = { base: "http://x", subscriptionId: "sub-1", resourceGroup: "rg", location: "eastus", deployed: new Map() } as unknown as ArmEvalCtx;
40
+ const want = "http://x/subscriptions/sub-1/resourceGroups/rg/providers/Microsoft.Network/virtualNetworks/vnet/subnets/apps?api-version=2024-01-01";
41
+ expect(await armResourceUrl({ type: "Microsoft.Network/virtualNetworks/subnets", apiVersion: "2024-01-01", name: "vnet/apps" }, ctx)).toBe(want);
42
+ expect(armResourceReadUrl({ endpoint: "http://x", subscriptionId: "sub-1", resourceGroup: "rg" }, "Microsoft.Network/virtualNetworks_subnets", "vnet/apps", "2024-01-01")).toBe(want);
43
+ expect(await evalArmString("[resourceId('Microsoft.Network/virtualNetworks/subnets', 'vnet', 'apps')]", ctx)).toBe(
44
+ "/subscriptions/sub-1/resourceGroups/rg/providers/Microsoft.Network/virtualNetworks/vnet/subnets/apps",
45
+ );
46
+ });
47
+ });
@@ -0,0 +1,31 @@
1
+ /**
2
+ * ARM resource types as ARM spells them (#4230).
3
+ *
4
+ * The lexicon's entity types come from the ARM schema ids, which join a child
5
+ * type to its parent with `_` (`Microsoft.Network/virtualNetworks_subnets`).
6
+ * ARM itself joins them with `/` (`Microsoft.Network/virtualNetworks/subnets`),
7
+ * in a template's `type`, in `resourceId()` and in the resource URL. Resource
8
+ * type segments never contain `_`, so every `_` after the namespace is a
9
+ * child separator.
10
+ */
11
+
12
+ /** `Microsoft.Network/virtualNetworks_subnets` → `Microsoft.Network/virtualNetworks/subnets`. Idempotent. */
13
+ export function armType(entityType: string): string {
14
+ const slash = entityType.indexOf("/");
15
+ if (slash < 0) return entityType;
16
+ return entityType.slice(0, slash + 1) + entityType.slice(slash + 1).replace(/_/g, "/");
17
+ }
18
+
19
+ /**
20
+ * The provider-relative path of one resource: each type segment followed by
21
+ * its name segment, as ARM's `resourceId()` builds it.
22
+ * `('Microsoft.Network/virtualNetworks/subnets', 'vnet/apps')` →
23
+ * `Microsoft.Network/virtualNetworks/vnet/subnets/apps`. A name with fewer
24
+ * segments than the type has kinds is joined as given.
25
+ */
26
+ export function armResourcePath(type: string, name: string): string {
27
+ const [namespace, ...kinds] = armType(type).split("/");
28
+ const names = name.split("/");
29
+ if (kinds.length <= 1 || names.length !== kinds.length) return `${armType(type)}/${name}`;
30
+ return [namespace, ...kinds.flatMap((kind, i) => [kind, names[i]])].join("/");
31
+ }
@@ -18,7 +18,7 @@ const stubArmFetch = (response: { status: number; text: string }): void => {
18
18
  };
19
19
 
20
20
  const { azurePlugin } = await import("./plugin");
21
- const { observeResourcesDeepAzure, azureDeepNormalizationHooks } = await import("./deep-observe");
21
+ const { observeResourcesDeepAzure, azureDeepNormalizationHooks, userAssignedIdentityKey } = await import("./deep-observe");
22
22
  const { deepDiffForLexicon } = await import("@intentius/chant/lifecycle/deep-observe");
23
23
  const { normalizeDeepObservation, normalizeDeepProperties, UNRESOLVED } = await import("@intentius/chant/deep-observation");
24
24
 
@@ -599,3 +599,58 @@ describe("end to end: declared + mutated live + baseline (#1086)", () => {
599
599
  ).toBe(true);
600
600
  });
601
601
  });
602
+
603
+ describe("user-assigned identities (#4231)", () => {
604
+ const identityId = (name: string) =>
605
+ `/subscriptions/sub/resourceGroups/Prod/providers/Microsoft.ManagedIdentity/userAssignedIdentities/${name}`;
606
+ const declared = entities({
607
+ web: {
608
+ entityType: "Microsoft.App/containerApps",
609
+ props: {
610
+ name: "web",
611
+ location: "eastus",
612
+ identity: {
613
+ type: "UserAssigned",
614
+ userAssignedIdentities: { "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', 'appId')]": {} },
615
+ },
616
+ },
617
+ },
618
+ });
619
+ const liveWith = (name: string) =>
620
+ stubArmFetch(ok({
621
+ id: "/subscriptions/sub/resourceGroups/prod/providers/Microsoft.App/containerApps/web",
622
+ name: "web",
623
+ location: "eastus",
624
+ identity: {
625
+ type: "UserAssigned",
626
+ principalId: "p0",
627
+ tenantId: "t0",
628
+ userAssignedIdentities: { [identityId(name)]: { principalId: "p1", clientId: "c1" } },
629
+ },
630
+ properties: { provisioningState: "Succeeded" },
631
+ }));
632
+
633
+ test("both spellings of an identity's key are one key", () => {
634
+ expect(userAssignedIdentityKey("[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', 'appId')]")).toBe(
635
+ "microsoft.managedidentity/userassignedidentities/appid",
636
+ );
637
+ expect(userAssignedIdentityKey(identityId("appId"))).toBe("microsoft.managedidentity/userassignedidentities/appid");
638
+ expect(userAssignedIdentityKey("plain")).toBe("plain");
639
+ });
640
+
641
+ test("an applied identity is not drift", async () => {
642
+ liveWith("appId");
643
+ const result = await deepDiffForLexicon(azurePlugin, { environment: "prod", buildOutput: "", entities: declared });
644
+ expect(result.drifted).toEqual([]);
645
+ expect(result.unclaimed).toEqual([]);
646
+ expect(result.unchanged).toEqual(["web"]);
647
+ });
648
+
649
+ test("a different identity attached live is drift", async () => {
650
+ liveWith("otherId");
651
+ const result = await deepDiffForLexicon(azurePlugin, { environment: "prod", buildOutput: "", entities: declared });
652
+ expect(result.drifted.map((d) => d.changes.map((c) => `${c.kind} ${c.path}`))).toEqual([
653
+ ["absent identity.userAssignedIdentities.microsoft.managedidentity/userassignedidentities/appid"],
654
+ ]);
655
+ });
656
+ });
@@ -240,6 +240,45 @@ function lastSegment(pattern: string): string {
240
240
  return dot === -1 ? withoutIndex : withoutIndex.slice(dot + 1);
241
241
  }
242
242
 
243
+ /**
244
+ * The canonical key for one entry of `identity.userAssignedIdentities`
245
+ * (#4231): `<type>/<name>` lowercased, from either spelling. Source keys the
246
+ * map by `[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities',
247
+ * 'appId')]`; ARM answers with the evaluated id, `/subscriptions/.../providers/
248
+ * Microsoft.ManagedIdentity/userAssignedIdentities/appId`, in whatever case it
249
+ * stored. A key in neither form is left as it is.
250
+ */
251
+ export function userAssignedIdentityKey(key: string): string {
252
+ const expr = /^\[\s*resourceId\(\s*'([^']+)'\s*,\s*'([^']+)'\s*\)\s*\]$/.exec(key);
253
+ if (expr) return `${expr[1]}/${expr[2]}`.toLowerCase();
254
+ const at = key.toLowerCase().lastIndexOf("/providers/");
255
+ if (at !== -1 && key.startsWith("/subscriptions/")) return key.slice(at + "/providers/".length).toLowerCase();
256
+ return key;
257
+ }
258
+
259
+ /**
260
+ * Rekey `identity.userAssignedIdentities` by {@link userAssignedIdentityKey},
261
+ * on a copy, and drop the `principalId` and `clientId` ARM fills in on each
262
+ * entry. Nobody declares them, and pruning them later would leave the live
263
+ * entry empty and gone, where source declares `{}`.
264
+ */
265
+ function rekeyUserAssignedIdentities(tree: Record<string, unknown>): Record<string, unknown> {
266
+ const identity = tree.identity;
267
+ if (!identity || typeof identity !== "object" || Array.isArray(identity)) return tree;
268
+ const assigned = (identity as Record<string, unknown>).userAssignedIdentities;
269
+ if (!assigned || typeof assigned !== "object" || Array.isArray(assigned)) return tree;
270
+ const rekeyed: Record<string, unknown> = {};
271
+ for (const [k, v] of Object.entries(assigned)) {
272
+ let entry = v;
273
+ if (entry && typeof entry === "object" && !Array.isArray(entry)) {
274
+ const { principalId: _p, clientId: _c, ...rest } = entry as Record<string, unknown>;
275
+ entry = rest;
276
+ }
277
+ rekeyed[userAssignedIdentityKey(k)] = entry;
278
+ }
279
+ return { ...tree, identity: { ...(identity as Record<string, unknown>), userAssignedIdentities: rekeyed } };
280
+ }
281
+
243
282
  /**
244
283
  * The azure lexicon's noise rules. Three classes, same as AWS: server-
245
284
  * populated fields (by name, unconditional), controller-injected boilerplate
@@ -250,6 +289,8 @@ function lastSegment(pattern: string): string {
250
289
  * since it is a plain object; only the *default empty map* needs a rule here).
251
290
  */
252
291
  export const azureDeepNormalizationHooks: DeepNormalizationHooks = {
292
+ prepare: (tree) => rekeyUserAssignedIdentities(tree),
293
+
253
294
  prune(node: DeepNode): boolean {
254
295
  const name = lastSegment(node.pattern);
255
296
  if (AZURE_READ_ONLY_NAMES.has(name) || AZURE_UNCONDITIONAL_PRUNE_NAMES.has(name)) return true;
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
3
+ import { azureGraphEdges } from "./graph-edges";
4
+ import { azureSerializer } from "./serializer";
5
+ import { ListKeys, Reference, ResourceId } from "./intrinsics";
6
+
7
+ function makeEntity(entityType: string, props: Record<string, unknown> = {}): any {
8
+ return { [DECLARABLE_MARKER]: true, lexicon: "azure", entityType, kind: "resource", props };
9
+ }
10
+
11
+ /** behold's three-cloud Azure member, cut down to the two references in #4232. */
12
+ function threeCloud(): Map<string, any> {
13
+ const entities = new Map<string, any>();
14
+ entities.set("logs", makeEntity("Microsoft.OperationalInsights/workspaces", { name: "logs", location: "eastus" }));
15
+ entities.set("appIdentity", makeEntity("Microsoft.ManagedIdentity/userAssignedIdentities", { name: "app-id", location: "eastus" }));
16
+ entities.set("env", makeEntity("Microsoft.App/managedEnvironments", {
17
+ name: "env",
18
+ location: "eastus",
19
+ appLogsConfiguration: {
20
+ destination: "log-analytics",
21
+ logAnalyticsConfiguration: {
22
+ customerId: "[reference(resourceId('Microsoft.OperationalInsights/workspaces', 'logs')).customerId]",
23
+ sharedKey: ListKeys(ResourceId("Microsoft.OperationalInsights/workspaces", "logs"), "2022-10-01"),
24
+ },
25
+ },
26
+ }));
27
+ entities.set("app", makeEntity("Microsoft.App/containerApps", {
28
+ name: "web",
29
+ location: "eastus",
30
+ identity: {
31
+ type: "UserAssigned",
32
+ userAssignedIdentities: {
33
+ "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', 'app-id')]": {},
34
+ },
35
+ },
36
+ managedEnvironmentId: Reference("env"),
37
+ }));
38
+ return entities;
39
+ }
40
+
41
+ describe("azureGraphEdges (#4232)", () => {
42
+ it("draws env -> logs from reference(resourceId(...)) and listKeys(...)", () => {
43
+ const refs = azureGraphEdges(threeCloud())!.references;
44
+ expect(refs).toContainEqual({ from: "env", to: "logs", kind: "ref", viaAttr: "appLogsConfiguration" });
45
+ expect(refs.filter((r) => r.from === "env" && r.to === "logs")).toHaveLength(1);
46
+ });
47
+
48
+ it("draws app -> identity from a resourceId(...) object key, by the identity's ARM name", () => {
49
+ const refs = azureGraphEdges(threeCloud())!.references;
50
+ expect(refs).toContainEqual({ from: "app", to: "appIdentity", kind: "ref", viaAttr: "identity" });
51
+ expect(refs).toContainEqual({ from: "app", to: "env", kind: "ref", viaAttr: "managedEnvironmentId" });
52
+ });
53
+
54
+ it("agrees with the dependsOn the serializer writes", () => {
55
+ const entities = threeCloud();
56
+ const template = JSON.parse(azureSerializer.serialize(entities) as string);
57
+ const keyOfName = new Map<string, string>([["logs", "logs"], ["app-id", "appIdentity"], ["env", "env"], ["web", "app"]]);
58
+ const fromTemplate = new Set<string>();
59
+ for (const r of template.resources) {
60
+ for (const dep of r.dependsOn ?? []) {
61
+ const name = /'([^']+)'\)\]$/.exec(dep)![1];
62
+ fromTemplate.add(`${keyOfName.get(r.name)}->${keyOfName.get(name)}`);
63
+ }
64
+ }
65
+ const fromGraph = new Set(azureGraphEdges(entities)!.references.map((r) => `${r.from}->${r.to}`));
66
+ expect(fromGraph).toEqual(fromTemplate);
67
+ expect(fromGraph).toEqual(new Set(["env->logs", "app->appIdentity", "app->env"]));
68
+ });
69
+
70
+ it("matches a child resource by name whichever way its type is spelled, and links it to its parent", () => {
71
+ const entities = new Map<string, any>();
72
+ entities.set("vnet", makeEntity("Microsoft.Network/virtualNetworks", { name: "vnet", location: "eastus" }));
73
+ entities.set("apps", makeEntity("Microsoft.Network/virtualNetworks_subnets", { name: "vnet/apps" }));
74
+ entities.set("nic", makeEntity("Microsoft.Network/networkInterfaces", {
75
+ name: "nic",
76
+ location: "eastus",
77
+ ipConfigurations: [{ name: "ip", properties: { subnet: { id: "[resourceId('Microsoft.Network/virtualNetworks/subnets', 'vnet', 'apps')]" } } }],
78
+ }));
79
+ const refs = azureGraphEdges(entities)!.references;
80
+ expect(refs).toContainEqual({ from: "nic", to: "apps", kind: "ref", viaAttr: "ipConfigurations" });
81
+ expect(refs).toContainEqual({ from: "apps", to: "vnet", kind: "ref", viaAttr: "name" });
82
+ });
83
+
84
+ it("leaves out a name nothing declares, and returns nothing without azure resources", () => {
85
+ const entities = new Map<string, any>();
86
+ entities.set("app", makeEntity("Microsoft.Web/sites", {
87
+ name: "site",
88
+ location: "eastus",
89
+ serverFarmId: "[resourceId('Microsoft.Web/serverfarms', 'existing-plan')]",
90
+ }));
91
+ expect(azureGraphEdges(entities)).toBeUndefined();
92
+ expect(azureGraphEdges(new Map())).toBeUndefined();
93
+ });
94
+ });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * References written as ARM expressions, as graph edges (#4232).
3
+ *
4
+ * `chant graph` draws an edge for every `AttrRef` it finds in props, but an
5
+ * Azure reference is often an expression instead: `reference(resourceId(...))
6
+ * .customerId` and `listKeys(...)` for a value the resource has no attribute
7
+ * for, or a `resourceId(...)` used as an object key, which is how
8
+ * `identity.userAssignedIdentities` names an identity. The serializer already
9
+ * reads these to write `dependsOn` (./serializer.ts), with
10
+ * `findArmResourceRefs` (./lint/post-synth/arm-refs.ts). This module runs the
11
+ * same extraction over each prop as the template writes it and reports each
12
+ * reference to a declared resource through the plugin's `graphEdges` hook.
13
+ *
14
+ * A reference matches by resource name, never by type, as the serializer's
15
+ * inference does. A name nothing in the project declares is left out: it is
16
+ * an existing resource the template reads, not a dangling reference.
17
+ */
18
+
19
+ import type { Declarable } from "@intentius/chant/declarable";
20
+ import { isPropertyDeclarable, isResourceDeclarable } from "@intentius/chant/declarable";
21
+ import type { LexiconGraphEdges, LexiconGraphReference } from "@intentius/chant/graph-ir";
22
+ import { findArmResourceRefs } from "./lint/post-synth/arm-refs";
23
+ import { armResourceName, toArmValue } from "./serializer";
24
+
25
+ function isAzureResource(entity: Declarable): boolean {
26
+ return entity.lexicon === "azure" && isResourceDeclarable(entity) && !isPropertyDeclarable(entity);
27
+ }
28
+
29
+ export function azureGraphEdges(entities: ReadonlyMap<string, Declarable>): LexiconGraphEdges | undefined {
30
+ const entityNames = new Map<Declarable, string>();
31
+ for (const [key, entity] of entities) entityNames.set(entity, key);
32
+
33
+ // Template name -> entity key, for the resources whose name is a literal.
34
+ const byArmName = new Map<string, string>();
35
+ for (const [key, entity] of entities) {
36
+ if (!isAzureResource(entity)) continue;
37
+ const name = armResourceName(entity, key, entityNames);
38
+ if (typeof name === "string") byArmName.set(name, key);
39
+ }
40
+ if (byArmName.size === 0) return undefined;
41
+
42
+ const references: LexiconGraphReference[] = [];
43
+ const seen = new Set<string>();
44
+ const add = (from: string, to: string | undefined, viaAttr: string): void => {
45
+ if (!to || to === from) return;
46
+ const id = `${from}\0${to}\0${viaAttr}`;
47
+ if (seen.has(id)) return;
48
+ seen.add(id);
49
+ references.push({ from, to, kind: "ref", viaAttr });
50
+ };
51
+
52
+ for (const [key, entity] of entities) {
53
+ if (!isAzureResource(entity)) continue;
54
+ const props = (entity as { props?: unknown }).props;
55
+ if (props && typeof props === "object") {
56
+ for (const [attr, value] of Object.entries(props as Record<string, unknown>)) {
57
+ if (value === undefined) continue;
58
+ for (const ref of findArmResourceRefs(toArmValue(value, entityNames))) {
59
+ add(key, byArmName.get(ref), attr);
60
+ }
61
+ }
62
+ }
63
+ // A child resource named "vnet/subnet" depends on "vnet", as the
64
+ // serializer infers for dependsOn.
65
+ const name = armResourceName(entity, key, entityNames);
66
+ if (typeof name === "string" && name.includes("/")) {
67
+ add(key, byArmName.get(name.split("/")[0]), "name");
68
+ }
69
+ }
70
+
71
+ return references.length > 0 ? { references } : undefined;
72
+ }
@@ -56,6 +56,8 @@ export function parseArmTemplate(output: string | { primary: string }): ArmTempl
56
56
  * - [reference('myStorage')]
57
57
  * - [reference('myStorage').primaryEndpoints.blob]
58
58
  *
59
+ * Object keys are read as well as values.
60
+ *
59
61
  * Returns the set of resource names found in these references.
60
62
  */
61
63
  export function findArmResourceRefs(value: unknown): Set<string> {
@@ -121,7 +123,10 @@ function walkArmValue(value: unknown, refs: Set<string>): void {
121
123
 
122
124
  if (typeof value === "object") {
123
125
  const obj = value as Record<string, unknown>;
124
- for (const val of Object.values(obj)) {
126
+ for (const [key, val] of Object.entries(obj)) {
127
+ // A key can be an expression too: a user-assigned identity is named by
128
+ // its resource ID as the key of identity.userAssignedIdentities.
129
+ walkArmValue(key, refs);
125
130
  walkArmValue(val, refs);
126
131
  }
127
132
  }
@@ -149,6 +149,38 @@ describe("armResourceUrl / armResourceBody (#707)", () => {
149
149
  tags: { "managed-by": "chant" },
150
150
  });
151
151
  });
152
+
153
+ test("body keeps identity, zones, plan, extendedLocation and managedBy, keys evaluated (#4231)", async () => {
154
+ const app: ArmResource = {
155
+ type: "Microsoft.App/containerApps",
156
+ apiVersion: "2024-03-01",
157
+ name: "web",
158
+ location: "[resourceGroup().location]",
159
+ identity: {
160
+ type: "UserAssigned",
161
+ userAssignedIdentities: { "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', 'appId')]": {} },
162
+ },
163
+ zones: ["1"],
164
+ plan: { name: "p1", publisher: "pub", product: "prod" },
165
+ extendedLocation: { type: "EdgeZone", name: "edge1" },
166
+ managedBy: "[resourceId('Microsoft.Web/sites', 'owner')]",
167
+ properties: {},
168
+ };
169
+ expect(await armResourceBody(app, ctx())).toEqual({
170
+ location: "eastus",
171
+ properties: {},
172
+ identity: {
173
+ type: "UserAssigned",
174
+ userAssignedIdentities: {
175
+ "/subscriptions/sub-1/resourceGroups/chant-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/appId": {},
176
+ },
177
+ },
178
+ zones: ["1"],
179
+ plan: { name: "p1", publisher: "pub", product: "prod" },
180
+ extendedLocation: { type: "EdgeZone", name: "edge1" },
181
+ managedBy: "/subscriptions/sub-1/resourceGroups/chant-rg/providers/Microsoft.Web/sites/owner",
182
+ });
183
+ });
152
184
  });
153
185
 
154
186
  describe("azApply flow (#707)", () => {
@@ -1,4 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import { armResourcePath } from "../../arm-type";
2
3
  import { createHash } from "node:crypto";
3
4
  import { hasOwnershipMarker, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
4
5
  import { AZURE_TAG_OWNERSHIP_KEYS } from "../../ownership";
@@ -25,6 +26,11 @@ export interface ArmResource {
25
26
  sku?: unknown;
26
27
  kind?: unknown;
27
28
  tags?: Record<string, string>;
29
+ identity?: unknown;
30
+ zones?: unknown;
31
+ plan?: unknown;
32
+ extendedLocation?: unknown;
33
+ managedBy?: unknown;
28
34
  dependsOn?: unknown;
29
35
  }
30
36
 
@@ -71,13 +77,17 @@ export async function evalArmString(s: string, ctx: ArmEvalCtx): Promise<unknown
71
77
  return new ArmExpr(s.slice(1, -1), ctx).parse();
72
78
  }
73
79
 
74
- /** Recursively evaluate every string in a value against the ARM context. */
80
+ /**
81
+ * Recursively evaluate every string in a value against the ARM context,
82
+ * object keys included: ARM evaluates a property name too, and
83
+ * `identity.userAssignedIdentities` is keyed by `[resourceId(...)]` (#4231).
84
+ */
75
85
  export async function evalArm(value: unknown, ctx: ArmEvalCtx): Promise<unknown> {
76
86
  if (typeof value === "string") return evalArmString(value, ctx);
77
87
  if (Array.isArray(value)) return Promise.all(value.map((v) => evalArm(v, ctx)));
78
88
  if (value && typeof value === "object") {
79
89
  const out: Record<string, unknown> = {};
80
- for (const [k, v] of Object.entries(value)) out[k] = await evalArm(v, ctx);
90
+ for (const [k, v] of Object.entries(value)) out[String(await evalArmString(k, ctx))] = await evalArm(v, ctx);
81
91
  return out;
82
92
  }
83
93
  return value;
@@ -151,7 +161,7 @@ class ArmExpr {
151
161
  return { subscriptionId: this.ctx.subscriptionId, id: `/subscriptions/${this.ctx.subscriptionId}` };
152
162
  case "resourceId":
153
163
  // resourceId('Microsoft.X/y', 'name'[, 'child']) → the resource-id path.
154
- return `/subscriptions/${this.ctx.subscriptionId}/resourceGroups/${this.ctx.resourceGroup}/providers/${args.map(String).join("/")}`;
164
+ return `/subscriptions/${this.ctx.subscriptionId}/resourceGroups/${this.ctx.resourceGroup}/providers/${armResourcePath(String(args[0]), args.slice(1).map(String).join("/"))}`;
155
165
  case "reference": {
156
166
  // reference('name') → the runtime `properties` of an already-applied resource.
157
167
  const dep = this.ctx.deployed.get(String(args[0]));
@@ -260,17 +270,23 @@ export function orderArmResources(resources: ArmResource[]): ArmResource[] {
260
270
  /** The ARM resource-ID PUT URL for a resource (name expression evaluated). */
261
271
  export async function armResourceUrl(resource: ArmResource, ctx: ArmEvalCtx): Promise<string> {
262
272
  const name = await evalArmString(resource.name, ctx);
263
- return `${ctx.base}/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}/providers/${resource.type}/${name}?api-version=${resource.apiVersion}`;
273
+ return `${ctx.base}/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}/providers/${armResourcePath(resource.type, String(name))}?api-version=${resource.apiVersion}`;
264
274
  }
265
275
 
266
- /** The ARM resource PUT body (location/properties/sku/kind/tags), expressions evaluated. */
276
+ /**
277
+ * The top-level ARM resource fields a PUT takes beside `location` (#4231). A
278
+ * field missing here is dropped from the body, which is how a declared
279
+ * `identity` came back absent.
280
+ */
281
+ const ARM_BODY_FIELDS = ["properties", "sku", "kind", "tags", "identity", "zones", "plan", "extendedLocation", "managedBy"] as const;
282
+
283
+ /** The ARM resource PUT body: `location` and {@link ARM_BODY_FIELDS}, expressions evaluated. */
267
284
  export async function armResourceBody(resource: ArmResource, ctx: ArmEvalCtx): Promise<Record<string, unknown>> {
268
285
  const body: Record<string, unknown> = {};
269
286
  if (resource.location) body.location = await evalArmString(resource.location, ctx);
270
- if (resource.properties !== undefined) body.properties = await evalArm(resource.properties, ctx);
271
- if (resource.sku !== undefined) body.sku = await evalArm(resource.sku, ctx);
272
- if (resource.kind !== undefined) body.kind = await evalArm(resource.kind, ctx);
273
- if (resource.tags !== undefined) body.tags = await evalArm(resource.tags, ctx);
287
+ for (const field of ARM_BODY_FIELDS) {
288
+ if (resource[field] !== undefined) body[field] = await evalArm(resource[field], ctx);
289
+ }
274
290
  return body;
275
291
  }
276
292
 
@@ -454,7 +470,7 @@ export async function deleteArmResource(
454
470
  http: AzHttp = defaultHttp,
455
471
  signal?: AbortSignal,
456
472
  ): Promise<{ type: string; name: string; deleted: boolean }> {
457
- const url = `${ctx.base}/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}/providers/${type}/${name}?api-version=${apiVersion}`;
473
+ const url = `${ctx.base}/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}/providers/${armResourcePath(type, name)}?api-version=${apiVersion}`;
458
474
  const res = await http("DELETE", url, undefined, signal);
459
475
  if (res.status === 404) return { type, name, deleted: false };
460
476
  if (res.status >= 300) throw new Error(`${type} ${name} delete failed (${res.status}): ${res.text}`);
package/src/plugin.ts CHANGED
@@ -15,6 +15,7 @@ import { join, dirname } from "path";
15
15
  import { fileURLToPath } from "url";
16
16
  import { moduleSourceDir } from "@intentius/chant/module-source";
17
17
  import { azureSerializer } from "./serializer";
18
+ import { azureGraphEdges } from "./graph-edges";
18
19
  import { hardcodedLocationRule } from "./lint/rules/hardcoded-location";
19
20
  import { storageHttpsRule } from "./lint/rules/storage-https";
20
21
  import { nsgWildcardRule } from "./lint/rules/nsg-wildcard";
@@ -37,6 +38,8 @@ export const azurePlugin: LexiconPlugin = {
37
38
  emulator: FLOCI_AZ_EMULATOR,
38
39
  auditCatalog: () => azureAuditCatalog,
39
40
  serializer: azureSerializer,
41
+ // References written as ARM expressions (resourceId, reference, listKeys) as graph edges (#4232).
42
+ graphEdges: (entities) => azureGraphEdges(entities),
40
43
 
41
44
  lintRules(): LintRule[] {
42
45
  return [hardcodedLocationRule, storageHttpsRule, nsgWildcardRule];
package/src/serializer.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  * - parameters, outputs sections
11
11
  */
12
12
 
13
+ import { armType } from "./arm-type";
13
14
  import type { Declarable, CoreParameter } from "@intentius/chant/declarable";
14
15
  import { isPropertyDeclarable, isResourceDeclarable } from "@intentius/chant/declarable";
15
16
  import type { Serializer, SerializerResult, SerializeContext } from "@intentius/chant/serializer";
@@ -194,7 +195,7 @@ function entityNamed(entityNames: Map<Declarable, string>, name: string): Declar
194
195
  function armResourceId(entityNames: Map<Declarable, string>, name: string): string {
195
196
  const entity = entityNamed(entityNames, name);
196
197
  const { args } = armNameArgs(entity, name);
197
- return entity ? `resourceId('${entity.entityType}', ${args})` : `resourceId(${args})`;
198
+ return entity ? `resourceId('${armType(entity.entityType)}', ${args})` : `resourceId(${args})`;
198
199
  }
199
200
 
200
201
  /**
@@ -259,10 +260,24 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
259
260
  return proto === Object.prototype || proto === null;
260
261
  }
261
262
 
262
- function toArmValue(value: unknown, entityNames: Map<Declarable, string>): unknown {
263
+ /** A prop value as the template writes it: references become bracket expressions. */
264
+ export function toArmValue(value: unknown, entityNames: Map<Declarable, string>): unknown {
263
265
  return walkValue(value, entityNames, armVisitor(entityNames));
264
266
  }
265
267
 
268
+ /**
269
+ * The name a resource takes in the template: its `name` prop, or the entity
270
+ * key when it sets none. `findArmResourceRefs` reports references by this
271
+ * name, so the dependsOn inference below and the graph edges
272
+ * (./graph-edges.ts) both resolve a reference through it.
273
+ */
274
+ export function armResourceName(entity: Declarable, key: string, entityNames: Map<Declarable, string>): unknown {
275
+ const props = isResourceDeclarable(entity) && typeof entity.props === "object" && entity.props !== null
276
+ ? entity.props as Record<string, unknown>
277
+ : undefined;
278
+ return props?.name !== undefined ? toArmValue(props.name, entityNames) : key;
279
+ }
280
+
266
281
  /**
267
282
  * Serialize entities into an ARM template object.
268
283
  */
@@ -291,7 +306,7 @@ function serializeToTemplate(
291
306
  } else if (isChildProject(entity)) {
292
307
  emittedTypes.push("Microsoft.Resources/deployments");
293
308
  } else if (!isPropertyDeclarable(entity)) {
294
- emittedTypes.push(entity.entityType);
309
+ emittedTypes.push(armType(entity.entityType));
295
310
  }
296
311
  }
297
312
  const templateScope = pinnedScope ?? resolveTemplateScope(emittedTypes);
@@ -369,9 +384,9 @@ function serializeToTemplate(
369
384
  const apiVersion = (typeof attrs0?.apiVersion === "string") ? attrs0.apiVersion : getApiVersion(resourceType);
370
385
 
371
386
  const resource: ArmResource = {
372
- type: resourceType,
387
+ type: armType(resourceType),
373
388
  apiVersion,
374
- name: name,
389
+ name: armResourceName(entity, name, entityNames) as string,
375
390
  };
376
391
 
377
392
  // Extract all props
@@ -386,7 +401,7 @@ function serializeToTemplate(
386
401
  // Hoist to resource level
387
402
  (resource as unknown as Record<string, unknown>)[key] = toArmValue(value, entityNames);
388
403
  } else if (key === "name") {
389
- resource.name = toArmValue(value, entityNames) as string;
404
+ // Set above by armResourceName.
390
405
  } else if (key === "properties" && isPlainObject(value)) {
391
406
  // The generated types take ARM's own `properties` object, so
392
407
  // merge it, not nest it (#4183).