@intentius/chant-lexicon-azure 0.44.14 → 0.46.0

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 (58) hide show
  1. package/dist/codegen/docs.d.ts.map +1 -1
  2. package/dist/deploy-scopes.d.ts.map +1 -1
  3. package/dist/describe-resources.d.ts.map +1 -1
  4. package/dist/generated/index.d.ts +328 -87
  5. package/dist/generated/index.d.ts.map +1 -1
  6. package/dist/integrity.json +4 -4
  7. package/dist/manifest.json +1 -1
  8. package/dist/meta.json +1923 -427
  9. package/dist/okf/index.md +22 -5
  10. package/dist/okf/rules/AZR028.md +1 -0
  11. package/dist/okf/types/{snapshots.md → Akssnapshots.md} +3 -3
  12. package/dist/okf/types/AzureStackHCIloadBalancers.md +21 -0
  13. package/dist/okf/types/AzureStackHCInatGateways.md +22 -0
  14. package/dist/okf/types/AzureStackHCInetworkSecurityGroups_securityRules.md +19 -0
  15. package/dist/okf/types/AzureStackHCIsnapshots.md +21 -0
  16. package/dist/okf/types/AzureStackHCIstorageContainers.md +21 -0
  17. package/dist/okf/types/AzureStackHCIvirtualMachineInstances.md +21 -0
  18. package/dist/okf/types/AzureStackHCIvirtualMachineInstances_guestAgents.md +18 -0
  19. package/dist/okf/types/AzureStackHCIvirtualNetworks.md +22 -0
  20. package/dist/okf/types/{storageContainers.md → DiscoverystorageContainers.md} +3 -3
  21. package/dist/okf/types/{loadBalancers.md → KubernetesRuntimeloadBalancers.md} +3 -3
  22. package/dist/okf/types/{natGateways.md → NetworknatGateways.md} +4 -4
  23. package/dist/okf/types/{networkSecurityGroups_securityRules.md → NetworknetworkSecurityGroups_securityRules.md} +3 -3
  24. package/dist/okf/types/galleryImages.md +21 -0
  25. package/dist/okf/types/logicalNetworks.md +21 -0
  26. package/dist/okf/types/marketplaceGalleryImages.md +21 -0
  27. package/dist/okf/types/natGateways_inboundRules.md +19 -0
  28. package/dist/okf/types/networkInterfaces.md +25 -0
  29. package/dist/okf/types/networkSecurityGroups.md +22 -0
  30. package/dist/okf/types/publicIPAddresses.md +21 -0
  31. package/dist/okf/types/virtualHardDisks.md +21 -0
  32. package/dist/okf/types/virtualNetworks_subnets.md +19 -0
  33. package/dist/op/activities/az-apply.d.ts +37 -23
  34. package/dist/op/activities/az-apply.d.ts.map +1 -1
  35. package/dist/op/activities/index.d.ts +1 -1
  36. package/dist/op/activities/index.d.ts.map +1 -1
  37. package/dist/serializer.d.ts +14 -0
  38. package/dist/serializer.d.ts.map +1 -1
  39. package/dist/spec/api-versions.d.ts +12 -0
  40. package/dist/spec/api-versions.d.ts.map +1 -1
  41. package/dist/types/index.d.ts +3682 -946
  42. package/dist/validate.d.ts.map +1 -1
  43. package/package.json +2 -2
  44. package/src/codegen/docs.ts +0 -16
  45. package/src/deploy-scopes.ts +19 -5
  46. package/src/describe-resources.ts +8 -0
  47. package/src/generated/index.d.ts +3682 -946
  48. package/src/generated/index.ts +328 -87
  49. package/src/generated/lexicon-azure.json +1923 -427
  50. package/src/lifecycle-integration.test.ts +40 -0
  51. package/src/op/activities/az-apply.test.ts +187 -21
  52. package/src/op/activities/az-apply.ts +104 -29
  53. package/src/op/activities/index.ts +1 -0
  54. package/src/serializer-api-versions.test.ts +94 -0
  55. package/src/serializer.ts +69 -32
  56. package/src/spec/api-versions.test.ts +16 -0
  57. package/src/spec/api-versions.ts +13 -0
  58. package/src/validate.ts +6 -0
@@ -164,5 +164,45 @@ describeObservationConformance({
164
164
  });
165
165
  },
166
166
  },
167
+ {
168
+ name: "a marker-stamped ARM envelope surfaces its stack/env identity (#1222)",
169
+ declared: ["myStore"],
170
+ expectPresent: ["myStore"],
171
+ expectMarker: { myStore: { stack: "shop", env: "prod" } },
172
+ run: () => {
173
+ stubArm(() => ({
174
+ status: 200,
175
+ text: JSON.stringify({
176
+ ...resourceShow,
177
+ tags: { "chant-managed-by": "chant", "chant-stack": "shop", "chant-env": "prod" },
178
+ }),
179
+ }));
180
+ return azurePlugin.describeResources!({
181
+ environment: "prod",
182
+ buildOutput: "",
183
+ entityNames: ["myStore"],
184
+ entities: new Map([
185
+ ["myStore", { entityType: "Microsoft.Storage/storageAccounts", props: { name: "mystore" } }],
186
+ ]),
187
+ });
188
+ },
189
+ },
190
+ {
191
+ name: "an untagged ARM envelope surfaces no marker — never a guess (#1222)",
192
+ declared: ["myStore"],
193
+ expectPresent: ["myStore"],
194
+ expectNoMarker: ["myStore"],
195
+ run: () => {
196
+ stubArm(() => ({ status: 200, text: JSON.stringify(resourceShow) }));
197
+ return azurePlugin.describeResources!({
198
+ environment: "prod",
199
+ buildOutput: "",
200
+ entityNames: ["myStore"],
201
+ entities: new Map([
202
+ ["myStore", { entityType: "Microsoft.Storage/storageAccounts", props: { name: "mystore" } }],
203
+ ]),
204
+ });
205
+ },
206
+ },
167
207
  ],
168
208
  });
@@ -19,7 +19,9 @@ import {
19
19
  type ArmEvalCtx,
20
20
  type ArmResource,
21
21
  type AzHttp,
22
+ providerApiVersion,
22
23
  } from "./az-apply";
24
+ import { lookupApiVersion } from "../../serializer";
23
25
 
24
26
  const noHttp: AzHttp = async () => ({ status: 200, text: "{}" });
25
27
 
@@ -278,7 +280,7 @@ describe("pruneArmOrphans (#azure-prune)", () => {
278
280
  { id: "/1", name: "keep1", type: "Microsoft.Storage/storageAccounts", tags: { "managed-by": "chant" } }, // in template → keep
279
281
  { id: "/2", name: "orphan1", type: "Microsoft.Storage/storageAccounts", tags: { "managed-by": "chant" } }, // owned, not in template → prune
280
282
  { id: "/3", name: "foreign", type: "Microsoft.Storage/storageAccounts", tags: {} }, // not owned → skip
281
- { id: "/4", name: "othertype", type: "Microsoft.Web/sites", tags: { "managed-by": "chant" } }, // type not templated → skip
283
+ { id: "/4", name: "othertype", type: "Microsoft.Web/sites", tags: { "managed-by": "chant" } }, // type not templated → registry apiVersion
282
284
  ],
283
285
  };
284
286
  const deletes: string[] = [];
@@ -287,15 +289,16 @@ describe("pruneArmOrphans (#azure-prune)", () => {
287
289
  return { status: 200, text: method === "GET" ? JSON.stringify(live) : "" };
288
290
  };
289
291
  const { pruned, notPrunable } = await pruneArmOrphans(desired, ctx(), http);
290
- expect(pruned).toEqual([{ type: "Microsoft.Storage/storageAccounts", name: "orphan1", deleted: true }]);
291
- expect(deletes).toHaveLength(1);
292
- expect(deletes[0]).toContain("/storageAccounts/orphan1?api-version=2023-01-01"); // apiVersion from the template
293
- // #1457: "othertype" is chant-owned and undeclared, and its TYPE is absent
294
- // from the template — so there is no apiVersion to delete it with. It used
295
- // to be dropped by the `!entry` guard in silence; now it is reported.
296
- expect(notPrunable).toEqual([
297
- { type: "Microsoft.Web/sites", name: "othertype", reason: "no-api-version" },
292
+ expect(pruned).toEqual([
293
+ { type: "Microsoft.Storage/storageAccounts", name: "orphan1", deleted: true },
294
+ { type: "Microsoft.Web/sites", name: "othertype", deleted: true },
298
295
  ]);
296
+ expect(deletes).toHaveLength(2);
297
+ expect(deletes[0]).toContain("/storageAccounts/orphan1?api-version=2023-01-01"); // apiVersion from the template
298
+ // #1472: "othertype" is chant-owned and undeclared, and its TYPE is absent
299
+ // from the template — the apiVersion comes from the lexicon registry instead.
300
+ expect(deletes[1]).toContain(`/sites/othertype?api-version=${lookupApiVersion("Microsoft.Web/sites")}`);
301
+ expect(notPrunable).toEqual([]);
299
302
  });
300
303
 
301
304
  // #1448 routes ApplyOp's `arm` target here, so this is now the delete scope for
@@ -412,7 +415,7 @@ describe("azDelete (#azure-prune)", () => {
412
415
  * unreachable by prune forever. Prune one of SEVERAL and it works, which is why
413
416
  * this survived testing.
414
417
  */
415
- describe("pruneArmOrphans: a type that left the template (#1457)", () => {
418
+ describe("pruneArmOrphans: a type that left the template (#1457, #1472)", () => {
416
419
  const ctx = (): ArmEvalCtx => ({
417
420
  subscriptionId: "sub",
418
421
  resourceGroup: "rg",
@@ -422,30 +425,193 @@ describe("pruneArmOrphans: a type that left the template (#1457)", () => {
422
425
  base: "http://x",
423
426
  });
424
427
 
425
- const liveHttp = (value: unknown[], deletes: string[]): AzHttp => async (method, url) => {
426
- if (method === "DELETE") deletes.push(url);
427
- return { status: 200, text: method === "GET" ? JSON.stringify({ value }) : "" };
428
- };
428
+ // Only the resource-group listing answers; the provider-metadata endpoint
429
+ // 404s, the way floci-az does.
430
+ const liveHttp = (value: unknown[], deletes: string[], providers: string[] = []): AzHttp =>
431
+ async (method, url) => {
432
+ if (method === "DELETE") deletes.push(url);
433
+ if (method === "GET" && url.includes("/resources?")) return { status: 200, text: JSON.stringify({ value }) };
434
+ if (method === "GET" && url.includes("/providers/")) {
435
+ providers.push(url);
436
+ return { status: 404, text: "" };
437
+ }
438
+ return { status: 200, text: "" };
439
+ };
429
440
 
430
- test("an owned orphan whose type is gone is reported, not silently skipped", async () => {
441
+ test("an owned orphan whose type is gone is deleted with the registry apiVersion", async () => {
431
442
  // The template now declares a DIFFERENT type entirely — the storage account
432
- // was the last of its kind and has been removed from source.
443
+ // was the last of its kind and has been removed from source. #1457 only
444
+ // reported this; the registry the serializer pins from now supplies the
445
+ // apiVersion, so the orphan is actually deleted.
433
446
  const desired: ArmResource[] = [
434
447
  { type: "Microsoft.Web/sites", apiVersion: "2022-03-01", name: "site" },
435
448
  ];
436
449
  const deletes: string[] = [];
450
+ const providers: string[] = [];
437
451
  const http = liveHttp(
438
452
  [{ id: "/1", name: "leftover", type: "Microsoft.Storage/storageAccounts", tags: { "managed-by": "chant" } }],
439
453
  deletes,
454
+ providers,
440
455
  );
441
456
  const { pruned, notPrunable } = await pruneArmOrphans(desired, ctx(), http);
442
- expect(pruned).toEqual([]);
443
- expect(notPrunable).toEqual([
444
- { type: "Microsoft.Storage/storageAccounts", name: "leftover", reason: "no-api-version" },
457
+ expect(pruned).toEqual([{ type: "Microsoft.Storage/storageAccounts", name: "leftover", deleted: true }]);
458
+ expect(notPrunable).toEqual([]);
459
+ const registryVersion = lookupApiVersion("Microsoft.Storage/storageAccounts");
460
+ expect(registryVersion).toBeDefined();
461
+ expect(deletes).toEqual([
462
+ `http://x/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/leftover?api-version=${registryVersion}`,
463
+ ]);
464
+ // The registry answered, so ARM's provider metadata was never consulted.
465
+ expect(providers).toEqual([]);
466
+ });
467
+
468
+ test("the live listing's type casing need not match the registry's", async () => {
469
+ const desired: ArmResource[] = [];
470
+ const deletes: string[] = [];
471
+ const http = liveHttp(
472
+ [{ id: "/1", name: "leftover", type: "microsoft.storage/storageaccounts", tags: { "managed-by": "chant" } }],
473
+ deletes,
474
+ );
475
+ const { pruned, notPrunable } = await pruneArmOrphans(desired, ctx(), http);
476
+ expect(pruned).toEqual([{ type: "microsoft.storage/storageaccounts", name: "leftover", deleted: true }]);
477
+ expect(notPrunable).toEqual([]);
478
+ expect(deletes[0]).toContain(`?api-version=${lookupApiVersion("Microsoft.Storage/storageAccounts")}`);
479
+ });
480
+
481
+ test("a type the registry does not know falls back to ARM provider metadata, newest stable", async () => {
482
+ const desired: ArmResource[] = [];
483
+ const deletes: string[] = [];
484
+ const gets: string[] = [];
485
+ const http: AzHttp = async (method, url) => {
486
+ if (method === "DELETE") deletes.push(url);
487
+ if (method === "GET") gets.push(url);
488
+ if (method === "GET" && url.includes("/resources?")) {
489
+ return {
490
+ status: 200,
491
+ text: JSON.stringify({
492
+ value: [
493
+ { id: "/1", name: "a", type: "Microsoft.Example/widgets", tags: { "managed-by": "chant" } },
494
+ { id: "/2", name: "b", type: "Microsoft.Example/widgets", tags: { "managed-by": "chant" } },
495
+ ],
496
+ }),
497
+ };
498
+ }
499
+ if (method === "GET" && url.includes("/providers/Microsoft.Example?")) {
500
+ return {
501
+ status: 200,
502
+ text: JSON.stringify({
503
+ namespace: "Microsoft.Example",
504
+ resourceTypes: [
505
+ { resourceType: "widgets", apiVersions: ["2024-05-01-preview", "2023-09-01", "2024-01-01", "2022-01-01"] },
506
+ { resourceType: "gadgets", apiVersions: ["2020-01-01"] },
507
+ ],
508
+ }),
509
+ };
510
+ }
511
+ return { status: 200, text: "" };
512
+ };
513
+ const { pruned, notPrunable } = await pruneArmOrphans(desired, ctx(), http);
514
+ expect(pruned).toEqual([
515
+ { type: "Microsoft.Example/widgets", name: "a", deleted: true },
516
+ { type: "Microsoft.Example/widgets", name: "b", deleted: true },
445
517
  ]);
446
- // Still not deleted — the apiVersion genuinely is not available. The fix is
447
- // that it is now visible rather than that it is now deleted.
518
+ expect(notPrunable).toEqual([]);
519
+ // Newest STABLE wins over the newer preview.
520
+ expect(deletes).toEqual([
521
+ "http://x/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Example/widgets/a?api-version=2024-01-01",
522
+ "http://x/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Example/widgets/b?api-version=2024-01-01",
523
+ ]);
524
+ // One provider-metadata call per namespace per run, not per orphan.
525
+ expect(gets.filter((u) => u.includes("/providers/Microsoft.Example?api-version=2021-04-01"))).toHaveLength(1);
526
+ expect(gets.find((u) => u.includes("/providers/Microsoft.Example?"))).toBe(
527
+ "http://x/subscriptions/sub/providers/Microsoft.Example?api-version=2021-04-01",
528
+ );
529
+ });
530
+
531
+ test("no template, no registry entry, no provider metadata → still reported, never deleted", async () => {
532
+ const desired: ArmResource[] = [];
533
+ const deletes: string[] = [];
534
+ const providers: string[] = [];
535
+ const http = liveHttp(
536
+ [{ id: "/1", name: "mystery", type: "Microsoft.Nowhere/things", tags: { "managed-by": "chant" } }],
537
+ deletes,
538
+ providers,
539
+ );
540
+ const { pruned, notPrunable } = await pruneArmOrphans(desired, ctx(), http);
541
+ expect(pruned).toEqual([]);
542
+ expect(notPrunable).toEqual([{ type: "Microsoft.Nowhere/things", name: "mystery", reason: "no-api-version" }]);
448
543
  expect(deletes).toEqual([]);
544
+ // All three sources were tried; the last one is the provider endpoint.
545
+ expect(providers).toEqual(["http://x/subscriptions/sub/providers/Microsoft.Nowhere?api-version=2021-04-01"]);
546
+ });
547
+
548
+ test("providerApiVersion: preview-only types, missing types, and failed calls", async () => {
549
+ const cache = new Map<string, Map<string, string>>();
550
+ let calls = 0;
551
+ const http: AzHttp = async () => {
552
+ calls++;
553
+ return {
554
+ status: 200,
555
+ text: JSON.stringify({
556
+ resourceTypes: [{ resourceType: "previewOnly", apiVersions: ["2023-01-01-preview", "2024-01-01-preview"] }],
557
+ }),
558
+ };
559
+ };
560
+ expect(await providerApiVersion("Microsoft.P/previewOnly", ctx(), http, undefined, cache)).toBe("2024-01-01-preview");
561
+ expect(await providerApiVersion("Microsoft.P/absent", ctx(), http, undefined, cache)).toBeUndefined();
562
+ expect(calls).toBe(1);
563
+ expect(await providerApiVersion("notatype", ctx(), http)).toBeUndefined();
564
+ const failing: AzHttp = async () => { throw new Error("network"); };
565
+ expect(await providerApiVersion("Microsoft.P/previewOnly", ctx(), failing)).toBeUndefined();
566
+ });
567
+
568
+ test("azApply end to end: declare one storage account, apply, remove it, prune → deleted", async () => {
569
+ // The Floci-az style fake: PUT records the resource (with the ownership tag
570
+ // chant stamps), the group listing echoes what was recorded, DELETE removes
571
+ // it. The provider-metadata endpoint is absent, as on floci-az.
572
+ const store = new Map<string, { id: string; name: string; type: string; tags?: Record<string, string> }>();
573
+ const deletes: string[] = [];
574
+ const http: AzHttp = async (method, url, body) => {
575
+ const m = url.match(/\/providers\/([^/]+\/[^/]+)\/([^/?]+)\?api-version=([^&]+)$/);
576
+ if (method === "PUT" && m) {
577
+ const tags = (body as { tags?: Record<string, string> }).tags;
578
+ store.set(`${m[1]}/${m[2]}`, { id: `/${m[2]}`, name: m[2], type: m[1], tags });
579
+ return { status: 200, text: JSON.stringify({ properties: {} }) };
580
+ }
581
+ if (method === "DELETE" && m) {
582
+ deletes.push(url);
583
+ const had = store.delete(`${m[1]}/${m[2]}`);
584
+ return { status: had ? 200 : 404, text: "" };
585
+ }
586
+ if (method === "GET" && url.includes("/resources?")) {
587
+ return { status: 200, text: JSON.stringify({ value: [...store.values()] }) };
588
+ }
589
+ return { status: 404, text: "" };
590
+ };
591
+ const path = `/tmp/chant-1472-az-${process.pid}.json`;
592
+ const account = { type: "Microsoft.Storage/storageAccounts", apiVersion: "2023-01-01", name: "lastone" };
593
+ try {
594
+ writeFileSync(path, JSON.stringify({ resources: [account] }));
595
+ const first = await azApply({ templatePath: path, resourceGroup: "rg", endpoint: "http://x", prune: true }, undefined, http);
596
+ expect(first.applied).toEqual([{ type: account.type, name: "lastone" }]);
597
+ expect(store.size).toBe(1);
598
+
599
+ // Remove the only storage account from source: the template now declares
600
+ // no resource of that type at all.
601
+ writeFileSync(path, JSON.stringify({ resources: [] }));
602
+ const second = await azApply({ templatePath: path, resourceGroup: "rg", endpoint: "http://x", prune: true }, undefined, http);
603
+ expect(second.applied).toEqual([]);
604
+ expect(second.pruned).toEqual([{ type: account.type, name: "lastone", deleted: true }]);
605
+ expect(second.notPrunable).toEqual([]);
606
+ expect(store.size).toBe(0);
607
+ expect(deletes).toEqual([
608
+ `http://x/subscriptions/${"00000000-0000-0000-0000-000000000001"}/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/lastone?api-version=${lookupApiVersion(account.type)}`,
609
+ ]);
610
+ // Core's envelope agrees: nothing left over that could not be attempted.
611
+ expect(toApplyResult(second).notAttempted ?? []).toEqual([]);
612
+ } finally {
613
+ unlinkSync(path);
614
+ }
449
615
  });
450
616
 
451
617
  test("pruning one of several of a type still works — the case that hid this", async () => {
@@ -3,6 +3,8 @@ import { createHash } from "node:crypto";
3
3
  import { safeHeartbeat } from "@intentius/chant/op";
4
4
  import { hasOwnershipMarker, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
5
5
  import { AZURE_TAG_OWNERSHIP_KEYS } from "../../ownership";
6
+ import { lookupApiVersion } from "../../serializer";
7
+ import { compareApiDates } from "../../spec/api-versions";
6
8
  import {
7
9
  applyResult,
8
10
  type ApplyResult,
@@ -501,38 +503,96 @@ export interface AzNotPrunable {
501
503
  type: string;
502
504
  name: string;
503
505
  /**
504
- * `no-api-version` — the resource is owned and undeclared, but its type is
505
- * absent from the current template, which is the only place an `apiVersion`
506
- * comes from. `deleteArmResource` cannot build a URL without one.
506
+ * `no-api-version` — the resource is owned and undeclared, but no apiVersion
507
+ * could be found for its type: the current template does not declare the
508
+ * type, the lexicon's pinned registry has no entry for it, and ARM's
509
+ * provider metadata listed no apiVersion for it (or the call failed).
510
+ * `deleteArmResource` cannot build a URL without one.
507
511
  */
508
512
  reason: "no-api-version";
509
513
  }
510
514
 
515
+ /** The ARM provider-metadata apiVersion (`GET /subscriptions/{sub}/providers/{ns}`). */
516
+ const PROVIDER_METADATA_API_VERSION = "2021-04-01";
517
+
518
+ /**
519
+ * Resolve an apiVersion for a live resource type from ARM's provider metadata:
520
+ * `GET /subscriptions/{sub}/providers/{namespace}?api-version=2021-04-01`
521
+ * returns `resourceTypes[].apiVersions[]`. Picks the newest stable version,
522
+ * falling back to the newest preview when no stable one exists. One call per
523
+ * namespace per prune run; the result is memoised in `cache` so a group with
524
+ * many orphans of one provider costs one round trip. Returns `undefined` when
525
+ * the endpoint is unavailable (floci-az does not implement it) or the type is
526
+ * not listed.
527
+ */
528
+ export async function providerApiVersion(
529
+ resourceType: string,
530
+ ctx: ArmEvalCtx,
531
+ http: AzHttp = defaultHttp,
532
+ signal?: AbortSignal,
533
+ cache: Map<string, Map<string, string>> = new Map(),
534
+ ): Promise<string | undefined> {
535
+ const slash = resourceType.indexOf("/");
536
+ if (slash < 0) return undefined;
537
+ const namespace = resourceType.slice(0, slash);
538
+ const typeName = resourceType.slice(slash + 1).toLowerCase();
539
+ let byType = cache.get(namespace.toLowerCase());
540
+ if (!byType) {
541
+ byType = new Map();
542
+ cache.set(namespace.toLowerCase(), byType);
543
+ const url = `${ctx.base}/subscriptions/${ctx.subscriptionId}/providers/${namespace}?api-version=${PROVIDER_METADATA_API_VERSION}`;
544
+ let res: { status: number; text: string };
545
+ try {
546
+ res = await http("GET", url, undefined, signal);
547
+ } catch {
548
+ return undefined;
549
+ }
550
+ if (res.status >= 300) return undefined;
551
+ let parsed: { resourceTypes?: Array<{ resourceType?: string; apiVersions?: string[] }> };
552
+ try {
553
+ parsed = JSON.parse(res.text);
554
+ } catch {
555
+ return undefined;
556
+ }
557
+ for (const rt of parsed.resourceTypes ?? []) {
558
+ if (!rt?.resourceType || !Array.isArray(rt.apiVersions) || rt.apiVersions.length === 0) continue;
559
+ const versions = rt.apiVersions.filter((v): v is string => typeof v === "string");
560
+ const stable = versions.filter((v) => !v.endsWith("-preview"));
561
+ const pool = stable.length > 0 ? stable : versions;
562
+ if (pool.length === 0) continue;
563
+ const newest = pool.reduce((a, b) => (compareApiDates(b, a) > 0 ? b : a));
564
+ byType.set(rt.resourceType.toLowerCase(), newest);
565
+ }
566
+ }
567
+ return byType.get(typeName);
568
+ }
569
+
511
570
  /**
512
- * Owned-only prune: for each resource type present in the template, delete the
513
- * chant-owned live resources of that type whose (evaluated) name is not in the
514
- * template. Foreign (non-chant) resources are never touched.
571
+ * Owned-only prune: delete every chant-owned live resource in the group whose
572
+ * (evaluated) name is not declared in the template. Foreign (non-chant)
573
+ * resources are never touched.
574
+ *
575
+ * ## Where the apiVersion comes from (#1457, #1472)
515
576
  *
516
- * ## The type-left-the-template hole (#1457)
577
+ * `deleteArmResource` needs an `apiVersion`, and ARM's resource-group listing
578
+ * does not return one per resource. The template is the obvious source, but it
579
+ * only covers types it still declares: declare one storage account, apply,
580
+ * delete it from source, and the template has zero resources of that type.
581
+ * Keying the delete scope off the template alone made the last resource of a
582
+ * type a permanent orphan (#1457 made that a reported skip rather than a
583
+ * silent one).
517
584
  *
518
- * `byType` is keyed off the resources the template CURRENTLY declares, and the
519
- * `!entry` guard skipped any live resource of a type the template no longer
520
- * mentions — before `isChantOwned` was ever consulted.
585
+ * So the apiVersion is resolved in order:
521
586
  *
522
- * So: declare one storage account, apply, then delete it from source. The
523
- * template now has zero resources of that type, `byType` has no entry, and the
524
- * live account is skipped. It is owned, it is undeclared, and prune would never
525
- * touch it on that run or any future one. **Prune the last resource of a type
526
- * and you created a permanent orphan.** Prune one of several and it works,
527
- * which is why it survived testing.
587
+ * 1. the template, when it still declares the type;
588
+ * 2. the lexicon's pinned per-provider registry (`lookupApiVersion`, the same
589
+ * registry the serializer stamps into the template) deterministic, no
590
+ * extra call, covers every type the lexicon can emit;
591
+ * 3. ARM's provider metadata (`providerApiVersion`) covers a type the
592
+ * lexicon has never heard of, at one extra call per namespace per run.
528
593
  *
529
- * The guard cannot simply be removed: `deleteArmResource` needs an
530
- * `apiVersion`, and the template is the only source of one — ARM's
531
- * resource-group listing does not return it per resource. So the skip stays,
532
- * and is now REPORTED rather than silent: a permanent invisible orphan becomes
533
- * a permanent visible one, which is the difference between a bug and a known
534
- * limitation. Resolving it properly wants a per-type apiVersion source (the
535
- * provider metadata endpoint, or a lexicon-side map).
594
+ * Only when all three come up empty is the resource reported as
595
+ * `notPrunable: no-api-version`.
536
596
  *
537
597
  * This matters more since #1448, which routed `ApplyOp`'s `arm` target through
538
598
  * `azApply` — before that, the composite reached `--mode Complete` and never
@@ -555,24 +615,39 @@ export async function pruneArmOrphans(
555
615
  byType.set(r.type, entry);
556
616
  }
557
617
 
618
+ const providerCache = new Map<string, Map<string, string>>();
619
+ const resolveApiVersion = async (type: string): Promise<string | undefined> => {
620
+ const fromTemplate = byType.get(type)?.apiVersion;
621
+ if (fromTemplate) return fromTemplate;
622
+ let fromRegistry: string | undefined;
623
+ try {
624
+ fromRegistry = lookupApiVersion(type);
625
+ } catch {
626
+ // A missing registry is the serializer's problem to report; prune still
627
+ // has the provider metadata to fall back on.
628
+ }
629
+ if (fromRegistry) return fromRegistry;
630
+ return providerApiVersion(type, ctx, http, signal, providerCache);
631
+ };
632
+
558
633
  const pruned: Array<{ type: string; name: string; deleted: boolean }> = [];
559
634
  const notPrunable: AzNotPrunable[] = [];
560
635
  for (const item of await listGroupResources(ctx, http, signal)) {
561
636
  // Ownership first, so a foreign resource never reaches the report either —
562
637
  // it is not an orphan chant failed to prune, it is simply not chant's.
563
638
  if (!isChantOwned(item.tags)) continue;
564
- const entry = byType.get(item.type);
565
- if (!entry) {
639
+ if (byType.get(item.type)?.keep.has(item.name)) continue;
640
+ const apiVersion = await resolveApiVersion(item.type);
641
+ if (!apiVersion) {
566
642
  console.log(
567
- `prune: ${item.type}/${item.name} is chant-owned and undeclared, but its type is absent ` +
568
- `from the template so no apiVersion is available — not deleted`,
643
+ `prune: ${item.type}/${item.name} is chant-owned and undeclared, but no apiVersion is ` +
644
+ `available for its type (not in the template, the lexicon registry, or ARM provider metadata) — not deleted`,
569
645
  );
570
646
  notPrunable.push({ type: item.type, name: item.name, reason: "no-api-version" });
571
647
  continue;
572
648
  }
573
- if (entry.keep.has(item.name)) continue;
574
649
  safeHeartbeat({ step: "azPrune", type: item.type, name: item.name });
575
- const result = await deleteArmResource(item.type, item.name, entry.apiVersion, ctx, http, signal);
650
+ const result = await deleteArmResource(item.type, item.name, apiVersion, ctx, http, signal);
576
651
  console.log(`pruned: ${item.type}/${item.name} (${ctx.base})`);
577
652
  pruned.push(result);
578
653
  }
@@ -23,6 +23,7 @@ export {
23
23
  azApply,
24
24
  azDelete,
25
25
  pruneArmOrphans,
26
+ providerApiVersion,
26
27
  toApplyResult,
27
28
  deleteArmResource,
28
29
  listGroupResources,
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join, dirname } from "path";
5
+ import { fileURLToPath } from "url";
6
+ import { execFileSync } from "child_process";
7
+ import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
8
+ import {
9
+ azureSerializer,
10
+ apiVersionRegistryCandidates,
11
+ getApiVersion,
12
+ loadApiVersions,
13
+ resetApiVersionCache,
14
+ } from "./serializer";
15
+ import { PROVIDER_VERSION_OVERRIDES } from "./spec/api-versions";
16
+
17
+ function makeEntity(entityType: string, props: Record<string, unknown> = {}): any {
18
+ return { [DECLARABLE_MARKER]: true, lexicon: "azure", entityType, kind: "resource", props };
19
+ }
20
+
21
+ // This file runs as ESM (package.json "type": "module"), so `require` is not
22
+ // defined in module scope — the path #1581 broke.
23
+ describe("apiVersion registry under ESM (#1581)", () => {
24
+ afterEach(() => resetApiVersionCache());
25
+
26
+ it("resolves pinned versions in a real ESM process (no require shim)", () => {
27
+ // vitest defines a module-scope `require`, which hid #1581. Run the
28
+ // serializer under plain tsx ESM, where `require` is undefined.
29
+ const here = dirname(fileURLToPath(import.meta.url));
30
+ const script = [
31
+ `import { azureSerializer } from ${JSON.stringify(join(here, "serializer.ts"))};`,
32
+ `import { DECLARABLE_MARKER } from "@intentius/chant/declarable";`,
33
+ `const entities = new Map();`,
34
+ `entities.set("vm", { [DECLARABLE_MARKER]: true, lexicon: "azure", entityType: "Microsoft.Compute/virtualMachines", kind: "resource", props: { name: "vm", location: "eastus" } });`,
35
+ `const t = JSON.parse(azureSerializer.serialize(entities));`,
36
+ `console.log(JSON.stringify({ req: typeof require, apiVersion: t.resources[0].apiVersion }));`,
37
+ ].join("\n");
38
+ const dir = mkdtempSync(join(tmpdir(), "azure-esm-"));
39
+ try {
40
+ const file = join(dir, "probe.mts");
41
+ writeFileSync(file, script);
42
+ const out = execFileSync("npx", ["tsx", file], { cwd: here, encoding: "utf-8", timeout: 120_000 });
43
+ const { req, apiVersion } = JSON.parse(out.trim().split("\n").pop()!);
44
+ expect(req).toBe("undefined");
45
+ expect(PROVIDER_VERSION_OVERRIDES["Microsoft.Compute"]).toContain(apiVersion);
46
+ expect(apiVersion).not.toBe("2023-01-01");
47
+ } finally {
48
+ rmSync(dir, { recursive: true, force: true });
49
+ }
50
+ });
51
+
52
+ it("loads the registry via import.meta.url and uses pinned versions", () => {
53
+ const versions = loadApiVersions();
54
+ expect(versions.size).toBeGreaterThan(100);
55
+
56
+ // The pinned Microsoft.Compute version (spec/api-versions.ts) must reach
57
+ // the serializer, not the uniform 2023-01-01 fallback.
58
+ const computePins = PROVIDER_VERSION_OVERRIDES["Microsoft.Compute"];
59
+ expect(computePins?.length).toBeGreaterThan(0);
60
+ expect(computePins).toContain(versions.get("Microsoft.Compute/virtualMachines"));
61
+
62
+ const entities = new Map<string, any>();
63
+ entities.set("vm", makeEntity("Microsoft.Compute/virtualMachines", { name: "vm", location: "eastus" }));
64
+ entities.set("sa", makeEntity("Microsoft.Storage/storageAccounts", { name: "sa", location: "eastus" }));
65
+ const template = JSON.parse(azureSerializer.serialize(entities) as string);
66
+ const byType = Object.fromEntries(template.resources.map((r: any) => [r.type, r.apiVersion]));
67
+ expect(computePins).toContain(byType["Microsoft.Compute/virtualMachines"]);
68
+ expect(byType["Microsoft.Storage/storageAccounts"]).toBe(versions.get("Microsoft.Storage/storageAccounts"));
69
+ expect(byType["Microsoft.Storage/storageAccounts"]).not.toBe("2023-01-01");
70
+ });
71
+
72
+ it("throws loudly when no registry can be found", () => {
73
+ const dir = mkdtempSync(join(tmpdir(), "azure-apiv-"));
74
+ try {
75
+ expect(() => loadApiVersions(apiVersionRegistryCandidates(dir))).toThrow(/apiVersion registry not found/);
76
+ } finally {
77
+ rmSync(dir, { recursive: true, force: true });
78
+ }
79
+ });
80
+
81
+ it("throws on an empty registry rather than falling back uniformly", () => {
82
+ const dir = mkdtempSync(join(tmpdir(), "azure-apiv-"));
83
+ try {
84
+ writeFileSync(join(dir, "meta.json"), "{}");
85
+ expect(() => loadApiVersions([join(dir, "meta.json")])).toThrow(/registry is empty/);
86
+ } finally {
87
+ rmSync(dir, { recursive: true, force: true });
88
+ }
89
+ });
90
+
91
+ it("keeps explicit overrides ahead of the registry", () => {
92
+ expect(getApiVersion("Microsoft.Authorization/roleAssignments")).toBe("2022-04-01");
93
+ });
94
+ });