@intentius/chant-lexicon-azure 0.13.1 → 0.15.1

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.
@@ -0,0 +1,331 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ evalArmString,
4
+ evalArm,
5
+ armResourceUrl,
6
+ armResourceBody,
7
+ armDependencies,
8
+ orderArmResources,
9
+ azApply,
10
+ azDelete,
11
+ pruneArmOrphans,
12
+ deleteArmResource,
13
+ listGroupResources,
14
+ chantOwnershipTags,
15
+ isChantOwned,
16
+ type ArmEvalCtx,
17
+ type ArmResource,
18
+ type AzHttp,
19
+ } from "./az-apply";
20
+
21
+ const noHttp: AzHttp = async () => ({ status: 200, text: "{}" });
22
+
23
+ function ctx(over: Partial<ArmEvalCtx> = {}): ArmEvalCtx {
24
+ return {
25
+ subscriptionId: "sub-1",
26
+ resourceGroup: "chant-rg",
27
+ location: "eastus",
28
+ deployed: new Map(),
29
+ http: noHttp,
30
+ base: "http://x",
31
+ ...over,
32
+ };
33
+ }
34
+
35
+ describe("evalArmString — static functions (#707)", () => {
36
+ test("resourceGroup / subscription / concat / uniqueString", async () => {
37
+ expect(await evalArmString("[resourceGroup().location]", ctx())).toBe("eastus");
38
+ expect(await evalArmString("[resourceGroup().id]", ctx())).toBe("/subscriptions/sub-1/resourceGroups/chant-rg");
39
+ expect(await evalArmString("[subscription().subscriptionId]", ctx())).toBe("sub-1");
40
+ const v = await evalArmString("[concat('store', uniqueString(resourceGroup().id))]", ctx());
41
+ expect(String(v)).toHaveLength("store".length + 13);
42
+ });
43
+
44
+ test("resourceId('type','name') → the resource-id path", async () => {
45
+ expect(await evalArmString("[resourceId('Microsoft.Web/serverfarms', 'plan1')]", ctx())).toBe(
46
+ "/subscriptions/sub-1/resourceGroups/chant-rg/providers/Microsoft.Web/serverfarms/plan1",
47
+ );
48
+ });
49
+
50
+ test("non-expression + [[ escape passthrough", async () => {
51
+ expect(await evalArmString("plain", ctx())).toBe("plain");
52
+ expect(await evalArmString("[[literal]", ctx())).toBe("[literal]");
53
+ });
54
+ });
55
+
56
+ describe("evalArmString — reference() (#707)", () => {
57
+ test("reference('name') → the applied resource's properties, with .prop access", async () => {
58
+ const deployed = new Map<string, unknown>([
59
+ ["mystore", { properties: { primaryEndpoints: { blob: "http://mystore.blob/" } } }],
60
+ ]);
61
+ expect(await evalArmString("[reference('mystore').primaryEndpoints.blob]", ctx({ deployed }))).toBe(
62
+ "http://mystore.blob/",
63
+ );
64
+ });
65
+ });
66
+
67
+ describe("evalArmString — listKeys() (#707)", () => {
68
+ test("listKeys(resourceId(...), v).keys[0].value → POSTs the key action and indexes", async () => {
69
+ const calls: string[] = [];
70
+ const http: AzHttp = async (method, url) => {
71
+ calls.push(`${method} ${url}`);
72
+ return { status: 200, text: JSON.stringify({ keys: [{ value: "SECRET-KEY" }, { value: "k2" }] }) };
73
+ };
74
+ const expr = "[concat('AccountKey=', listKeys(resourceId('Microsoft.Storage/storageAccounts', 'st'), '2023-01-01').keys[0].value)]";
75
+ expect(await evalArmString(expr, ctx({ http }))).toBe("AccountKey=SECRET-KEY");
76
+ expect(calls[0]).toBe("POST http://x/subscriptions/sub-1/resourceGroups/chant-rg/providers/Microsoft.Storage/storageAccounts/st/listKeys?api-version=2023-01-01");
77
+ });
78
+ });
79
+
80
+ describe("evalArm recursion (#707)", () => {
81
+ test("recurses objects/arrays, resolving async expressions", async () => {
82
+ expect(await evalArm({ a: "[resourceGroup().location]", b: ["[subscription().subscriptionId]", 1] }, ctx())).toEqual({
83
+ a: "eastus",
84
+ b: ["sub-1", 1],
85
+ });
86
+ });
87
+ });
88
+
89
+ describe("dependency ordering (#707)", () => {
90
+ const plan: ArmResource = { type: "Microsoft.Web/serverfarms", apiVersion: "2023-01-01", name: "plan1" };
91
+ const store: ArmResource = { type: "Microsoft.Storage/storageAccounts", apiVersion: "2023-01-01", name: "st1" };
92
+ const site: ArmResource = {
93
+ type: "Microsoft.Web/sites",
94
+ apiVersion: "2023-01-01",
95
+ name: "site1",
96
+ properties: {
97
+ serverFarmId: "[resourceId('Microsoft.Web/serverfarms', 'plan1')]",
98
+ conn: "[listKeys(resourceId('Microsoft.Storage/storageAccounts', 'st1'), '2023-01-01')]",
99
+ },
100
+ };
101
+
102
+ test("armDependencies finds referenced resource names in the template", () => {
103
+ expect(armDependencies(site, new Set(["plan1", "st1", "site1"])).sort()).toEqual(["plan1", "st1"]);
104
+ expect(armDependencies(plan, new Set(["plan1", "st1", "site1"]))).toEqual([]);
105
+ });
106
+
107
+ test("orderArmResources applies dependencies before the referrer", () => {
108
+ const ordered = orderArmResources([site, plan, store]).map((r) => r.name);
109
+ expect(ordered.indexOf("plan1")).toBeLessThan(ordered.indexOf("site1"));
110
+ expect(ordered.indexOf("st1")).toBeLessThan(ordered.indexOf("site1"));
111
+ });
112
+
113
+ test("throws on a cycle", () => {
114
+ const a: ArmResource = { type: "T", apiVersion: "v", name: "a", properties: { r: "[resourceId('T', 'b')]" } };
115
+ const b: ArmResource = { type: "T", apiVersion: "v", name: "b", properties: { r: "[resourceId('T', 'a')]" } };
116
+ expect(() => orderArmResources([a, b])).toThrow(/reference cycle/);
117
+ });
118
+ });
119
+
120
+ const STORAGE: ArmResource = {
121
+ type: "Microsoft.Storage/storageAccounts",
122
+ apiVersion: "2025-06-01",
123
+ name: "chantstore1",
124
+ location: "[resourceGroup().location]",
125
+ sku: { name: "Standard_LRS" },
126
+ kind: "StorageV2",
127
+ properties: { minimumTlsVersion: "TLS1_2" },
128
+ tags: { "managed-by": "chant" },
129
+ };
130
+
131
+ describe("armResourceUrl / armResourceBody (#707)", () => {
132
+ test("URL is the resource-id PUT path", async () => {
133
+ expect(await armResourceUrl(STORAGE, ctx())).toBe(
134
+ "http://x/subscriptions/sub-1/resourceGroups/chant-rg/providers/Microsoft.Storage/storageAccounts/chantstore1?api-version=2025-06-01",
135
+ );
136
+ });
137
+
138
+ test("body evaluates location, keeps sku/kind/tags/properties", async () => {
139
+ expect(await armResourceBody(STORAGE, ctx())).toEqual({
140
+ location: "eastus",
141
+ sku: { name: "Standard_LRS" },
142
+ kind: "StorageV2",
143
+ properties: { minimumTlsVersion: "TLS1_2" },
144
+ tags: { "managed-by": "chant" },
145
+ });
146
+ });
147
+ });
148
+
149
+ describe("azApply flow (#707)", () => {
150
+ test("ensures the resource group, applies in dependency order, captures state", async () => {
151
+ const calls: Array<{ method: string; url: string }> = [];
152
+ const http: AzHttp = async (method, url) => {
153
+ calls.push({ method, url });
154
+ return { status: 200, text: "{}" };
155
+ };
156
+ const fs = await import("node:fs");
157
+ const tmp = `/tmp/chant-arm-${process.pid}.json`;
158
+ const site: ArmResource = {
159
+ type: "Microsoft.Web/sites",
160
+ apiVersion: "2023-01-01",
161
+ name: "site1",
162
+ properties: { serverFarmId: "[resourceId('Microsoft.Web/serverfarms', 'plan1')]" },
163
+ };
164
+ const plan: ArmResource = { type: "Microsoft.Web/serverfarms", apiVersion: "2023-01-01", name: "plan1" };
165
+ fs.writeFileSync(tmp, JSON.stringify({ resources: [site, plan] })); // listed out of order
166
+ const res = await azApply({ templatePath: tmp, resourceGroup: "chant-rg", location: "eastus", endpoint: "http://x", subscriptionId: "sub-1" }, undefined, http);
167
+ fs.unlinkSync(tmp);
168
+ // plan (dependency) applied before site (referrer), despite manifest order.
169
+ expect(res.applied.map((a) => a.name)).toEqual(["plan1", "site1"]);
170
+ const puts = calls.filter((c) => c.method === "PUT" && c.url.includes("/providers/"));
171
+ expect(puts[0].url).toContain("/serverfarms/plan1");
172
+ expect(puts[1].url).toContain("/sites/site1");
173
+ });
174
+
175
+ test("surfaces a resource apply failure", async () => {
176
+ const fs = await import("node:fs");
177
+ const tmp = `/tmp/chant-arm-fail-${process.pid}.json`;
178
+ fs.writeFileSync(tmp, JSON.stringify({ resources: [STORAGE] }));
179
+ const http: AzHttp = async (_method, url) =>
180
+ url.includes("/providers/") ? { status: 400, text: "denied" } : { status: 200, text: "" };
181
+ await expect(
182
+ azApply({ templatePath: tmp, resourceGroup: "rg", endpoint: "http://x" }, undefined, http),
183
+ ).rejects.toThrow(/Microsoft.Storage\/storageAccounts chantstore1 apply failed \(400\)/);
184
+ fs.unlinkSync(tmp);
185
+ });
186
+
187
+ test("stamps chant ownership on the PUT body", async () => {
188
+ const fs = await import("node:fs");
189
+ const tmp = `/tmp/chant-arm-own-${process.pid}.json`;
190
+ fs.writeFileSync(tmp, JSON.stringify({ resources: [{ type: "T", apiVersion: "v", name: "r1" }] }));
191
+ let putBody: Record<string, unknown> | undefined;
192
+ const http: AzHttp = async (method, url, body) => {
193
+ if (method === "PUT" && url.includes("/providers/")) putBody = body as Record<string, unknown>;
194
+ return { status: 200, text: "{}" };
195
+ };
196
+ await azApply({ templatePath: tmp, resourceGroup: "rg", endpoint: "http://x" }, undefined, http);
197
+ fs.unlinkSync(tmp);
198
+ expect((putBody?.tags as Record<string, string>)["managed-by"]).toBe("chant");
199
+ });
200
+ });
201
+
202
+ describe("ownership helpers (#azure-prune)", () => {
203
+ test("chantOwnershipTags / isChantOwned", () => {
204
+ expect(chantOwnershipTags()).toEqual({ "managed-by": "chant" });
205
+ expect(isChantOwned({ "managed-by": "chant" })).toBe(true);
206
+ expect(isChantOwned({ "managed-by": "someone-else" })).toBe(false);
207
+ expect(isChantOwned(undefined)).toBe(false);
208
+ });
209
+ });
210
+
211
+ describe("deleteArmResource (#azure-prune)", () => {
212
+ test("DELETEs the resource-id path; 404 is not-deleted", async () => {
213
+ const calls: string[] = [];
214
+ const http: AzHttp = async (method, url) => {
215
+ calls.push(`${method} ${url}`);
216
+ return { status: 200, text: "" };
217
+ };
218
+ const res = await deleteArmResource("Microsoft.Storage/storageAccounts", "st1", "2023-01-01", ctx(), http);
219
+ expect(res).toEqual({ type: "Microsoft.Storage/storageAccounts", name: "st1", deleted: true });
220
+ expect(calls[0]).toBe(
221
+ "DELETE http://x/subscriptions/sub-1/resourceGroups/chant-rg/providers/Microsoft.Storage/storageAccounts/st1?api-version=2023-01-01",
222
+ );
223
+ const gone = await deleteArmResource("T", "x", "v", ctx(), async () => ({ status: 404, text: "" }));
224
+ expect(gone.deleted).toBe(false);
225
+ });
226
+
227
+ test("throws on a non-404 error", async () => {
228
+ await expect(
229
+ deleteArmResource("T", "x", "v", ctx(), async () => ({ status: 403, text: "no" })),
230
+ ).rejects.toThrow(/T x delete failed \(403\)/);
231
+ });
232
+ });
233
+
234
+ describe("listGroupResources (#azure-prune)", () => {
235
+ test("returns the value[] items, filtering malformed entries", async () => {
236
+ const http: AzHttp = async () => ({
237
+ status: 200,
238
+ text: JSON.stringify({ value: [{ id: "/a", name: "a", type: "T", tags: { "managed-by": "chant" } }, { id: "/bad" }] }),
239
+ });
240
+ const items = await listGroupResources(ctx(), http);
241
+ expect(items.map((i) => i.name)).toEqual(["a"]);
242
+ });
243
+
244
+ test("returns [] on an error status", async () => {
245
+ expect(await listGroupResources(ctx(), async () => ({ status: 500, text: "" }))).toEqual([]);
246
+ });
247
+ });
248
+
249
+ describe("pruneArmOrphans (#azure-prune)", () => {
250
+ const desired: ArmResource[] = [{ type: "Microsoft.Storage/storageAccounts", apiVersion: "2023-01-01", name: "keep1" }];
251
+
252
+ test("deletes only chant-owned, templated-type resources not in the template", async () => {
253
+ const live = {
254
+ value: [
255
+ { id: "/1", name: "keep1", type: "Microsoft.Storage/storageAccounts", tags: { "managed-by": "chant" } }, // in template → keep
256
+ { id: "/2", name: "orphan1", type: "Microsoft.Storage/storageAccounts", tags: { "managed-by": "chant" } }, // owned, not in template → prune
257
+ { id: "/3", name: "foreign", type: "Microsoft.Storage/storageAccounts", tags: {} }, // not owned → skip
258
+ { id: "/4", name: "othertype", type: "Microsoft.Web/sites", tags: { "managed-by": "chant" } }, // type not templated → skip
259
+ ],
260
+ };
261
+ const deletes: string[] = [];
262
+ const http: AzHttp = async (method, url) => {
263
+ if (method === "DELETE") deletes.push(url);
264
+ return { status: 200, text: method === "GET" ? JSON.stringify(live) : "" };
265
+ };
266
+ const pruned = await pruneArmOrphans(desired, ctx(), http);
267
+ expect(pruned).toEqual([{ type: "Microsoft.Storage/storageAccounts", name: "orphan1", deleted: true }]);
268
+ expect(deletes).toHaveLength(1);
269
+ expect(deletes[0]).toContain("/storageAccounts/orphan1?api-version=2023-01-01"); // apiVersion from the template
270
+ });
271
+ });
272
+
273
+ describe("azApply prune flag (#azure-prune)", () => {
274
+ test("prunes owned orphans of a templated type after applying", async () => {
275
+ const fs = await import("node:fs");
276
+ const tmp = `/tmp/chant-arm-prune-${process.pid}.json`;
277
+ fs.writeFileSync(tmp, JSON.stringify({ resources: [{ type: "T", apiVersion: "v", name: "keep1" }] }));
278
+ const live = { value: [{ id: "/o", name: "orphan1", type: "T", tags: { "managed-by": "chant" } }] };
279
+ const deletes: string[] = [];
280
+ const http: AzHttp = async (method, url) => {
281
+ if (method === "DELETE") deletes.push(url);
282
+ return { status: 200, text: method === "GET" ? JSON.stringify(live) : "{}" };
283
+ };
284
+ const res = await azApply({ templatePath: tmp, resourceGroup: "rg", endpoint: "http://x", prune: true }, undefined, http);
285
+ fs.unlinkSync(tmp);
286
+ expect(res.applied.map((a) => a.name)).toEqual(["keep1"]);
287
+ expect(res.pruned).toEqual([{ type: "T", name: "orphan1", deleted: true }]);
288
+ expect(deletes[0]).toContain("/providers/T/orphan1");
289
+ });
290
+
291
+ test("no prune when the flag is off", async () => {
292
+ const fs = await import("node:fs");
293
+ const tmp = `/tmp/chant-arm-noprune-${process.pid}.json`;
294
+ fs.writeFileSync(tmp, JSON.stringify({ resources: [{ type: "T", apiVersion: "v", name: "keep1" }] }));
295
+ let listed = false;
296
+ const http: AzHttp = async (method, url) => {
297
+ if (method === "GET" && url.includes("/resources?")) listed = true;
298
+ return { status: 200, text: "{}" };
299
+ };
300
+ const res = await azApply({ templatePath: tmp, resourceGroup: "rg", endpoint: "http://x" }, undefined, http);
301
+ fs.unlinkSync(tmp);
302
+ expect(res.pruned).toEqual([]);
303
+ expect(listed).toBe(false);
304
+ });
305
+ });
306
+
307
+ describe("azDelete (#azure-prune)", () => {
308
+ test("deletes declared resources in reverse dependency order", async () => {
309
+ const fs = await import("node:fs");
310
+ const tmp = `/tmp/chant-arm-del-${process.pid}.json`;
311
+ const site: ArmResource = {
312
+ type: "Microsoft.Web/sites",
313
+ apiVersion: "2023-01-01",
314
+ name: "site1",
315
+ properties: { serverFarmId: "[resourceId('Microsoft.Web/serverfarms', 'plan1')]" },
316
+ };
317
+ const plan: ArmResource = { type: "Microsoft.Web/serverfarms", apiVersion: "2023-01-01", name: "plan1" };
318
+ fs.writeFileSync(tmp, JSON.stringify({ resources: [plan, site] }));
319
+ const deletes: string[] = [];
320
+ const http: AzHttp = async (method, url) => {
321
+ if (method === "DELETE") deletes.push(url);
322
+ return { status: 200, text: "" };
323
+ };
324
+ const res = await azDelete({ templatePath: tmp, resourceGroup: "chant-rg", endpoint: "http://x" }, undefined, http);
325
+ fs.unlinkSync(tmp);
326
+ // referrer (site) deleted before the resource it references (plan).
327
+ expect(res.deleted.map((d) => d.name)).toEqual(["site1", "plan1"]);
328
+ expect(deletes[0]).toContain("/sites/site1");
329
+ expect(deletes[1]).toContain("/serverfarms/plan1");
330
+ });
331
+ });