@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.
@@ -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
 
@@ -64,6 +70,29 @@ export interface ArmEvalCtx {
64
70
  signal?: AbortSignal;
65
71
  }
66
72
 
73
+ /** The ARM resource id of a resource in the context's group. */
74
+ export function armResourceIdOf(ctx: Pick<ArmEvalCtx, "subscriptionId" | "resourceGroup">, type: string, name: string): string {
75
+ return `/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}/providers/${armResourcePath(type, name)}`;
76
+ }
77
+
78
+ /**
79
+ * Record an applied resource's response so `reference()` resolves it by name
80
+ * and by resource id (#4243). Ids are case-insensitive in ARM, so they are
81
+ * stored lowercased, under both the id chant derives and the one the
82
+ * response reports.
83
+ */
84
+ export function rememberDeployed(ctx: ArmEvalCtx, type: string, name: string, body: unknown): void {
85
+ ctx.deployed.set(name, body);
86
+ ctx.deployed.set(armResourceIdOf(ctx, type, name).toLowerCase(), body);
87
+ const id = (body as { id?: unknown } | null)?.id;
88
+ if (typeof id === "string") ctx.deployed.set(id.toLowerCase(), body);
89
+ }
90
+
91
+ /** An applied resource by the name or the resource id a `reference()` names. */
92
+ export function lookupDeployed(ctx: ArmEvalCtx, ref: string): unknown {
93
+ return ctx.deployed.get(ref) ?? (ref.startsWith("/") ? ctx.deployed.get(ref.toLowerCase()) : undefined);
94
+ }
95
+
67
96
  /** Evaluate an ARM expression string (`"[...]"`); a plain string is returned as-is. */
68
97
  export async function evalArmString(s: string, ctx: ArmEvalCtx): Promise<unknown> {
69
98
  if (!(s.startsWith("[") && s.endsWith("]"))) return s;
@@ -71,13 +100,17 @@ export async function evalArmString(s: string, ctx: ArmEvalCtx): Promise<unknown
71
100
  return new ArmExpr(s.slice(1, -1), ctx).parse();
72
101
  }
73
102
 
74
- /** Recursively evaluate every string in a value against the ARM context. */
103
+ /**
104
+ * Recursively evaluate every string in a value against the ARM context,
105
+ * object keys included: ARM evaluates a property name too, and
106
+ * `identity.userAssignedIdentities` is keyed by `[resourceId(...)]` (#4231).
107
+ */
75
108
  export async function evalArm(value: unknown, ctx: ArmEvalCtx): Promise<unknown> {
76
109
  if (typeof value === "string") return evalArmString(value, ctx);
77
110
  if (Array.isArray(value)) return Promise.all(value.map((v) => evalArm(v, ctx)));
78
111
  if (value && typeof value === "object") {
79
112
  const out: Record<string, unknown> = {};
80
- for (const [k, v] of Object.entries(value)) out[k] = await evalArm(v, ctx);
113
+ for (const [k, v] of Object.entries(value)) out[String(await evalArmString(k, ctx))] = await evalArm(v, ctx);
81
114
  return out;
82
115
  }
83
116
  return value;
@@ -151,10 +184,13 @@ class ArmExpr {
151
184
  return { subscriptionId: this.ctx.subscriptionId, id: `/subscriptions/${this.ctx.subscriptionId}` };
152
185
  case "resourceId":
153
186
  // 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("/")}`;
187
+ return armResourceIdOf(this.ctx, String(args[0]), args.slice(1).map(String).join("/"));
155
188
  case "reference": {
156
- // reference('name') → the runtime `properties` of an already-applied resource.
157
- const dep = this.ctx.deployed.get(String(args[0]));
189
+ // reference('name' | resourceId(...)[, apiVersion[, 'Full']]) → the
190
+ // runtime `properties` of an already-applied resource, or its whole
191
+ // body with 'Full' (#4243).
192
+ const dep = lookupDeployed(this.ctx, String(args[0]));
193
+ if (String(args[2] ?? "").toLowerCase() === "full") return dep;
158
194
  return (dep as { properties?: unknown } | undefined)?.properties ?? dep;
159
195
  }
160
196
  case "listKeys": {
@@ -260,17 +296,23 @@ export function orderArmResources(resources: ArmResource[]): ArmResource[] {
260
296
  /** The ARM resource-ID PUT URL for a resource (name expression evaluated). */
261
297
  export async function armResourceUrl(resource: ArmResource, ctx: ArmEvalCtx): Promise<string> {
262
298
  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}`;
299
+ return `${ctx.base}/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}/providers/${armResourcePath(resource.type, String(name))}?api-version=${resource.apiVersion}`;
264
300
  }
265
301
 
266
- /** The ARM resource PUT body (location/properties/sku/kind/tags), expressions evaluated. */
302
+ /**
303
+ * The top-level ARM resource fields a PUT takes beside `location` (#4231). A
304
+ * field missing here is dropped from the body, which is how a declared
305
+ * `identity` came back absent.
306
+ */
307
+ const ARM_BODY_FIELDS = ["properties", "sku", "kind", "tags", "identity", "zones", "plan", "extendedLocation", "managedBy"] as const;
308
+
309
+ /** The ARM resource PUT body: `location` and {@link ARM_BODY_FIELDS}, expressions evaluated. */
267
310
  export async function armResourceBody(resource: ArmResource, ctx: ArmEvalCtx): Promise<Record<string, unknown>> {
268
311
  const body: Record<string, unknown> = {};
269
312
  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);
313
+ for (const field of ARM_BODY_FIELDS) {
314
+ if (resource[field] !== undefined) body[field] = await evalArm(resource[field], ctx);
315
+ }
274
316
  return body;
275
317
  }
276
318
 
@@ -346,7 +388,7 @@ export async function azApply(
346
388
  }
347
389
  // Capture the applied resource so later reference()/dependents resolve.
348
390
  try {
349
- ctx.deployed.set(name, JSON.parse(res.text));
391
+ rememberDeployed(ctx, resource.type, name, JSON.parse(res.text));
350
392
  } catch {
351
393
  // non-JSON response — reference() to this resource resolves to undefined
352
394
  }
@@ -454,7 +496,7 @@ export async function deleteArmResource(
454
496
  http: AzHttp = defaultHttp,
455
497
  signal?: AbortSignal,
456
498
  ): Promise<{ type: string; name: string; deleted: boolean }> {
457
- const url = `${ctx.base}/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}/providers/${type}/${name}?api-version=${apiVersion}`;
499
+ const url = `${ctx.base}/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}/providers/${armResourcePath(type, name)}?api-version=${apiVersion}`;
458
500
  const res = await http("DELETE", url, undefined, signal);
459
501
  if (res.status === 404) return { type, name, deleted: false };
460
502
  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).