@intentius/chant-lexicon-azure 0.124.31 → 0.124.32

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 (117) hide show
  1. package/dist/actions/appservice.js +7 -0
  2. package/dist/actions/compute.js +9 -0
  3. package/dist/actions/container.js +14 -0
  4. package/dist/actions/identity.js +11 -0
  5. package/dist/actions/index.js +11 -0
  6. package/dist/actions/keyvault.js +12 -0
  7. package/dist/actions/network.js +9 -0
  8. package/dist/actions/sql.js +9 -0
  9. package/dist/actions/storage.js +15 -0
  10. package/dist/api/read-client.js +146 -0
  11. package/dist/codegen/docs-cli.js +2 -0
  12. package/dist/codegen/docs.js +83 -0
  13. package/dist/codegen/generate-cli.js +11 -0
  14. package/dist/codegen/generate-lexicon.js +77 -0
  15. package/dist/codegen/generate-typescript.js +163 -0
  16. package/dist/codegen/generate.js +106 -0
  17. package/dist/codegen/naming.js +139 -0
  18. package/dist/codegen/package.js +64 -0
  19. package/dist/composites/aks-cluster.js +68 -0
  20. package/dist/composites/app-service.js +62 -0
  21. package/dist/composites/application-gateway.js +107 -0
  22. package/dist/composites/catalog.js +856 -0
  23. package/dist/composites/container-instance.js +36 -0
  24. package/dist/composites/container-registry.js +62 -0
  25. package/dist/composites/cosmos-database.js +56 -0
  26. package/dist/composites/from-arm.js +47 -0
  27. package/dist/composites/function-app.js +57 -0
  28. package/dist/composites/index.js +16 -0
  29. package/dist/composites/keyvault.js +69 -0
  30. package/dist/composites/landing-zone.js +119 -0
  31. package/dist/composites/private-endpoint.js +61 -0
  32. package/dist/composites/redis-cache.js +21 -0
  33. package/dist/composites/service-bus-pipeline.js +54 -0
  34. package/dist/composites/sql-database.js +62 -0
  35. package/dist/composites/storage-account.js +59 -0
  36. package/dist/composites/vm-linux.js +139 -0
  37. package/dist/composites/vnet-default.js +102 -0
  38. package/dist/coverage.js +28 -0
  39. package/dist/deep-observe.js +356 -0
  40. package/dist/default-tags.js +50 -0
  41. package/dist/deploy-scopes.d.ts.map +1 -1
  42. package/dist/deploy-scopes.js +157 -0
  43. package/dist/describe-resources.js +187 -0
  44. package/dist/detect.js +17 -0
  45. package/dist/export-resources.js +108 -0
  46. package/dist/generated/index.js +16598 -0
  47. package/dist/generated/runtime.js +4 -0
  48. package/dist/governance.js +144 -0
  49. package/dist/import/generator.js +140 -0
  50. package/dist/import/live-export.js +39 -0
  51. package/dist/import/parser.js +192 -0
  52. package/dist/index.js +44 -0
  53. package/dist/integrity.json +2 -2
  54. package/dist/intrinsics.js +179 -0
  55. package/dist/lint/audit-catalog.js +36 -0
  56. package/dist/lint/audit-lineage.js +71 -0
  57. package/dist/lint/post-synth/arm-refs.js +94 -0
  58. package/dist/lint/post-synth/azr010.js +45 -0
  59. package/dist/lint/post-synth/azr011.js +43 -0
  60. package/dist/lint/post-synth/azr012.js +41 -0
  61. package/dist/lint/post-synth/azr013.js +50 -0
  62. package/dist/lint/post-synth/azr014.js +49 -0
  63. package/dist/lint/post-synth/azr015.js +66 -0
  64. package/dist/lint/post-synth/azr016.js +37 -0
  65. package/dist/lint/post-synth/azr017.js +37 -0
  66. package/dist/lint/post-synth/azr018.js +42 -0
  67. package/dist/lint/post-synth/azr019.js +41 -0
  68. package/dist/lint/post-synth/azr020.js +37 -0
  69. package/dist/lint/post-synth/azr021.js +36 -0
  70. package/dist/lint/post-synth/azr022.js +39 -0
  71. package/dist/lint/post-synth/azr023.js +39 -0
  72. package/dist/lint/post-synth/azr024.js +39 -0
  73. package/dist/lint/post-synth/azr025.js +37 -0
  74. package/dist/lint/post-synth/azr026.js +38 -0
  75. package/dist/lint/post-synth/azr027.js +37 -0
  76. package/dist/lint/post-synth/azr028.js +37 -0
  77. package/dist/lint/post-synth/azr029.js +38 -0
  78. package/dist/lint/post-synth/azr030.js +44 -0
  79. package/dist/lint/post-synth/azr031.js +36 -0
  80. package/dist/lint/post-synth/azr032.js +53 -0
  81. package/dist/lint/post-synth/azr033.js +35 -0
  82. package/dist/lint/post-synth/index.js +50 -0
  83. package/dist/lint/rules/hardcoded-location.js +82 -0
  84. package/dist/lint/rules/index.js +6 -0
  85. package/dist/lint/rules/nsg-wildcard.js +44 -0
  86. package/dist/lint/rules/storage-https.js +62 -0
  87. package/dist/lsp/completions.d.ts.map +1 -1
  88. package/dist/lsp/completions.js +18 -0
  89. package/dist/lsp/hover.d.ts.map +1 -1
  90. package/dist/lsp/hover.js +44 -0
  91. package/dist/manifest.json +1 -1
  92. package/dist/op/activities/az-apply.js +563 -0
  93. package/dist/op/activities/azure.js +48 -0
  94. package/dist/op/activities/floci-az.js +37 -0
  95. package/dist/op/activities/index.js +10 -0
  96. package/dist/op/builders.js +70 -0
  97. package/dist/ownership.js +13 -0
  98. package/dist/package-cli.js +21 -0
  99. package/dist/parameter.js +13 -0
  100. package/dist/plugin.d.ts.map +1 -1
  101. package/dist/plugin.js +472 -0
  102. package/dist/pseudo.js +40 -0
  103. package/dist/serializer.js +457 -0
  104. package/dist/spec/api-versions.js +130 -0
  105. package/dist/spec/fetch.js +196 -0
  106. package/dist/spec/parse.js +308 -0
  107. package/dist/taggable.js +34 -0
  108. package/dist/validate-cli.js +16 -0
  109. package/dist/validate.js +53 -0
  110. package/package.json +8 -8
  111. package/src/codegen/package.ts +2 -2
  112. package/src/deploy-scopes.ts +2 -1
  113. package/src/lsp/completions.ts +2 -1
  114. package/src/lsp/hover.ts +2 -1
  115. package/src/plugin.ts +3 -2
  116. package/src/serializer.ts +2 -2
  117. package/src/taggable.ts +2 -2
@@ -0,0 +1,563 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { hasOwnershipMarker, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
4
+ import { AZURE_TAG_OWNERSHIP_KEYS } from "../../ownership.js";
5
+ import { lookupApiVersion } from "../../serializer.js";
6
+ import { compareApiDates } from "../../spec/api-versions.js";
7
+ import { applyResult, } from "@intentius/chant/apply";
8
+ // The floci-az default local subscription (from its ARM management-plane mock).
9
+ const DEFAULT_SUBSCRIPTION = "00000000-0000-0000-0000-000000000001";
10
+ const DEFAULT_ENDPOINT = "https://management.azure.com";
11
+ const defaultHttp = async (method, url, body, signal) => {
12
+ const res = await fetch(url, {
13
+ method,
14
+ headers: body === undefined ? undefined : { "content-type": "application/json" },
15
+ body: body === undefined ? undefined : JSON.stringify(body),
16
+ signal,
17
+ });
18
+ return { status: res.status, text: await res.text() };
19
+ };
20
+ /** Evaluate an ARM expression string (`"[...]"`); a plain string is returned as-is. */
21
+ export async function evalArmString(s, ctx) {
22
+ if (!(s.startsWith("[") && s.endsWith("]")))
23
+ return s;
24
+ if (s.startsWith("[["))
25
+ return s.slice(1); // escaped literal "["
26
+ return new ArmExpr(s.slice(1, -1), ctx).parse();
27
+ }
28
+ /** Recursively evaluate every string in a value against the ARM context. */
29
+ export async function evalArm(value, ctx) {
30
+ if (typeof value === "string")
31
+ return evalArmString(value, ctx);
32
+ if (Array.isArray(value))
33
+ return Promise.all(value.map((v) => evalArm(v, ctx)));
34
+ if (value && typeof value === "object") {
35
+ const out = {};
36
+ for (const [k, v] of Object.entries(value))
37
+ out[k] = await evalArm(v, ctx);
38
+ return out;
39
+ }
40
+ return value;
41
+ }
42
+ /**
43
+ * Recursive-descent evaluator for the ARM function subset chant emits:
44
+ * `concat`, `uniqueString`, `resourceGroup()`, `subscription()`, `resourceId`,
45
+ * `reference` (runtime state of an applied resource), and `listKeys` (async key
46
+ * action), with `.prop` and `[index]` access.
47
+ */
48
+ class ArmExpr {
49
+ constructor(src, ctx) {
50
+ this.src = src;
51
+ this.ctx = ctx;
52
+ this.i = 0;
53
+ }
54
+ async parse() {
55
+ return this.access(await this.atom());
56
+ }
57
+ async atom() {
58
+ this.ws();
59
+ return this.src[this.i] === "'" ? this.stringLiteral() : this.call();
60
+ }
61
+ /** Postfix `.prop` / `[index]` access. */
62
+ async access(v) {
63
+ for (;;) {
64
+ const c = this.peek();
65
+ if (c === ".") {
66
+ this.i++;
67
+ v = v?.[this.ident()];
68
+ }
69
+ else if (c === "[") {
70
+ this.i++;
71
+ v = v?.[this.indexNumber()];
72
+ }
73
+ else {
74
+ return v;
75
+ }
76
+ }
77
+ }
78
+ async call() {
79
+ const name = this.ident();
80
+ this.ws();
81
+ const args = [];
82
+ if (this.peek() === "(") {
83
+ this.i++;
84
+ this.ws();
85
+ if (this.peek() !== ")") {
86
+ args.push(await this.parse());
87
+ this.ws();
88
+ while (this.peek() === ",") {
89
+ this.i++;
90
+ args.push(await this.parse());
91
+ this.ws();
92
+ }
93
+ }
94
+ if (this.peek() === ")")
95
+ this.i++;
96
+ }
97
+ return this.applyFn(name, args);
98
+ }
99
+ async applyFn(name, args) {
100
+ switch (name) {
101
+ case "concat":
102
+ return args.map(String).join("");
103
+ case "uniqueString":
104
+ return createHash("sha256").update(args.map(String).join("-")).digest("hex").slice(0, 13);
105
+ case "resourceGroup":
106
+ return { location: this.ctx.location, id: `/subscriptions/${this.ctx.subscriptionId}/resourceGroups/${this.ctx.resourceGroup}`, name: this.ctx.resourceGroup };
107
+ case "subscription":
108
+ return { subscriptionId: this.ctx.subscriptionId, id: `/subscriptions/${this.ctx.subscriptionId}` };
109
+ case "resourceId":
110
+ // resourceId('Microsoft.X/y', 'name'[, 'child']) → the resource-id path.
111
+ return `/subscriptions/${this.ctx.subscriptionId}/resourceGroups/${this.ctx.resourceGroup}/providers/${args.map(String).join("/")}`;
112
+ case "reference": {
113
+ // reference('name') → the runtime `properties` of an already-applied resource.
114
+ const dep = this.ctx.deployed.get(String(args[0]));
115
+ return dep?.properties ?? dep;
116
+ }
117
+ case "listKeys": {
118
+ // listKeys(resourceId, apiVersion) → POST the resource's key action.
119
+ const resId = String(args[0]);
120
+ const apiVersion = String(args[1] ?? "2023-01-01");
121
+ const res = await this.ctx.http("POST", `${this.ctx.base}${resId}/listKeys?api-version=${apiVersion}`, {}, this.ctx.signal);
122
+ try {
123
+ return JSON.parse(res.text);
124
+ }
125
+ catch {
126
+ return {};
127
+ }
128
+ }
129
+ default:
130
+ throw new Error(`unsupported ARM function: ${name}`);
131
+ }
132
+ }
133
+ stringLiteral() {
134
+ this.i++; // opening '
135
+ let out = "";
136
+ while (this.i < this.src.length && this.src[this.i] !== "'")
137
+ out += this.src[this.i++];
138
+ this.i++; // closing '
139
+ return out;
140
+ }
141
+ ident() {
142
+ this.ws();
143
+ let out = "";
144
+ while (this.i < this.src.length && /[A-Za-z0-9_]/.test(this.src[this.i]))
145
+ out += this.src[this.i++];
146
+ return out;
147
+ }
148
+ indexNumber() {
149
+ this.ws();
150
+ let n = "";
151
+ while (this.i < this.src.length && /[0-9]/.test(this.src[this.i]))
152
+ n += this.src[this.i++];
153
+ this.ws();
154
+ if (this.peek() === "]")
155
+ this.i++;
156
+ return parseInt(n, 10);
157
+ }
158
+ ws() {
159
+ while (this.i < this.src.length && /\s/.test(this.src[this.i]))
160
+ this.i++;
161
+ }
162
+ peek() {
163
+ this.ws();
164
+ return this.src[this.i];
165
+ }
166
+ }
167
+ // ── Dependency ordering ───────────────────────────────────────────────────────
168
+ /** Resource names this resource references via `resourceId('type','name')` / `reference('name')`. Pure. */
169
+ export function armDependencies(resource, names) {
170
+ const deps = new Set();
171
+ const scan = (v) => {
172
+ if (typeof v === "string") {
173
+ for (const m of v.matchAll(/resourceId\(\s*'[^']*'\s*,\s*'([^']*)'/g))
174
+ if (names.has(m[1]))
175
+ deps.add(m[1]);
176
+ for (const m of v.matchAll(/reference\(\s*'([^']*)'/g))
177
+ if (names.has(m[1]))
178
+ deps.add(m[1]);
179
+ }
180
+ else if (Array.isArray(v)) {
181
+ v.forEach(scan);
182
+ }
183
+ else if (v && typeof v === "object") {
184
+ Object.values(v).forEach(scan);
185
+ }
186
+ };
187
+ scan(resource);
188
+ deps.delete(resource.name);
189
+ return [...deps];
190
+ }
191
+ /**
192
+ * Topologically order ARM resources so a referenced resource is applied before
193
+ * the resource that references it. Names that are expressions are ordered as-is
194
+ * (they don't match a literal reference). Throws on a cycle. Pure.
195
+ */
196
+ export function orderArmResources(resources) {
197
+ const byName = new Map();
198
+ for (const r of resources)
199
+ byName.set(r.name, r);
200
+ const names = new Set(resources.map((r) => r.name));
201
+ const ordered = [];
202
+ const done = new Set();
203
+ const active = new Set();
204
+ const visit = (r) => {
205
+ if (done.has(r))
206
+ return;
207
+ if (active.has(r))
208
+ throw new Error(`ARM reference cycle involving ${r.name}`);
209
+ active.add(r);
210
+ for (const dep of armDependencies(r, names)) {
211
+ const target = byName.get(dep);
212
+ if (target && target !== r)
213
+ visit(target);
214
+ }
215
+ active.delete(r);
216
+ done.add(r);
217
+ ordered.push(r);
218
+ };
219
+ for (const r of resources)
220
+ visit(r);
221
+ return ordered;
222
+ }
223
+ // ── URL + body + apply ────────────────────────────────────────────────────────
224
+ /** The ARM resource-ID PUT URL for a resource (name expression evaluated). */
225
+ export async function armResourceUrl(resource, ctx) {
226
+ const name = await evalArmString(resource.name, ctx);
227
+ return `${ctx.base}/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}/providers/${resource.type}/${name}?api-version=${resource.apiVersion}`;
228
+ }
229
+ /** The ARM resource PUT body (location/properties/sku/kind/tags), expressions evaluated. */
230
+ export async function armResourceBody(resource, ctx) {
231
+ const body = {};
232
+ if (resource.location)
233
+ body.location = await evalArmString(resource.location, ctx);
234
+ if (resource.properties !== undefined)
235
+ body.properties = await evalArm(resource.properties, ctx);
236
+ if (resource.sku !== undefined)
237
+ body.sku = await evalArm(resource.sku, ctx);
238
+ if (resource.kind !== undefined)
239
+ body.kind = await evalArm(resource.kind, ctx);
240
+ if (resource.tags !== undefined)
241
+ body.tags = await evalArm(resource.tags, ctx);
242
+ return body;
243
+ }
244
+ /**
245
+ * The native Azure applier — read a built ARM template and PUT each resource
246
+ * directly to the ARM resource-CRUD API, in dependency order, resolving ARM
247
+ * expressions (including `reference()`/`listKeys()` against resources applied
248
+ * earlier this run). The Azure twin of `gcpApply`: it targets floci-az (which
249
+ * `az deployment` can't, floci-az having no deployments provider) or real Azure
250
+ * by endpoint override; the resource group is ensured first.
251
+ */
252
+ export async function azApply(args, signal, http = defaultHttp) {
253
+ const base = (args.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, "");
254
+ const ctx = {
255
+ subscriptionId: args.subscriptionId ?? DEFAULT_SUBSCRIPTION,
256
+ resourceGroup: args.resourceGroup,
257
+ location: args.location ?? "eastus",
258
+ deployed: new Map(),
259
+ http,
260
+ base,
261
+ signal,
262
+ };
263
+ // Ensure the resource group exists (ARM rejects resource PUTs without it).
264
+ await http("PUT", `${base}/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}?api-version=2021-04-01`, { location: ctx.location }, signal);
265
+ const template = JSON.parse(readFileSync(args.templatePath, "utf8"));
266
+ const resources = template.resources ?? [];
267
+ const applied = [];
268
+ for (const resource of orderArmResources(resources)) {
269
+ const name = String(await evalArmString(resource.name, ctx));
270
+ // Stamp chant ownership so a later prune can tell chant-managed resources
271
+ // apart from foreign ones in the same group.
272
+ const body = await armResourceBody(resource, ctx);
273
+ body.tags = { ...(body.tags ?? {}), ...chantOwnershipTags() };
274
+ const res = await http("PUT", await armResourceUrl(resource, ctx), body, signal);
275
+ if (res.status >= 300) {
276
+ throw new Error(`${resource.type} ${name} apply failed (${res.status}): ${res.text}`);
277
+ }
278
+ // Capture the applied resource so later reference()/dependents resolve.
279
+ try {
280
+ ctx.deployed.set(name, JSON.parse(res.text));
281
+ }
282
+ catch {
283
+ // non-JSON response — reference() to this resource resolves to undefined
284
+ }
285
+ console.log(`applied: ${resource.type}/${name} (${base})`);
286
+ applied.push({ type: resource.type, name });
287
+ }
288
+ const prune = args.prune
289
+ ? await pruneArmOrphans(resources, ctx, http, signal)
290
+ : { pruned: [], notPrunable: [] };
291
+ return { applied, pruned: prune.pruned, notPrunable: prune.notPrunable };
292
+ }
293
+ // ── Ownership + prune + delete ────────────────────────────────────────────────
294
+ // chant stamps this tag on every resource it applies; prune only ever deletes
295
+ // resources carrying it, so a foreign resource sharing the group is never
296
+ // touched. Real Azure persists resource tags; note that floci-az currently drops
297
+ // them, so owned-only prune only takes effect against real Azure (the delete
298
+ // mechanics themselves work against either — see azDelete).
299
+ /**
300
+ * The key this applier used before #1446, and still recognises.
301
+ *
302
+ * The lexicon declares its marker channel in `../../ownership.ts`
303
+ * (`chant-managed-by`) — the key the serializer stamps, the plugin registers as
304
+ * `ownershipChannel`, and `import/live-export.ts` filters on. This applier used
305
+ * a bare `managed-by` instead, so the two never agreed.
306
+ *
307
+ * That is not cosmetic. Both are ordinary ARM tag keys on the same surface, so
308
+ * nothing forced the difference — and it meant a resource carrying only the
309
+ * SERIALIZER's marker was invisible to this prune. Anything deployed by a path
310
+ * other than `azApply` reads as foreign and is never pruned, including
311
+ * everything `ApplyOp` deployed before #1448 routed it here from
312
+ * `az deployment --mode Complete`.
313
+ *
314
+ * (GCP has the same-looking split for a real reason: its serializer stamps a
315
+ * Config Connector object, where `app.kubernetes.io/managed-by` is a legal
316
+ * Kubernetes label, while `gcpApply` writes GCP REST labels, where it is not.
317
+ * Two surfaces, two key vocabularies. Azure has one surface.)
318
+ */
319
+ export const LEGACY_OWNERSHIP_TAG_KEY = "managed-by";
320
+ /**
321
+ * The ownership tags azApply stamps on every resource it applies.
322
+ *
323
+ * Stamps the declared channel's key. The legacy key goes on too, so a resource
324
+ * applied by this chant is still recognised by an older one mid-rollout; it is
325
+ * transitional and can be dropped once no deployed estate predates #1446.
326
+ *
327
+ * Only the managed-by marker, not stack/env: this applier has no
328
+ * {@link OwnershipMarker} to hand — the serializer stamps those from project
329
+ * config. Same shape the fly serializer uses when no ownership context is set.
330
+ */
331
+ export function chantOwnershipTags() {
332
+ return {
333
+ [AZURE_TAG_OWNERSHIP_KEYS.managedBy]: OWNERSHIP_MANAGED_BY_VALUE,
334
+ [LEGACY_OWNERSHIP_TAG_KEY]: OWNERSHIP_MANAGED_BY_VALUE,
335
+ };
336
+ }
337
+ /**
338
+ * Whether a resource's tags mark it chant-owned.
339
+ *
340
+ * Resolves through core's `hasOwnershipMarker` against the lexicon's declared
341
+ * channel (#1446), so this predicate and the serializer's stamp can no longer
342
+ * drift — the arrangement the fly lexicon already had.
343
+ *
344
+ * The legacy key is accepted as well, and the union is deliberate: it is
345
+ * strictly more than this predicate recognised before, so nothing that was
346
+ * prunable stops being prunable, and resources the serializer marked become
347
+ * prunable for the first time.
348
+ */
349
+ export function isChantOwned(tags) {
350
+ if (hasOwnershipMarker(tags ?? undefined, AZURE_TAG_OWNERSHIP_KEYS))
351
+ return true;
352
+ return tags?.[LEGACY_OWNERSHIP_TAG_KEY] === OWNERSHIP_MANAGED_BY_VALUE;
353
+ }
354
+ /** List the resources in the group via the ARM resource-list endpoint. */
355
+ export async function listGroupResources(ctx, http = defaultHttp, signal) {
356
+ const url = `${ctx.base}/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}/resources?api-version=2021-04-01`;
357
+ const res = await http("GET", url, undefined, signal);
358
+ if (res.status >= 300)
359
+ return [];
360
+ try {
361
+ return (JSON.parse(res.text).value ?? []).filter((r) => r?.type && r?.name);
362
+ }
363
+ catch {
364
+ return [];
365
+ }
366
+ }
367
+ /** Idempotently delete one ARM resource by type/name/apiVersion. A 404 means it is already gone. */
368
+ export async function deleteArmResource(type, name, apiVersion, ctx, http = defaultHttp, signal) {
369
+ const url = `${ctx.base}/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}/providers/${type}/${name}?api-version=${apiVersion}`;
370
+ const res = await http("DELETE", url, undefined, signal);
371
+ if (res.status === 404)
372
+ return { type, name, deleted: false };
373
+ if (res.status >= 300)
374
+ throw new Error(`${type} ${name} delete failed (${res.status}): ${res.text}`);
375
+ return { type, name, deleted: true };
376
+ }
377
+ /**
378
+ * Normalize an azApply/azDelete result into core's apply envelope (#1446).
379
+ *
380
+ * The lexicon keeps its own shape — ARM `type`, per-resource applied/pruned —
381
+ * and this projects it onto the shared tri-state so a caller can read any
382
+ * applier's result the same way.
383
+ */
384
+ export function toApplyResult(result) {
385
+ const applied = (result.applied ?? []).map((a) => ({
386
+ kind: a.type,
387
+ name: a.name,
388
+ // ARM PUT is a create-or-update with no distinguishing response, so this
389
+ // applier cannot tell the two apart. `updated` is the honest verdict for a
390
+ // call that converged without claiming which it did.
391
+ action: "updated",
392
+ }));
393
+ const notAttempted = (result.notPrunable ?? []).map((n) => ({
394
+ kind: n.type,
395
+ name: n.name,
396
+ reason: "not-prunable",
397
+ detail: n.reason,
398
+ }));
399
+ const pruned = (result.pruned ?? result.deleted ?? []).map((p) => ({
400
+ kind: p.type,
401
+ name: p.name,
402
+ deleted: p.deleted,
403
+ }));
404
+ return applyResult(applied, pruned, notAttempted);
405
+ }
406
+ /** The ARM provider-metadata apiVersion (`GET /subscriptions/{sub}/providers/{ns}`). */
407
+ const PROVIDER_METADATA_API_VERSION = "2021-04-01";
408
+ /**
409
+ * Resolve an apiVersion for a live resource type from ARM's provider metadata:
410
+ * `GET /subscriptions/{sub}/providers/{namespace}?api-version=2021-04-01`
411
+ * returns `resourceTypes[].apiVersions[]`. Picks the newest stable version,
412
+ * falling back to the newest preview when no stable one exists. One call per
413
+ * namespace per prune run; the result is memoised in `cache` so a group with
414
+ * many orphans of one provider costs one round trip. Returns `undefined` when
415
+ * the endpoint is unavailable (floci-az does not implement it) or the type is
416
+ * not listed.
417
+ */
418
+ export async function providerApiVersion(resourceType, ctx, http = defaultHttp, signal, cache = new Map()) {
419
+ const slash = resourceType.indexOf("/");
420
+ if (slash < 0)
421
+ return undefined;
422
+ const namespace = resourceType.slice(0, slash);
423
+ const typeName = resourceType.slice(slash + 1).toLowerCase();
424
+ let byType = cache.get(namespace.toLowerCase());
425
+ if (!byType) {
426
+ byType = new Map();
427
+ cache.set(namespace.toLowerCase(), byType);
428
+ const url = `${ctx.base}/subscriptions/${ctx.subscriptionId}/providers/${namespace}?api-version=${PROVIDER_METADATA_API_VERSION}`;
429
+ let res;
430
+ try {
431
+ res = await http("GET", url, undefined, signal);
432
+ }
433
+ catch {
434
+ return undefined;
435
+ }
436
+ if (res.status >= 300)
437
+ return undefined;
438
+ let parsed;
439
+ try {
440
+ parsed = JSON.parse(res.text);
441
+ }
442
+ catch {
443
+ return undefined;
444
+ }
445
+ for (const rt of parsed.resourceTypes ?? []) {
446
+ if (!rt?.resourceType || !Array.isArray(rt.apiVersions) || rt.apiVersions.length === 0)
447
+ continue;
448
+ const versions = rt.apiVersions.filter((v) => typeof v === "string");
449
+ const stable = versions.filter((v) => !v.endsWith("-preview"));
450
+ const pool = stable.length > 0 ? stable : versions;
451
+ if (pool.length === 0)
452
+ continue;
453
+ const newest = pool.reduce((a, b) => (compareApiDates(b, a) > 0 ? b : a));
454
+ byType.set(rt.resourceType.toLowerCase(), newest);
455
+ }
456
+ }
457
+ return byType.get(typeName);
458
+ }
459
+ /**
460
+ * Owned-only prune: delete every chant-owned live resource in the group whose
461
+ * (evaluated) name is not declared in the template. Foreign (non-chant)
462
+ * resources are never touched.
463
+ *
464
+ * ## Where the apiVersion comes from (#1457, #1472)
465
+ *
466
+ * `deleteArmResource` needs an `apiVersion`, and ARM's resource-group listing
467
+ * does not return one per resource. The template is the obvious source, but it
468
+ * only covers types it still declares: declare one storage account, apply,
469
+ * delete it from source, and the template has zero resources of that type.
470
+ * Keying the delete scope off the template alone made the last resource of a
471
+ * type a permanent orphan (#1457 made that a reported skip rather than a
472
+ * silent one).
473
+ *
474
+ * So the apiVersion is resolved in order:
475
+ *
476
+ * 1. the template, when it still declares the type;
477
+ * 2. the lexicon's pinned per-provider registry (`lookupApiVersion`, the same
478
+ * registry the serializer stamps into the template) — deterministic, no
479
+ * extra call, covers every type the lexicon can emit;
480
+ * 3. ARM's provider metadata (`providerApiVersion`) — covers a type the
481
+ * lexicon has never heard of, at one extra call per namespace per run.
482
+ *
483
+ * Only when all three come up empty is the resource reported as
484
+ * `notPrunable: no-api-version`.
485
+ *
486
+ * This matters more since #1448, which routed `ApplyOp`'s `arm` target through
487
+ * `azApply` — before that, the composite reached `--mode Complete` and never
488
+ * came here at all.
489
+ */
490
+ export async function pruneArmOrphans(desired, ctx, http = defaultHttp, signal) {
491
+ const byType = new Map();
492
+ for (const r of desired) {
493
+ const name = String(await evalArmString(r.name, ctx));
494
+ const entry = byType.get(r.type) ?? { keep: new Set(), apiVersion: r.apiVersion };
495
+ entry.keep.add(name);
496
+ byType.set(r.type, entry);
497
+ }
498
+ const providerCache = new Map();
499
+ const resolveApiVersion = async (type) => {
500
+ const fromTemplate = byType.get(type)?.apiVersion;
501
+ if (fromTemplate)
502
+ return fromTemplate;
503
+ let fromRegistry;
504
+ try {
505
+ fromRegistry = lookupApiVersion(type);
506
+ }
507
+ catch {
508
+ // A missing registry is the serializer's problem to report; prune still
509
+ // has the provider metadata to fall back on.
510
+ }
511
+ if (fromRegistry)
512
+ return fromRegistry;
513
+ return providerApiVersion(type, ctx, http, signal, providerCache);
514
+ };
515
+ const pruned = [];
516
+ const notPrunable = [];
517
+ for (const item of await listGroupResources(ctx, http, signal)) {
518
+ // Ownership first, so a foreign resource never reaches the report either —
519
+ // it is not an orphan chant failed to prune, it is simply not chant's.
520
+ if (!isChantOwned(item.tags))
521
+ continue;
522
+ if (byType.get(item.type)?.keep.has(item.name))
523
+ continue;
524
+ const apiVersion = await resolveApiVersion(item.type);
525
+ if (!apiVersion) {
526
+ console.log(`prune: ${item.type}/${item.name} is chant-owned and undeclared, but no apiVersion is ` +
527
+ `available for its type (not in the template, the lexicon registry, or ARM provider metadata) — not deleted`);
528
+ notPrunable.push({ type: item.type, name: item.name, reason: "no-api-version" });
529
+ continue;
530
+ }
531
+ const result = await deleteArmResource(item.type, item.name, apiVersion, ctx, http, signal);
532
+ console.log(`pruned: ${item.type}/${item.name} (${ctx.base})`);
533
+ pruned.push(result);
534
+ }
535
+ return { pruned, notPrunable };
536
+ }
537
+ /**
538
+ * The inverse of {@link azApply} — read a built ARM template and delete the
539
+ * resources it declares, in reverse dependency order (a referrer goes before the
540
+ * resource it references). Idempotent: already-absent resources are a no-op. The
541
+ * Azure twin of `gcpDelete`; `http` is injectable for tests.
542
+ */
543
+ export async function azDelete(args, signal, http = defaultHttp) {
544
+ const base = (args.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, "");
545
+ const ctx = {
546
+ subscriptionId: args.subscriptionId ?? DEFAULT_SUBSCRIPTION,
547
+ resourceGroup: args.resourceGroup,
548
+ location: args.location ?? "eastus",
549
+ deployed: new Map(),
550
+ http,
551
+ base,
552
+ signal,
553
+ };
554
+ const template = JSON.parse(readFileSync(args.templatePath, "utf8"));
555
+ const deleted = [];
556
+ for (const resource of orderArmResources(template.resources ?? []).reverse()) {
557
+ const name = String(await evalArmString(resource.name, ctx));
558
+ const result = await deleteArmResource(resource.type, name, resource.apiVersion, ctx, http, signal);
559
+ console.log(`${result.deleted ? "deleted" : "absent"}: ${resource.type}/${name} (${base})`);
560
+ deleted.push(result);
561
+ }
562
+ return { deleted };
563
+ }
@@ -0,0 +1,48 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execAsync = promisify(exec);
4
+ const DEFAULT_LOCATION = "eastus";
5
+ /**
6
+ * Build the `az group create` command. `az group create` is idempotent — a group
7
+ * that already exists is returned unchanged — so no existence check is needed.
8
+ * Pure — exported for testing.
9
+ */
10
+ export function azGroupEnsureCommand(resourceGroup, location) {
11
+ return `az group create --name ${resourceGroup} --location ${location} --output none`;
12
+ }
13
+ /** Build the `az group delete` command (non-blocking). Pure — exported for testing. */
14
+ export function azGroupDeleteCommand(resourceGroup) {
15
+ return `az group delete --name ${resourceGroup} --yes --no-wait`;
16
+ }
17
+ /**
18
+ * Ensure an Azure resource group exists before an ARM deployment.
19
+ *
20
+ * `az deployment group create` (the `arm` target of `nativeApply`) fails if its
21
+ * resource group is absent — unlike CloudFormation, which creates its own stack.
22
+ * Run this before the apply. Uses fastIdempotent profile — 5m timeout.
23
+ *
24
+ * This targets real Azure (via the logged-in `az` context). Local emulation
25
+ * against floci-az awaits an upstream `Microsoft.Resources/deployments` provider
26
+ * (see #705); when that lands, an endpoint/custom-cloud override slots in here.
27
+ */
28
+ export async function azGroupEnsure(args, signal) {
29
+ const location = args.location ?? DEFAULT_LOCATION;
30
+ const { stdout, stderr } = await execAsync(azGroupEnsureCommand(args.resourceGroup, location), { signal });
31
+ if (stdout)
32
+ console.log(stdout);
33
+ if (stderr)
34
+ console.error(stderr);
35
+ }
36
+ /**
37
+ * Delete an Azure resource group and everything in it (non-blocking). A no-op
38
+ * success when the group is already gone. Uses fastIdempotent profile — 5m
39
+ * timeout (`--no-wait` returns immediately).
40
+ */
41
+ export async function azGroupDelete(args, signal) {
42
+ try {
43
+ await execAsync(azGroupDeleteCommand(args.resourceGroup), { signal });
44
+ }
45
+ catch {
46
+ // Group already absent — treat as success.
47
+ }
48
+ }
@@ -0,0 +1,37 @@
1
+ import { emulatorLifecycle } from "@intentius/chant/op";
2
+ // floci-az is a bespoke ARM fake (not LocalStack) — a plain 200 on its health
3
+ // endpoint means ready. Shared lifecycle: emulatorLifecycle (#746).
4
+ // Pinned rather than `:latest` (#1345): an image that moves underneath a
5
+ // passing local suite is exactly the drift a pin exists to stop.
6
+ export const FLOCI_AZ_SPEC = {
7
+ name: "chant-floci-az",
8
+ image: "floci/floci-az:0.10.0",
9
+ containerPort: 4577,
10
+ healthPath: "/_floci/health",
11
+ upstream: { repo: "floci-io/floci-az" },
12
+ };
13
+ /**
14
+ * The azure plugin's emulator capability (#1345).
15
+ *
16
+ * The spec below has existed since #746; what was missing was declaring it, so
17
+ * `chant emulator up --all` booted Floci and nothing else even though floci-az
18
+ * was one line away. `AZURE_ENDPOINT_URL` is the var azure's own
19
+ * `describe-resources.ts` and `deep-observe.ts` already read on every live
20
+ * call, which is also what makes `--live --env <local>` reach the emulator
21
+ * instead of real Azure.
22
+ */
23
+ export const FLOCI_AZ_EMULATOR = {
24
+ spec: FLOCI_AZ_SPEC,
25
+ env: (endpoint) => ({ AZURE_ENDPOINT_URL: endpoint }),
26
+ };
27
+ const az = emulatorLifecycle(FLOCI_AZ_SPEC);
28
+ export const flociAzExistsCommand = az.existsCommand;
29
+ export const flociAzRmCommand = az.rmCommand;
30
+ export const flociAzHealthUrl = az.healthUrl;
31
+ /** The ARM endpoint URL (what `azApply`'s `endpoint` should point at). */
32
+ export const flociAzEndpoint = az.endpoint;
33
+ export const flociAzRunCommand = (args = {}) => az.runCommand(args);
34
+ /** Boot a local floci-az (Azure emulator) in Docker and return its ARM endpoint. */
35
+ export const flociAzUp = (args = {}, signal) => az.up(args, signal);
36
+ /** Stop and remove the local floci-az container (no-op if already gone). */
37
+ export const flociAzDown = (args = {}, signal) => az.down(args, signal);
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Azure Op activities — resolved by the core activity registry when a project's
3
+ * `chant.config.ts` lists the `azure` lexicon. Contributes the resource-group
4
+ * lifecycle (`azGroupEnsure`/`azGroupDelete`) that an ARM `nativeApply` needs.
5
+ */
6
+ export { azGroupEnsure, azGroupDelete, azGroupEnsureCommand, azGroupDeleteCommand } from "./azure.js";
7
+ // floci-az (Azure emulator) lifecycle — the typed twin of aws's flociUp/Down, so
8
+ // the trio's Azure op boots/tears down the emulator as a modeled step, not a shell.
9
+ export { flociAzUp, flociAzDown, flociAzRunCommand, flociAzRmCommand, flociAzExistsCommand, flociAzHealthUrl, flociAzEndpoint, } from "./floci-az.js";
10
+ export { azApply, azDelete, pruneArmOrphans, providerApiVersion, toApplyResult, deleteArmResource, listGroupResources, chantOwnershipTags, isChantOwned, evalArm, evalArmString, armResourceUrl, armResourceBody, armDependencies, orderArmResources, } from "./az-apply.js";