@intentius/chant-lexicon-azure 0.124.32 → 0.124.33

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.
Files changed (40) hide show
  1. package/dist/codegen/generate-typescript.js +1 -1
  2. package/dist/codegen/generate.js +1 -1
  3. package/dist/generated/index.d.ts.map +1 -1
  4. package/dist/generated/index.js +1 -1
  5. package/dist/health.d.ts +42 -0
  6. package/dist/health.d.ts.map +1 -0
  7. package/dist/health.js +137 -0
  8. package/dist/integrity.json +5 -5
  9. package/dist/lint/post-synth/arm-refs.d.ts +1 -0
  10. package/dist/lint/post-synth/arm-refs.d.ts.map +1 -1
  11. package/dist/lint/post-synth/arm-refs.js +5 -3
  12. package/dist/manifest.json +1 -1
  13. package/dist/meta.json +6 -1
  14. package/dist/okf/types/containerApps.md +5 -0
  15. package/dist/plugin.d.ts.map +1 -1
  16. package/dist/plugin.js +6 -0
  17. package/dist/rules/arm-refs.ts +5 -3
  18. package/dist/serializer.d.ts +7 -0
  19. package/dist/serializer.d.ts.map +1 -1
  20. package/dist/serializer.js +115 -29
  21. package/dist/spec/parse.d.ts.map +1 -1
  22. package/dist/spec/parse.js +10 -0
  23. package/dist/types/index.d.ts +5 -0
  24. package/package.json +2 -2
  25. package/src/attributes.test.ts +17 -0
  26. package/src/codegen/generate-typescript.ts +1 -1
  27. package/src/codegen/generate.ts +1 -1
  28. package/src/generated/index.d.ts +5 -0
  29. package/src/generated/index.ts +1 -1
  30. package/src/generated/lexicon-azure.json +6 -1
  31. package/src/health.test.ts +66 -0
  32. package/src/health.ts +162 -0
  33. package/src/lint/post-synth/arm-refs.ts +5 -3
  34. package/src/lint/post-synth/post-synth.test.ts +1 -1
  35. package/src/plugin.ts +8 -0
  36. package/src/serializer-names.test.ts +83 -0
  37. package/src/serializer-outputs.test.ts +65 -0
  38. package/src/serializer.ts +110 -28
  39. package/src/spec/parse.test.ts +15 -0
  40. package/src/spec/parse.ts +10 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-azure",
3
- "version": "0.124.32",
3
+ "version": "0.124.33",
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.32"
77
+ "@intentius/chant": "^0.124.33"
78
78
  }
79
79
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * A Container App's hostname is referenceable (#4179), so DNS in another
3
+ * lexicon can point at it: `configuration.ingress.fqdn` is read through
4
+ * `configuration_ingress_fqdn`.
5
+ */
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import { AttrRef } from "@intentius/chant";
9
+ import { containerApps } from "./index";
10
+
11
+ describe("Container App attributes", () => {
12
+ test("configuration.ingress.fqdn and latestRevisionFqdn are AttrRefs", () => {
13
+ const app = new containerApps({ name: "web", location: "eastus" } as never) as unknown as Record<string, unknown>;
14
+ expect((app.configuration_ingress_fqdn as AttrRef).attribute).toBe("configuration.ingress.fqdn");
15
+ expect(app.latestRevisionFqdn).toBeInstanceOf(AttrRef);
16
+ });
17
+ });
@@ -78,7 +78,7 @@ export function generateTypeScriptDeclarations(
78
78
  description: p.description,
79
79
  }));
80
80
  const dtsAttrs: DtsAttribute[] = r.resource.attributes.map((a) => ({
81
- name: a.name,
81
+ name: a.name.replace(/\./g, "_"),
82
82
  type: a.tsType,
83
83
  }));
84
84
 
@@ -95,7 +95,7 @@ function generateRuntimeIndex(
95
95
  // Build attrs map
96
96
  const attrs: Record<string, string> = {};
97
97
  for (const a of r.resource.attributes) {
98
- attrs[a.name] = a.name;
98
+ attrs[a.name.replace(/\./g, "_")] = a.name;
99
99
  }
100
100
 
101
101
  resourceEntries.push({ tsName, resourceType: armType, attrs });
@@ -4232,7 +4232,12 @@ export declare class containerApps {
4232
4232
  /** Resource tags. */
4233
4233
  tags?: Record<string, unknown>;
4234
4234
  }, attributes?: ArmResourceAttributes);
4235
+ readonly configuration_ingress_fqdn: string;
4235
4236
  readonly id: string;
4237
+ readonly latestRevisionFqdn: string;
4238
+ readonly latestRevisionName: string;
4239
+ readonly outboundIpAddresses: string[];
4240
+ readonly provisioningState: string;
4236
4241
  }
4237
4242
 
4238
4243
  export declare class containerApps_authConfigs {
@@ -314,7 +314,7 @@ export const connections_flows = /* @__PURE__ */ createResource("Microsoft.Azure
314
314
  export const connectors = /* @__PURE__ */ createResource("Microsoft.Workloads/connectors", "azure", {"id":"id"});
315
315
  export const connectors_acssBackups = /* @__PURE__ */ createResource("Microsoft.Workloads/connectors_acssBackups", "azure", {"id":"id"});
316
316
  export const Consumptionbudgets = /* @__PURE__ */ createResource("Microsoft.Consumption/budgets", "azure", {"id":"id"});
317
- export const containerApps = /* @__PURE__ */ createResource("Microsoft.App/containerApps", "azure", {"id":"id"});
317
+ export const containerApps = /* @__PURE__ */ createResource("Microsoft.App/containerApps", "azure", {"id":"id","configuration_ingress_fqdn":"configuration.ingress.fqdn","latestRevisionFqdn":"latestRevisionFqdn","latestRevisionName":"latestRevisionName","outboundIpAddresses":"outboundIpAddresses","provisioningState":"provisioningState"});
318
318
  export const containerApps_authConfigs = /* @__PURE__ */ createResource("Microsoft.App/containerApps_authConfigs", "azure", {"id":"id"});
319
319
  export const containerApps_resiliencyPolicies = /* @__PURE__ */ createResource("Microsoft.App/containerApps_resiliencyPolicies", "azure", {"id":"id"});
320
320
  export const containerApps_sourcecontrols = /* @__PURE__ */ createResource("Microsoft.App/containerApps_sourcecontrols", "azure", {"id":"id"});
@@ -33235,7 +33235,12 @@
33235
33235
  "lexicon": "azure",
33236
33236
  "apiVersion": "2025-02-02-preview",
33237
33237
  "attrs": {
33238
- "id": "id"
33238
+ "id": "id",
33239
+ "configuration_ingress_fqdn": "configuration.ingress.fqdn",
33240
+ "latestRevisionFqdn": "latestRevisionFqdn",
33241
+ "latestRevisionName": "latestRevisionName",
33242
+ "outboundIpAddresses": "outboundIpAddresses",
33243
+ "provisioningState": "provisioningState"
33239
33244
  },
33240
33245
  "propertyConstraints": {
33241
33246
  "kind": {
@@ -0,0 +1,66 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
2
+ import { appRunningStatusEntry, containerAppEntry, observeHealthAzure } from "./health";
3
+ import type { AzHttp } from "./op/activities/az-apply";
4
+
5
+ const AT = "2026-10-20T21:00:00.000Z";
6
+ const now = () => new Date(AT);
7
+ const entities = new Map([["web", { entityType: "Microsoft.App/containerApps", props: { name: "web-app" } }]]);
8
+
9
+ describe("azure health (#4177)", () => {
10
+ let saved: string | undefined;
11
+ beforeEach(() => {
12
+ saved = process.env.AZURE_ENDPOINT_URL;
13
+ process.env.AZURE_ENDPOINT_URL = "http://localhost:4577";
14
+ });
15
+ afterEach(() => {
16
+ if (saved === undefined) delete process.env.AZURE_ENDPOINT_URL;
17
+ else process.env.AZURE_ENDPOINT_URL = saved;
18
+ });
19
+
20
+ test("judges the active revisions", () => {
21
+ const rev = (runningState: string, extra: Record<string, unknown> = {}) => ({
22
+ name: "web-app--r1",
23
+ properties: { active: true, runningState, replicas: 2, trafficWeight: 100, ...extra },
24
+ });
25
+ expect(containerAppEntry([rev("Running")], AT)).toMatchObject({ state: "healthy", detail: "web-app--r1 Running, 2 replica(s), 100% traffic" });
26
+ expect(containerAppEntry([rev("Running", { healthState: "Unhealthy" })], AT).state).toBe("unhealthy");
27
+ expect(containerAppEntry([rev("Failed")], AT).state).toBe("unhealthy");
28
+ expect(containerAppEntry([rev("Processing")], AT).state).toBe("unknown");
29
+ expect(containerAppEntry([{ name: "old", properties: { active: false, runningState: "Running" } }], AT)).toMatchObject({
30
+ state: "unhealthy",
31
+ detail: "no active revision (1 revision(s))",
32
+ });
33
+ });
34
+
35
+ test("the app's runningStatus answers when revisions cannot be listed", () => {
36
+ expect(appRunningStatusEntry("Running", AT).state).toBe("healthy");
37
+ expect(appRunningStatusEntry("Stopped", AT).state).toBe("unhealthy");
38
+ });
39
+
40
+ test("reads the app, then its revisions, at the endpoint override", async () => {
41
+ const urls: string[] = [];
42
+ const http: AzHttp = async (_method, url) => {
43
+ urls.push(url);
44
+ if (url.includes("/revisions")) {
45
+ return { status: 200, text: JSON.stringify({ value: [{ name: "web-app--r1", properties: { active: true, runningState: "Running", replicas: 1 } }] }) };
46
+ }
47
+ return { status: 200, text: JSON.stringify({ id: "/x", properties: {} }) };
48
+ };
49
+ const r = await observeHealthAzure({ environment: "rg", buildOutput: "", entities, http, now });
50
+ expect(r.entities.web).toMatchObject({ state: "healthy", observedAt: AT });
51
+ expect(urls[1]).toBe(
52
+ "http://localhost:4577/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/rg/providers/Microsoft.App/containerApps/web-app/revisions?api-version=2024-03-01",
53
+ );
54
+ });
55
+
56
+ test("a 404 app is absent; an unreadable revisions list with no runningStatus is a hole", async () => {
57
+ const notFound: AzHttp = async () => ({ status: 404, text: JSON.stringify({ error: { code: "ResourceNotFound", message: "gone" } }) });
58
+ expect((await observeHealthAzure({ environment: "rg", buildOutput: "", entities, http: notFound })).absent).toEqual(["web"]);
59
+
60
+ const noRevisions: AzHttp = async (_m, url) =>
61
+ url.includes("/revisions") ? { status: 404, text: "" } : { status: 200, text: JSON.stringify({ id: "/x", properties: {} }) };
62
+ const r = await observeHealthAzure({ environment: "rg", buildOutput: "", entities, http: noRevisions });
63
+ expect(r.entities).toEqual({});
64
+ expect(r.unobserved.web?.reason).toBe("read-failed");
65
+ });
66
+ });
package/src/health.ts ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Runtime health for Azure (#4177): Container Apps.
3
+ *
4
+ * For each declared `Microsoft.App/containerApps`, GET the app (a 404 is
5
+ * absent), then its revisions, and judge the active ones by their running and
6
+ * health state. Same transport and endpoint rule as `describe-resources.ts`, so
7
+ * `AZURE_ENDPOINT_URL` (floci-az) answers.
8
+ *
9
+ * When the revisions list cannot be read but the app carries a
10
+ * `runningStatus` (newer API versions do), that answers instead, and the detail
11
+ * says so.
12
+ */
13
+
14
+ import type { HealthEntry, HealthReadOptions, HealthReadResult, UnobservedEntity } from "@intentius/chant/lexicon";
15
+ import { armResourceReadUrl, AzureReadError, getResource, isNotFound, type AzureReadClientOptions } from "./api/read-client";
16
+ import { classifyArmFailure } from "./describe-resources";
17
+ import type { AzHttp } from "./op/activities/az-apply";
18
+
19
+ export const AZURE_HEALTH_TYPES = ["Microsoft.App/containerApps"] as const;
20
+
21
+ /** The Container Apps API version whose revisions carry `runningState` and `healthState`. */
22
+ const CONTAINER_APPS_API_VERSION = "2024-03-01";
23
+
24
+ export interface AzureHealthOptions extends HealthReadOptions {
25
+ http?: AzHttp;
26
+ now?: () => Date;
27
+ }
28
+
29
+ /** The fields of one revision that decide health. */
30
+ export interface RevisionState {
31
+ name?: string;
32
+ properties?: {
33
+ active?: boolean;
34
+ runningState?: string;
35
+ healthState?: string;
36
+ replicas?: number;
37
+ trafficWeight?: number;
38
+ lastActiveTime?: string;
39
+ createdTime?: string;
40
+ };
41
+ }
42
+
43
+ const RUNNING = new Set(["running", "runningatmaxscale"]);
44
+ const DOWN = new Set(["failed", "degraded", "stopped"]);
45
+
46
+ /**
47
+ * The active revisions' verdict: unhealthy if none is active or any active one
48
+ * failed, degraded, stopped or reports `Unhealthy`; healthy if every active one
49
+ * is running and none is unhealthy; otherwise unknown (still provisioning).
50
+ */
51
+ export function containerAppEntry(revisions: RevisionState[], observedAt: string): HealthEntry {
52
+ const type = "Microsoft.App/containerApps";
53
+ const active = revisions.filter((r) => r.properties?.active === true);
54
+ if (active.length === 0) {
55
+ return { type, state: "unhealthy", detail: `no active revision (${revisions.length} revision(s))`, observedAt };
56
+ }
57
+ const describe = (r: RevisionState): string => {
58
+ const p = r.properties ?? {};
59
+ const bits = [p.runningState ?? "no running state"];
60
+ if (p.healthState && p.healthState !== "None") bits.push(p.healthState);
61
+ if (typeof p.replicas === "number") bits.push(`${p.replicas} replica(s)`);
62
+ if (typeof p.trafficWeight === "number") bits.push(`${p.trafficWeight}% traffic`);
63
+ return `${r.name ?? "revision"} ${bits.join(", ")}`;
64
+ };
65
+ const detail = active.map(describe).join("; ");
66
+ const running = (r: RevisionState) => RUNNING.has((r.properties?.runningState ?? "").toLowerCase());
67
+ const down = (r: RevisionState) =>
68
+ DOWN.has((r.properties?.runningState ?? "").toLowerCase()) || r.properties?.healthState?.toLowerCase() === "unhealthy";
69
+ const checkedAt = active
70
+ .map((r) => r.properties?.lastActiveTime ?? r.properties?.createdTime)
71
+ .filter((t): t is string => typeof t === "string")
72
+ .sort()
73
+ .pop();
74
+ const state = active.some(down) ? "unhealthy" : active.every(running) ? "healthy" : "unknown";
75
+ return { type, state, detail, observedAt, ...(checkedAt ? { checkedAt } : {}) };
76
+ }
77
+
78
+ /** An app's own `runningStatus`, for when its revisions cannot be listed. */
79
+ export function appRunningStatusEntry(runningStatus: string, observedAt: string): HealthEntry {
80
+ const lower = runningStatus.toLowerCase();
81
+ const state = RUNNING.has(lower) ? "healthy" : DOWN.has(lower) ? "unhealthy" : "unknown";
82
+ return {
83
+ type: "Microsoft.App/containerApps",
84
+ state,
85
+ detail: `app runningStatus ${runningStatus} (revisions not readable)`,
86
+ observedAt,
87
+ };
88
+ }
89
+
90
+ function hole(err: unknown): UnobservedEntity {
91
+ const { reason, detail } = classifyArmFailure(err);
92
+ return { type: "Microsoft.App/containerApps", reason, detail };
93
+ }
94
+
95
+ async function listRevisions(client: AzureReadClientOptions, name: string): Promise<RevisionState[]> {
96
+ const http = client.http!;
97
+ const url = armResourceReadUrl(client, "Microsoft.App/containerApps", `${name}/revisions`, CONTAINER_APPS_API_VERSION);
98
+ const res = await http("GET", url, undefined, client.signal);
99
+ if (res.status >= 300) throw new AzureReadError(`revisions read failed with HTTP ${res.status}`, res.status);
100
+ let body: unknown;
101
+ try {
102
+ body = JSON.parse(res.text);
103
+ } catch {
104
+ throw new AzureReadError("unparseable revisions list", res.status);
105
+ }
106
+ const value = body && typeof body === "object" ? (body as { value?: unknown }).value : undefined;
107
+ if (!Array.isArray(value)) throw new AzureReadError("unparseable revisions list", res.status);
108
+ return value as RevisionState[];
109
+ }
110
+
111
+ const defaultHttp: AzHttp = async (method, url, body, signal) => {
112
+ const res = await fetch(url, {
113
+ method,
114
+ headers: body === undefined ? undefined : { "content-type": "application/json" },
115
+ body: body === undefined ? undefined : JSON.stringify(body),
116
+ signal,
117
+ });
118
+ return { status: res.status, text: await res.text() };
119
+ };
120
+
121
+ export async function observeHealthAzure(options: AzureHealthOptions): Promise<HealthReadResult> {
122
+ const observedAt = (options.now?.() ?? new Date()).toISOString();
123
+ const client: AzureReadClientOptions = {
124
+ resourceGroup: options.environment,
125
+ ...(process.env.AZURE_ENDPOINT_URL ? { endpoint: process.env.AZURE_ENDPOINT_URL } : {}),
126
+ ...(process.env.AZURE_SUBSCRIPTION_ID ? { subscriptionId: process.env.AZURE_SUBSCRIPTION_ID } : {}),
127
+ http: options.http ?? defaultHttp,
128
+ };
129
+ const entities: Record<string, HealthEntry> = {};
130
+ const unobserved: Record<string, UnobservedEntity> = {};
131
+ const absent: string[] = [];
132
+
133
+ await Promise.all(
134
+ [...options.entities].map(async ([entityName, { entityType, props }]) => {
135
+ const name = props.name as string | undefined;
136
+ if (!name) {
137
+ unobserved[entityName] = { type: entityType, reason: "read-failed", detail: "declared entity has no name to query by" };
138
+ return;
139
+ }
140
+ let app: Awaited<ReturnType<typeof getResource>>;
141
+ try {
142
+ app = await getResource(client, entityType, name, CONTAINER_APPS_API_VERSION);
143
+ } catch (err) {
144
+ if (isNotFound(err)) absent.push(entityName);
145
+ else unobserved[entityName] = hole(err);
146
+ return;
147
+ }
148
+ try {
149
+ entities[entityName] = containerAppEntry(await listRevisions(client, name), observedAt);
150
+ } catch (err) {
151
+ const runningStatus = app.properties?.runningStatus;
152
+ if (typeof runningStatus === "string" && runningStatus.length > 0) {
153
+ entities[entityName] = appRunningStatusEntry(runningStatus, observedAt);
154
+ } else {
155
+ unobserved[entityName] = hole(err);
156
+ }
157
+ }
158
+ }),
159
+ );
160
+
161
+ return { entities, unobserved, absent };
162
+ }
@@ -76,17 +76,19 @@ export function isBracketExpression(value: unknown): boolean {
76
76
  *
77
77
  * Handles:
78
78
  * - [resourceId('Type', 'name')] -> extracts 'name'
79
+ * - [resourceId('Type', 'a', 'b')] -> extracts 'a/b'
79
80
  * - [reference('name')] -> extracts 'name'
80
81
  * - [reference('name').prop] -> extracts 'name'
81
82
  */
82
83
  export function extractRefsFromExpression(expr: string): string[] {
83
84
  const refs: string[] = [];
84
85
 
85
- // Match resourceId('...', 'name') — extract the last quoted argument (the resource name)
86
- const resourceIdPattern = /resourceId\s*\([^)]*,\s*'([^']+)'\s*\)/g;
86
+ // Match resourceId('Type', 'name', ...): the name is every quoted argument
87
+ // after the type, joined with "/" for a child resource ('vnet', 'subnet').
88
+ const resourceIdPattern = /resourceId\s*\(\s*'[^']+'((?:\s*,\s*'[^']+')+)\s*\)/g;
87
89
  let match: RegExpExecArray | null;
88
90
  while ((match = resourceIdPattern.exec(expr)) !== null) {
89
- refs.push(match[1]);
91
+ refs.push([...match[1].matchAll(/'([^']+)'/g)].map((m) => m[1]).join("/"));
90
92
  }
91
93
 
92
94
  // Match reference('name') — extract the name
@@ -33,7 +33,7 @@ describe("findArmResourceRefs", () => {
33
33
  const refs = findArmResourceRefs({
34
34
  subnet: "[resourceId('Microsoft.Network/virtualNetworks/subnets', 'myVnet', 'mySubnet')]",
35
35
  });
36
- expect(refs.has("mySubnet")).toBe(true);
36
+ expect(refs.has("myVnet/mySubnet")).toBe(true);
37
37
  });
38
38
 
39
39
  test("extracts reference targets", () => {
package/src/plugin.ts CHANGED
@@ -486,6 +486,14 @@ export const tags = defaultTags([
486
486
  /** The noise rules the deep pass applies to both the live and declared trees. */
487
487
  deepNormalizationHooks: azureDeepNormalizationHooks,
488
488
 
489
+ /** Runtime health (#4177): Container Apps. Implementation in ./health.ts. */
490
+ healthTypes: ["Microsoft.App/containerApps"],
491
+
492
+ async observeHealth(options) {
493
+ const { observeHealthAzure } = await import("./health");
494
+ return observeHealthAzure(options);
495
+ },
496
+
489
497
  async exportResources(options) {
490
498
  const { exportResources } = await import("./export-resources");
491
499
  return exportResources(options);
@@ -0,0 +1,83 @@
1
+ /**
2
+ * References use the ARM name, not the chant export name, and an explicit
3
+ * `properties` object is merged, not nested (#4183).
4
+ */
5
+
6
+ import { describe, expect, it } from "vitest";
7
+ import { AttrRef } from "@intentius/chant";
8
+ import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
9
+ import { azureSerializer } from "./serializer";
10
+
11
+ function makeEntity(entityType: string, props: Record<string, unknown> = {}): any {
12
+ return { [DECLARABLE_MARKER]: true, lexicon: "azure", entityType, kind: "resource", props };
13
+ }
14
+
15
+ function ref(parent: object, logicalName: string, attribute: string): AttrRef {
16
+ const r = new AttrRef(parent, attribute);
17
+ r._setLogicalName(logicalName);
18
+ return r;
19
+ }
20
+
21
+ function serialize(entities: Map<string, any>): any {
22
+ return JSON.parse(azureSerializer.serialize(entities) as string);
23
+ }
24
+
25
+ describe("azure references by ARM name", () => {
26
+ it("reads reference() and resourceId() by the name prop, and infers dependsOn from them", () => {
27
+ const env = makeEntity("Microsoft.App/managedEnvironments", { name: "prod-env", location: "eastus" });
28
+ const entities = new Map<string, any>([["env", env]]);
29
+ entities.set("app", makeEntity("Microsoft.App/containerApps", {
30
+ name: "web",
31
+ location: "eastus",
32
+ environmentId: ref(env, "env", "id"),
33
+ envName: ref(env, "env", "defaultDomain"),
34
+ }));
35
+
36
+ const app = serialize(entities).resources.find((r: any) => r.name === "web");
37
+ expect(app.properties.environmentId).toBe("[resourceId('Microsoft.App/managedEnvironments', 'prod-env')]");
38
+ expect(app.properties.envName).toBe("[reference('prod-env').defaultDomain]");
39
+ expect(app.dependsOn).toEqual(["[resourceId('Microsoft.App/managedEnvironments', 'prod-env')]"]);
40
+ });
41
+
42
+ it("falls back to the export name when there is no name prop", () => {
43
+ const vnet = makeEntity("Microsoft.Network/virtualNetworks", { location: "eastus" });
44
+ const entities = new Map<string, any>([["vnet", vnet]]);
45
+ entities.set("nic", makeEntity("Microsoft.Network/networkInterfaces", { name: "nic", vnetId: ref(vnet, "vnet", "id") }));
46
+
47
+ const nic = serialize(entities).resources.find((r: any) => r.name === "nic");
48
+ expect(nic.properties.vnetId).toBe("[resourceId('Microsoft.Network/virtualNetworks', 'vnet')]");
49
+ });
50
+
51
+ it("splits a child resource's name into resourceId segments", () => {
52
+ const subnet = makeEntity("Microsoft.Network/virtualNetworks/subnets", { name: "vnet/apps" });
53
+ const entities = new Map<string, any>([["subnet", subnet]]);
54
+ entities.set("nic", makeEntity("Microsoft.Network/networkInterfaces", { name: "nic", subnetId: ref(subnet, "subnet", "id") }));
55
+
56
+ const nic = serialize(entities).resources.find((r: any) => r.name === "nic");
57
+ expect(nic.properties.subnetId).toBe("[resourceId('Microsoft.Network/virtualNetworks/subnets', 'vnet', 'apps')]");
58
+ expect(nic.dependsOn).toEqual(["[resourceId('Microsoft.Network/virtualNetworks/subnets', 'vnet', 'apps')]"]);
59
+ });
60
+
61
+ it("passes a bracket-expression name through unwrapped", () => {
62
+ const sa = makeEntity("Microsoft.Storage/storageAccounts", { name: "[parameters('storageName')]" });
63
+ const entities = new Map<string, any>([["sa", sa]]);
64
+ entities.set("x", makeEntity("Microsoft.Web/sites", { name: "site", blob: ref(sa, "sa", "primaryEndpoints") }));
65
+
66
+ const site = serialize(entities).resources.find((r: any) => r.name === "site");
67
+ expect(site.properties.blob).toBe("[reference(resourceId('Microsoft.Storage/storageAccounts', parameters('storageName'))).primaryEndpoints]");
68
+ });
69
+ });
70
+
71
+ describe("azure properties object", () => {
72
+ it("merges an explicit properties object instead of nesting it", () => {
73
+ const entities = new Map<string, any>([["app", makeEntity("Microsoft.App/containerApps", {
74
+ name: "web",
75
+ location: "eastus",
76
+ properties: { configuration: { ingress: { external: true, targetPort: 8080 } } },
77
+ })]]);
78
+
79
+ const app = serialize(entities).resources[0];
80
+ expect(app.properties.properties).toBeUndefined();
81
+ expect(app.properties.configuration).toEqual({ ingress: { external: true, targetPort: 8080 } });
82
+ });
83
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Outputs azure can't write as a reference are written as values or named in
3
+ * a warning, never as `[reference('').null]` (#4181).
4
+ */
5
+
6
+ import { describe, expect, it } from "vitest";
7
+ import { AttrRef } from "@intentius/chant";
8
+ import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
9
+ import { LexiconOutput } from "@intentius/chant/lexicon-output";
10
+ import { stackOutput } from "@intentius/chant/stack-output";
11
+ import { azureSerializer } from "./serializer";
12
+ import { ResourceIdIntrinsic } from "./intrinsics";
13
+
14
+ function makeEntity(entityType: string, props: Record<string, unknown> = {}): any {
15
+ return { [DECLARABLE_MARKER]: true, lexicon: "azure", entityType, kind: "resource", props };
16
+ }
17
+
18
+ function run(entities: Map<string, any>, outputs: LexiconOutput[]): { template: any; warnings: string[] } {
19
+ const result = azureSerializer.serialize(entities, outputs);
20
+ if (typeof result === "string") return { template: JSON.parse(result), warnings: [] };
21
+ return { template: JSON.parse(result.primary), warnings: result.warnings ?? [] };
22
+ }
23
+
24
+ describe("azure outputs", () => {
25
+ it("writes an attribute output by the resource's ARM name", () => {
26
+ const app = makeEntity("Microsoft.App/containerApps", { name: "web" });
27
+ const out = new LexiconOutput(new AttrRef(app, "latestRevisionFqdn"), "WebFqdn");
28
+ out._setSourceEntity("app");
29
+ const { template, warnings } = run(new Map([["app", app]]), [out]);
30
+ expect(template.outputs.WebFqdn.value).toBe("[reference('web').latestRevisionFqdn]");
31
+ expect(warnings).toEqual([]);
32
+ });
33
+
34
+ it("writes literal outputs with their ARM type", () => {
35
+ const { template } = run(new Map(), [
36
+ new LexiconOutput("eastus", "Region"),
37
+ new LexiconOutput(3, "Replicas"),
38
+ new LexiconOutput(true, "Public"),
39
+ ]);
40
+ expect(template.outputs).toEqual({
41
+ Region: { type: "string", value: "eastus" },
42
+ Replicas: { type: "int", value: 3 },
43
+ Public: { type: "bool", value: true },
44
+ });
45
+ });
46
+
47
+ it("writes an azure intrinsic output as its expression", () => {
48
+ const { template } = run(new Map(), [new LexiconOutput(new ResourceIdIntrinsic("Microsoft.Storage/storageAccounts", "sa"), "SaId")]);
49
+ expect(template.outputs.SaId.value).toBe("[resourceId('Microsoft.Storage/storageAccounts', 'sa')]");
50
+ });
51
+
52
+ it("leaves a CloudFormation intrinsic to aws, without a warning", () => {
53
+ const cfn = { [Symbol.for("chant.intrinsic")]: true, toJSON: () => ({ "Fn::Sub": "x" }) };
54
+ const out = new LexiconOutput(cfn as never, "Sub");
55
+ const { template, warnings } = run(new Map(), [out]);
56
+ expect(template.outputs).toBeUndefined();
57
+ expect(warnings).toEqual([]);
58
+ });
59
+
60
+ it("writes a string stack output, which the docs show as an ARM expression", () => {
61
+ const so = stackOutput("[resourceId('Microsoft.Storage/storageAccounts', 'sa')]", { lexicon: "azure" });
62
+ const { template } = run(new Map<string, any>([["saId", so]]), []);
63
+ expect(template.outputs.saId.value).toBe("[resourceId('Microsoft.Storage/storageAccounts', 'sa')]");
64
+ });
65
+ });