@intentius/chant-lexicon-azure 0.124.34 → 0.124.36
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.
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/api/read-client.js +2 -1
- package/dist/arm-type.d.ts +21 -0
- package/dist/arm-type.d.ts.map +1 -0
- package/dist/arm-type.js +31 -0
- package/dist/deep-observe.d.ts +9 -0
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/deep-observe.js +42 -0
- package/dist/graph-edges.d.ts +21 -0
- package/dist/graph-edges.d.ts.map +1 -0
- package/dist/graph-edges.js +71 -0
- package/dist/integrity.json +3 -3
- package/dist/lint/post-synth/arm-refs.d.ts +2 -0
- package/dist/lint/post-synth/arm-refs.d.ts.map +1 -1
- package/dist/lint/post-synth/arm-refs.js +6 -1
- package/dist/manifest.json +1 -1
- package/dist/op/activities/az-apply.d.ts +22 -2
- package/dist/op/activities/az-apply.d.ts.map +1 -1
- package/dist/op/activities/az-apply.js +49 -17
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +3 -0
- package/dist/rules/arm-refs.ts +6 -1
- package/dist/serializer.d.ts +9 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/serializer.js +20 -6
- package/package.json +2 -2
- package/src/api/read-client.ts +2 -1
- package/src/arm-type.test.ts +47 -0
- package/src/arm-type.ts +31 -0
- package/src/deep-observe.test.ts +56 -1
- package/src/deep-observe.ts +41 -0
- package/src/graph-edges.test.ts +94 -0
- package/src/graph-edges.ts +72 -0
- package/src/lint/post-synth/arm-refs.ts +6 -1
- package/src/op/activities/az-apply.test.ts +81 -0
- package/src/op/activities/az-apply.ts +55 -13
- package/src/plugin.ts +3 -0
- package/src/serializer.ts +21 -6
package/dist/serializer.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* - ARM bracket expression references
|
|
10
10
|
* - parameters, outputs sections
|
|
11
11
|
*/
|
|
12
|
+
import { armType } from "./arm-type.js";
|
|
12
13
|
import { isPropertyDeclarable, isResourceDeclarable } from "@intentius/chant/declarable";
|
|
13
14
|
import { ownershipEntries } from "@intentius/chant/ownership";
|
|
14
15
|
import { AZURE_TAG_OWNERSHIP_KEYS } from "./ownership.js";
|
|
@@ -144,7 +145,7 @@ function entityNamed(entityNames, name) {
|
|
|
144
145
|
function armResourceId(entityNames, name) {
|
|
145
146
|
const entity = entityNamed(entityNames, name);
|
|
146
147
|
const { args } = armNameArgs(entity, name);
|
|
147
|
-
return entity ? `resourceId('${entity.entityType}', ${args})` : `resourceId(${args})`;
|
|
148
|
+
return entity ? `resourceId('${armType(entity.entityType)}', ${args})` : `resourceId(${args})`;
|
|
148
149
|
}
|
|
149
150
|
/**
|
|
150
151
|
* An attribute of an export. `id` is `resourceId()`, since `reference()`
|
|
@@ -208,9 +209,22 @@ function isPlainObject(value) {
|
|
|
208
209
|
const proto = Object.getPrototypeOf(value);
|
|
209
210
|
return proto === Object.prototype || proto === null;
|
|
210
211
|
}
|
|
211
|
-
|
|
212
|
+
/** A prop value as the template writes it: references become bracket expressions. */
|
|
213
|
+
export function toArmValue(value, entityNames) {
|
|
212
214
|
return walkValue(value, entityNames, armVisitor(entityNames));
|
|
213
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* The name a resource takes in the template: its `name` prop, or the entity
|
|
218
|
+
* key when it sets none. `findArmResourceRefs` reports references by this
|
|
219
|
+
* name, so the dependsOn inference below and the graph edges
|
|
220
|
+
* (./graph-edges.ts) both resolve a reference through it.
|
|
221
|
+
*/
|
|
222
|
+
export function armResourceName(entity, key, entityNames) {
|
|
223
|
+
const props = isResourceDeclarable(entity) && typeof entity.props === "object" && entity.props !== null
|
|
224
|
+
? entity.props
|
|
225
|
+
: undefined;
|
|
226
|
+
return props?.name !== undefined ? toArmValue(props.name, entityNames) : key;
|
|
227
|
+
}
|
|
214
228
|
/**
|
|
215
229
|
* Serialize entities into an ARM template object.
|
|
216
230
|
*/
|
|
@@ -237,7 +251,7 @@ function serializeToTemplate(entities, outputs, ownership, warnings = []) {
|
|
|
237
251
|
emittedTypes.push("Microsoft.Resources/deployments");
|
|
238
252
|
}
|
|
239
253
|
else if (!isPropertyDeclarable(entity)) {
|
|
240
|
-
emittedTypes.push(entity.entityType);
|
|
254
|
+
emittedTypes.push(armType(entity.entityType));
|
|
241
255
|
}
|
|
242
256
|
}
|
|
243
257
|
const templateScope = pinnedScope ?? resolveTemplateScope(emittedTypes);
|
|
@@ -307,9 +321,9 @@ function serializeToTemplate(entities, outputs, ownership, warnings = []) {
|
|
|
307
321
|
: undefined;
|
|
308
322
|
const apiVersion = (typeof attrs0?.apiVersion === "string") ? attrs0.apiVersion : getApiVersion(resourceType);
|
|
309
323
|
const resource = {
|
|
310
|
-
type: resourceType,
|
|
324
|
+
type: armType(resourceType),
|
|
311
325
|
apiVersion,
|
|
312
|
-
name: name,
|
|
326
|
+
name: armResourceName(entity, name, entityNames),
|
|
313
327
|
};
|
|
314
328
|
// Extract all props
|
|
315
329
|
if (isResourceDeclarable(entity) && typeof entity.props === "object" && entity.props !== null) {
|
|
@@ -323,7 +337,7 @@ function serializeToTemplate(entities, outputs, ownership, warnings = []) {
|
|
|
323
337
|
resource[key] = toArmValue(value, entityNames);
|
|
324
338
|
}
|
|
325
339
|
else if (key === "name") {
|
|
326
|
-
|
|
340
|
+
// Set above by armResourceName.
|
|
327
341
|
}
|
|
328
342
|
else if (key === "properties" && isPlainObject(value)) {
|
|
329
343
|
// The generated types take ARM's own `properties` object, so
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-azure",
|
|
3
|
-
"version": "0.124.
|
|
3
|
+
"version": "0.124.36",
|
|
4
4
|
"description": "Azure lexicon for chant — declarative IaC in TypeScript",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -74,6 +74,6 @@
|
|
|
74
74
|
"typescript": "^5.9.3"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
|
-
"@intentius/chant": "^0.124.
|
|
77
|
+
"@intentius/chant": "^0.124.36"
|
|
78
78
|
}
|
|
79
79
|
}
|
package/src/api/read-client.ts
CHANGED
|
@@ -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
|
|
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
|
+
});
|
package/src/arm-type.ts
ADDED
|
@@ -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
|
+
}
|
package/src/deep-observe.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/deep-observe.ts
CHANGED
|
@@ -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.
|
|
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
|
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type ArmResource,
|
|
21
21
|
type AzHttp,
|
|
22
22
|
providerApiVersion,
|
|
23
|
+
rememberDeployed,
|
|
23
24
|
} from "./az-apply";
|
|
24
25
|
import { lookupApiVersion } from "../../serializer";
|
|
25
26
|
|
|
@@ -69,6 +70,54 @@ describe("evalArmString — reference() (#707)", () => {
|
|
|
69
70
|
});
|
|
70
71
|
});
|
|
71
72
|
|
|
73
|
+
describe("evalArmString — reference() by resource id (#4243)", () => {
|
|
74
|
+
test("reference(resourceId(...)).customerId resolves the resource applied by name", async () => {
|
|
75
|
+
const c = ctx();
|
|
76
|
+
rememberDeployed(c, "Microsoft.OperationalInsights/workspaces", "logs", { properties: { customerId: "cust-1" } });
|
|
77
|
+
expect(await evalArmString("[reference(resourceId('Microsoft.OperationalInsights/workspaces', 'logs')).customerId]", c)).toBe("cust-1");
|
|
78
|
+
expect(await evalArmString("[reference('logs').customerId]", c)).toBe("cust-1");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("a child type, a full id in another case, and 'Full'", async () => {
|
|
82
|
+
const c = ctx();
|
|
83
|
+
rememberDeployed(c, "Microsoft.Network/virtualNetworks/subnets", "vnet/apps", {
|
|
84
|
+
id: "/subscriptions/sub-1/resourceGroups/chant-rg/providers/Microsoft.Network/virtualNetworks/vnet/subnets/apps",
|
|
85
|
+
properties: { addressPrefix: "10.0.1.0/24" },
|
|
86
|
+
});
|
|
87
|
+
expect(await evalArmString("[reference(resourceId('Microsoft.Network/virtualNetworks/subnets', 'vnet', 'apps')).addressPrefix]", c)).toBe("10.0.1.0/24");
|
|
88
|
+
expect(
|
|
89
|
+
await evalArmString("[reference('/SUBSCRIPTIONS/sub-1/resourceGroups/chant-rg/providers/Microsoft.Network/virtualNetworks/vnet/subnets/apps').addressPrefix]", c),
|
|
90
|
+
).toBe("10.0.1.0/24");
|
|
91
|
+
expect(await evalArmString("[reference(resourceId('Microsoft.Network/virtualNetworks/subnets', 'vnet', 'apps'), '2023-04-01', 'Full').properties.addressPrefix]", c)).toBe("10.0.1.0/24");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("azApply: a later resource reads an earlier one through reference(resourceId(...))", async () => {
|
|
95
|
+
const path = `/tmp/chant-4243-${process.pid}.json`;
|
|
96
|
+
writeFileSync(path, JSON.stringify({
|
|
97
|
+
resources: [
|
|
98
|
+
{ type: "Microsoft.OperationalInsights/workspaces", apiVersion: "2022-10-01", name: "logs", location: "eastus", properties: {} },
|
|
99
|
+
{
|
|
100
|
+
type: "Microsoft.App/managedEnvironments", apiVersion: "2024-03-01", name: "env", location: "eastus",
|
|
101
|
+
dependsOn: ["[resourceId('Microsoft.OperationalInsights/workspaces', 'logs')]"],
|
|
102
|
+
properties: { appLogsConfiguration: { logAnalyticsConfiguration: { customerId: "[reference(resourceId('Microsoft.OperationalInsights/workspaces', 'logs')).customerId]" } } },
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
}));
|
|
106
|
+
const bodies: Record<string, unknown> = {};
|
|
107
|
+
const http: AzHttp = async (method, url, body) => {
|
|
108
|
+
if (method === "PUT" && url.includes("/workspaces/logs")) return { status: 200, text: JSON.stringify({ properties: { customerId: "cust-1" } }) };
|
|
109
|
+
if (method === "PUT" && url.includes("/managedEnvironments/env")) bodies.env = body;
|
|
110
|
+
return { status: 200, text: "{}" };
|
|
111
|
+
};
|
|
112
|
+
try {
|
|
113
|
+
await azApply({ templatePath: path, resourceGroup: "chant-rg", subscriptionId: "sub-1", endpoint: "http://x" }, undefined, http);
|
|
114
|
+
} finally {
|
|
115
|
+
unlinkSync(path);
|
|
116
|
+
}
|
|
117
|
+
expect((bodies.env as { properties: { appLogsConfiguration: { logAnalyticsConfiguration: { customerId: string } } } }).properties.appLogsConfiguration.logAnalyticsConfiguration.customerId).toBe("cust-1");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
72
121
|
describe("evalArmString — listKeys() (#707)", () => {
|
|
73
122
|
test("listKeys(resourceId(...), v).keys[0].value → POSTs the key action and indexes", async () => {
|
|
74
123
|
const calls: string[] = [];
|
|
@@ -149,6 +198,38 @@ describe("armResourceUrl / armResourceBody (#707)", () => {
|
|
|
149
198
|
tags: { "managed-by": "chant" },
|
|
150
199
|
});
|
|
151
200
|
});
|
|
201
|
+
|
|
202
|
+
test("body keeps identity, zones, plan, extendedLocation and managedBy, keys evaluated (#4231)", async () => {
|
|
203
|
+
const app: ArmResource = {
|
|
204
|
+
type: "Microsoft.App/containerApps",
|
|
205
|
+
apiVersion: "2024-03-01",
|
|
206
|
+
name: "web",
|
|
207
|
+
location: "[resourceGroup().location]",
|
|
208
|
+
identity: {
|
|
209
|
+
type: "UserAssigned",
|
|
210
|
+
userAssignedIdentities: { "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', 'appId')]": {} },
|
|
211
|
+
},
|
|
212
|
+
zones: ["1"],
|
|
213
|
+
plan: { name: "p1", publisher: "pub", product: "prod" },
|
|
214
|
+
extendedLocation: { type: "EdgeZone", name: "edge1" },
|
|
215
|
+
managedBy: "[resourceId('Microsoft.Web/sites', 'owner')]",
|
|
216
|
+
properties: {},
|
|
217
|
+
};
|
|
218
|
+
expect(await armResourceBody(app, ctx())).toEqual({
|
|
219
|
+
location: "eastus",
|
|
220
|
+
properties: {},
|
|
221
|
+
identity: {
|
|
222
|
+
type: "UserAssigned",
|
|
223
|
+
userAssignedIdentities: {
|
|
224
|
+
"/subscriptions/sub-1/resourceGroups/chant-rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/appId": {},
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
zones: ["1"],
|
|
228
|
+
plan: { name: "p1", publisher: "pub", product: "prod" },
|
|
229
|
+
extendedLocation: { type: "EdgeZone", name: "edge1" },
|
|
230
|
+
managedBy: "/subscriptions/sub-1/resourceGroups/chant-rg/providers/Microsoft.Web/sites/owner",
|
|
231
|
+
});
|
|
232
|
+
});
|
|
152
233
|
});
|
|
153
234
|
|
|
154
235
|
describe("azApply flow (#707)", () => {
|