@intentius/chant-lexicon-azure 0.124.32 → 0.124.34
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/codegen/generate-typescript.js +1 -1
- package/dist/codegen/generate.js +1 -1
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/generated/index.js +1 -1
- package/dist/health.d.ts +42 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +137 -0
- package/dist/integrity.json +5 -5
- package/dist/lint/post-synth/arm-refs.d.ts +1 -0
- package/dist/lint/post-synth/arm-refs.d.ts.map +1 -1
- package/dist/lint/post-synth/arm-refs.js +5 -3
- package/dist/manifest.json +1 -1
- package/dist/meta.json +6 -1
- package/dist/okf/types/containerApps.md +5 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +6 -0
- package/dist/rules/arm-refs.ts +5 -3
- package/dist/serializer.d.ts +7 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/serializer.js +115 -29
- package/dist/spec/parse.d.ts.map +1 -1
- package/dist/spec/parse.js +10 -0
- package/dist/types/index.d.ts +5 -0
- package/package.json +2 -2
- package/src/attributes.test.ts +17 -0
- package/src/codegen/generate-typescript.ts +1 -1
- package/src/codegen/generate.ts +1 -1
- package/src/generated/index.d.ts +5 -0
- package/src/generated/index.ts +1 -1
- package/src/generated/lexicon-azure.json +6 -1
- package/src/health.test.ts +66 -0
- package/src/health.ts +162 -0
- package/src/lint/post-synth/arm-refs.ts +5 -3
- package/src/lint/post-synth/post-synth.test.ts +1 -1
- package/src/plugin.ts +8 -0
- package/src/serializer-names.test.ts +83 -0
- package/src/serializer-outputs.test.ts +65 -0
- package/src/serializer.ts +110 -28
- package/src/spec/parse.test.ts +15 -0
- 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.
|
|
3
|
+
"version": "0.124.34",
|
|
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.34"
|
|
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
|
+
});
|
package/src/codegen/generate.ts
CHANGED
|
@@ -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 });
|
package/src/generated/index.d.ts
CHANGED
|
@@ -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 {
|
package/src/generated/index.ts
CHANGED
|
@@ -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('
|
|
86
|
-
|
|
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
|
+
});
|