@intentius/chant-lexicon-azure 0.39.0 → 0.41.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "4891e582720db1641de863bc9b7a6defc9eb96996d49586fa3bda27b51f75076",
4
+ "manifest.json": "7a3fb1cb00d88162dfce29698e3f3ce481d64c21d5103ae2c471b92d9470b371",
5
5
  "meta.json": "051fb4ee43f7de48a36f1abc59c8ff5207c3b75aa28f13db6440b9f5b0337605",
6
6
  "types/index.d.ts": "617a6de433bd92a126170a7f5ae903d6d7aa83fbb0b57169d3450b5a13e72e0d",
7
7
  "rules/hardcoded-location.ts": "a9b1d1cec93f2ca9c5206641f53fc9621cefcfd2c00b3ab89c194b30a75fa949",
@@ -33,5 +33,5 @@
33
33
  "skills/chant-azure-patterns.md": "1a3bacfac612826b77332d2692a59195638db9f1b5e8ea2eda7579621d25f603",
34
34
  "skills/chant-azure-aks.md": "2d4e0098c1a22b54ffadd410564d9df0f3a04cb4eb7c6261f20ae33106e7aca8"
35
35
  },
36
- "composite": "07dea253252664686f6944b8ae25fc4d8d8511bcc26f77ade3eb188128ea04b4"
36
+ "composite": "579545b4d3b7b8be7eae56689bbdcd3f4289c3580805f527b5aa93a6882e8023"
37
37
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azure",
3
- "version": "0.39.0",
3
+ "version": "0.41.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Azure",
6
6
  "intrinsics": [
@@ -1,3 +1,4 @@
1
+ import { type ApplyResult } from "@intentius/chant/apply";
1
2
  /** One ARM resource from a `deploymentTemplate.json` `resources[]`. */
2
3
  export interface ArmResource {
3
4
  type: string;
@@ -82,10 +83,34 @@ export declare function azApply(args: AzApplyArgs, signal?: AbortSignal, http?:
82
83
  name: string;
83
84
  deleted: boolean;
84
85
  }>;
86
+ /** Owned, undeclared resources the prune could not delete (#1457). Empty when
87
+ * `prune` is off, and empty on a clean prune. */
88
+ notPrunable: AzNotPrunable[];
85
89
  }>;
86
- /** The ownership tag azApply stamps on every resource it applies. */
90
+ /**
91
+ * The ownership tags azApply stamps on every resource it applies.
92
+ *
93
+ * Stamps the declared channel's key. The legacy key goes on too, so a resource
94
+ * applied by this chant is still recognised by an older one mid-rollout; it is
95
+ * transitional and can be dropped once no deployed estate predates #1446.
96
+ *
97
+ * Only the managed-by marker, not stack/env: this applier has no
98
+ * {@link OwnershipMarker} to hand — the serializer stamps those from project
99
+ * config. Same shape the fly serializer uses when no ownership context is set.
100
+ */
87
101
  export declare function chantOwnershipTags(): Record<string, string>;
88
- /** Whether a resource's tags mark it chant-owned. */
102
+ /**
103
+ * Whether a resource's tags mark it chant-owned.
104
+ *
105
+ * Resolves through core's `hasOwnershipMarker` against the lexicon's declared
106
+ * channel (#1446), so this predicate and the serializer's stamp can no longer
107
+ * drift — the arrangement the fly lexicon already had.
108
+ *
109
+ * The legacy key is accepted as well, and the union is deliberate: it is
110
+ * strictly more than this predicate recognised before, so nothing that was
111
+ * prunable stops being prunable, and resources the serializer marked become
112
+ * prunable for the first time.
113
+ */
89
114
  export declare function isChantOwned(tags: Record<string, string> | null | undefined): boolean;
90
115
  /** One resource from the ARM resource-group listing. */
91
116
  export interface ArmListItem {
@@ -103,17 +128,78 @@ export declare function deleteArmResource(type: string, name: string, apiVersion
103
128
  deleted: boolean;
104
129
  }>;
105
130
  /**
106
- * Owned-only prune: for each resource type present in the template, delete the
107
- * chant-owned live resources of that type whose (evaluated) name is not in the
108
- * template. Scoped to templated typeslike the GCP applier so a type chant
109
- * isn't managing this run is left alone, and the type's `apiVersion` is taken
110
- * from the template. Foreign (non-chant) resources are never touched.
131
+ * Normalize an azApply/azDelete result into core's apply envelope (#1446).
132
+ *
133
+ * The lexicon keeps its own shape ARM `type`, per-resource applied/pruned
134
+ * and this projects it onto the shared tri-state so a caller can read any
135
+ * applier's result the same way.
111
136
  */
112
- export declare function pruneArmOrphans(desired: ArmResource[], ctx: ArmEvalCtx, http?: AzHttp, signal?: AbortSignal): Promise<Array<{
137
+ export declare function toApplyResult(result: {
138
+ applied?: Array<{
139
+ type: string;
140
+ name: string;
141
+ }>;
142
+ pruned?: Array<{
143
+ type: string;
144
+ name: string;
145
+ deleted: boolean;
146
+ }>;
147
+ deleted?: Array<{
148
+ type: string;
149
+ name: string;
150
+ deleted: boolean;
151
+ }>;
152
+ notPrunable?: AzNotPrunable[];
153
+ }): ApplyResult;
154
+ /** A chant-owned live resource an owned-only prune could not delete, and why (#1457). */
155
+ export interface AzNotPrunable {
113
156
  type: string;
114
157
  name: string;
115
- deleted: boolean;
116
- }>>;
158
+ /**
159
+ * `no-api-version` — the resource is owned and undeclared, but its type is
160
+ * absent from the current template, which is the only place an `apiVersion`
161
+ * comes from. `deleteArmResource` cannot build a URL without one.
162
+ */
163
+ reason: "no-api-version";
164
+ }
165
+ /**
166
+ * Owned-only prune: for each resource type present in the template, delete the
167
+ * chant-owned live resources of that type whose (evaluated) name is not in the
168
+ * template. Foreign (non-chant) resources are never touched.
169
+ *
170
+ * ## The type-left-the-template hole (#1457)
171
+ *
172
+ * `byType` is keyed off the resources the template CURRENTLY declares, and the
173
+ * `!entry` guard skipped any live resource of a type the template no longer
174
+ * mentions — before `isChantOwned` was ever consulted.
175
+ *
176
+ * So: declare one storage account, apply, then delete it from source. The
177
+ * template now has zero resources of that type, `byType` has no entry, and the
178
+ * live account is skipped. It is owned, it is undeclared, and prune would never
179
+ * touch it — on that run or any future one. **Prune the last resource of a type
180
+ * and you created a permanent orphan.** Prune one of several and it works,
181
+ * which is why it survived testing.
182
+ *
183
+ * The guard cannot simply be removed: `deleteArmResource` needs an
184
+ * `apiVersion`, and the template is the only source of one — ARM's
185
+ * resource-group listing does not return it per resource. So the skip stays,
186
+ * and is now REPORTED rather than silent: a permanent invisible orphan becomes
187
+ * a permanent visible one, which is the difference between a bug and a known
188
+ * limitation. Resolving it properly wants a per-type apiVersion source (the
189
+ * provider metadata endpoint, or a lexicon-side map).
190
+ *
191
+ * This matters more since #1448, which routed `ApplyOp`'s `arm` target through
192
+ * `azApply` — before that, the composite reached `--mode Complete` and never
193
+ * came here at all.
194
+ */
195
+ export declare function pruneArmOrphans(desired: ArmResource[], ctx: ArmEvalCtx, http?: AzHttp, signal?: AbortSignal): Promise<{
196
+ pruned: Array<{
197
+ type: string;
198
+ name: string;
199
+ deleted: boolean;
200
+ }>;
201
+ notPrunable: AzNotPrunable[];
202
+ }>;
117
203
  /**
118
204
  * The inverse of {@link azApply} — read a built ARM template and delete the
119
205
  * resources it declares, in reverse dependency order (a referrer goes before the
@@ -1 +1 @@
1
- {"version":3,"file":"az-apply.d.ts","sourceRoot":"","sources":["../../../src/op/activities/az-apply.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"az-apply.d.ts","sourceRoot":"","sources":["../../../src/op/activities/az-apply.ts"],"names":[],"mappings":"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"}
@@ -7,6 +7,6 @@ export { azGroupEnsure, azGroupDelete, azGroupEnsureCommand, azGroupDeleteComman
7
7
  export type { AzGroupEnsureArgs, AzGroupDeleteArgs } from "./azure.js";
8
8
  export { flociAzUp, flociAzDown, flociAzRunCommand, flociAzRmCommand, flociAzExistsCommand, flociAzHealthUrl, flociAzEndpoint, } from "./floci-az.js";
9
9
  export type { FlociAzUpArgs, FlociAzDownArgs } from "./floci-az.js";
10
- export { azApply, azDelete, pruneArmOrphans, deleteArmResource, listGroupResources, chantOwnershipTags, isChantOwned, evalArm, evalArmString, armResourceUrl, armResourceBody, armDependencies, orderArmResources, } from "./az-apply.js";
10
+ export { azApply, azDelete, pruneArmOrphans, toApplyResult, deleteArmResource, listGroupResources, chantOwnershipTags, isChantOwned, evalArm, evalArmString, armResourceUrl, armResourceBody, armDependencies, orderArmResources, } from "./az-apply.js";
11
11
  export type { AzApplyArgs, ArmEvalCtx, ArmResource, ArmListItem, AzHttp } from "./az-apply.js";
12
12
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/op/activities/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AACnG,YAAY,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAIpE,OAAO,EACL,SAAS,EACT,WAAW,EACX,iBAAiB,EACjB,gBAAgB,EAChB,oBAAoB,EACpB,gBAAgB,EAChB,eAAe,GAChB,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAEjE,OAAO,EACL,OAAO,EACP,QAAQ,EACR,eAAe,EACf,iBAAiB,EACjB,kBAAkB,EAClB,kBAAkB,EAClB,YAAY,EACZ,OAAO,EACP,aAAa,EACb,cAAc,EACd,eAAe,EACf,eAAe,EACf,iBAAiB,GAClB,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/op/activities/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AACnG,YAAY,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAIpE,OAAO,EACL,SAAS,EACT,WAAW,EACX,iBAAiB,EACjB,gBAAgB,EAChB,oBAAoB,EACpB,gBAAgB,EAChB,eAAe,GAChB,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAEjE,OAAO,EACL,OAAO,EACP,QAAQ,EACR,eAAe,EACf,aAAa,EACb,iBAAiB,EACjB,kBAAkB,EAClB,kBAAkB,EAClB,YAAY,EACZ,OAAO,EACP,aAAa,EACb,cAAc,EACd,eAAe,EACf,eAAe,EACf,iBAAiB,GAClB,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-azure",
3
- "version": "0.39.0",
3
+ "version": "0.41.0",
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,7 +74,7 @@
74
74
  "typescript": "^5.9.3"
75
75
  },
76
76
  "peerDependencies": {
77
- "@intentius/chant": "^0.39.0",
77
+ "@intentius/chant": "^0.41.0",
78
78
  "typescript": "^5.9.3"
79
79
  }
80
80
  }
@@ -1,4 +1,6 @@
1
1
  import { describe, test, expect } from "vitest";
2
+ import { writeFileSync, unlinkSync } from "node:fs";
3
+ import { describeApplyConformance } from "@intentius/chant-test-utils";
2
4
  import {
3
5
  evalArmString,
4
6
  evalArm,
@@ -9,6 +11,7 @@ import {
9
11
  azApply,
10
12
  azDelete,
11
13
  pruneArmOrphans,
14
+ toApplyResult,
12
15
  deleteArmResource,
13
16
  listGroupResources,
14
17
  chantOwnershipTags,
@@ -200,10 +203,30 @@ describe("azApply flow (#707)", () => {
200
203
  });
201
204
 
202
205
  describe("ownership helpers (#azure-prune)", () => {
203
- test("chantOwnershipTags / isChantOwned", () => {
204
- expect(chantOwnershipTags()).toEqual({ "managed-by": "chant" });
206
+ // #1446: the applier used a bare `managed-by` while the lexicon's declared
207
+ // channel (AZURE_TAG_OWNERSHIP_KEYS, what the serializer stamps and
208
+ // live-export filters on) is `chant-managed-by`. Both are ordinary ARM tag
209
+ // keys on the same surface, so nothing forced the difference — and it meant a
210
+ // resource carrying only the serializer's marker read as foreign here.
211
+ test("stamps the lexicon's declared channel, plus the legacy key", () => {
212
+ expect(chantOwnershipTags()).toEqual({
213
+ "chant-managed-by": "chant",
214
+ "managed-by": "chant",
215
+ });
216
+ });
217
+
218
+ test("recognises the declared channel — the marker the serializer stamps", () => {
219
+ expect(isChantOwned({ "chant-managed-by": "chant" })).toBe(true);
220
+ });
221
+
222
+ test("still recognises the legacy key, so nothing already deployed is orphaned", () => {
205
223
  expect(isChantOwned({ "managed-by": "chant" })).toBe(true);
224
+ });
225
+
226
+ test("a foreign or unmarked resource is still foreign", () => {
206
227
  expect(isChantOwned({ "managed-by": "someone-else" })).toBe(false);
228
+ expect(isChantOwned({ "chant-managed-by": "terraform" })).toBe(false);
229
+ expect(isChantOwned({})).toBe(false);
207
230
  expect(isChantOwned(undefined)).toBe(false);
208
231
  });
209
232
  });
@@ -263,10 +286,16 @@ describe("pruneArmOrphans (#azure-prune)", () => {
263
286
  if (method === "DELETE") deletes.push(url);
264
287
  return { status: 200, text: method === "GET" ? JSON.stringify(live) : "" };
265
288
  };
266
- const pruned = await pruneArmOrphans(desired, ctx(), http);
289
+ const { pruned, notPrunable } = await pruneArmOrphans(desired, ctx(), http);
267
290
  expect(pruned).toEqual([{ type: "Microsoft.Storage/storageAccounts", name: "orphan1", deleted: true }]);
268
291
  expect(deletes).toHaveLength(1);
269
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" },
298
+ ]);
270
299
  });
271
300
 
272
301
  // #1448 routes ApplyOp's `arm` target here, so this is now the delete scope for
@@ -285,9 +314,12 @@ describe("pruneArmOrphans (#azure-prune)", () => {
285
314
  if (method === "DELETE") deletes.push(url);
286
315
  return { status: 200, text: method === "GET" ? JSON.stringify(live) : "" };
287
316
  };
288
- const pruned = await pruneArmOrphans(desired, ctx(), http);
317
+ const { pruned, notPrunable } = await pruneArmOrphans(desired, ctx(), http);
289
318
  expect(deletes).toEqual([]);
290
319
  expect(pruned).toEqual([]);
320
+ // Foreign resources are not chant's orphans, so they are not reported as
321
+ // ones chant failed to prune either — ownership is checked first.
322
+ expect(notPrunable).toEqual([]);
291
323
  });
292
324
 
293
325
  // The caveat that was a comment on OWNERSHIP_TAG_KEY: floci-az drops resource
@@ -305,7 +337,7 @@ describe("pruneArmOrphans (#azure-prune)", () => {
305
337
  if (method === "DELETE") deletes.push(url);
306
338
  return { status: 200, text: method === "GET" ? JSON.stringify(live) : "" };
307
339
  };
308
- expect(await pruneArmOrphans(desired, ctx(), http)).toEqual([]);
340
+ expect(await pruneArmOrphans(desired, ctx(), http)).toEqual({ pruned: [], notPrunable: [] });
309
341
  expect(deletes).toEqual([]);
310
342
  });
311
343
  });
@@ -369,3 +401,169 @@ describe("azDelete (#azure-prune)", () => {
369
401
  expect(deletes[1]).toContain("/serverfarms/plan1");
370
402
  });
371
403
  });
404
+
405
+ /**
406
+ * #1457 — the permanent-orphan case, stated as its own scenario rather than a
407
+ * clause in another test.
408
+ *
409
+ * Prune the LAST resource of a type and the template stops mentioning that type
410
+ * at all. `byType` then has no entry, and the `!entry` guard skipped the live
411
+ * resource before ownership was ever checked. It is owned, undeclared, and was
412
+ * unreachable by prune forever. Prune one of SEVERAL and it works, which is why
413
+ * this survived testing.
414
+ */
415
+ describe("pruneArmOrphans: a type that left the template (#1457)", () => {
416
+ const ctx = (): ArmEvalCtx => ({
417
+ subscriptionId: "sub",
418
+ resourceGroup: "rg",
419
+ location: "eastus",
420
+ deployed: new Map(),
421
+ http: async () => ({ status: 200, text: "" }),
422
+ base: "http://x",
423
+ });
424
+
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
+ };
429
+
430
+ test("an owned orphan whose type is gone is reported, not silently skipped", async () => {
431
+ // The template now declares a DIFFERENT type entirely — the storage account
432
+ // was the last of its kind and has been removed from source.
433
+ const desired: ArmResource[] = [
434
+ { type: "Microsoft.Web/sites", apiVersion: "2022-03-01", name: "site" },
435
+ ];
436
+ const deletes: string[] = [];
437
+ const http = liveHttp(
438
+ [{ id: "/1", name: "leftover", type: "Microsoft.Storage/storageAccounts", tags: { "managed-by": "chant" } }],
439
+ deletes,
440
+ );
441
+ 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" },
445
+ ]);
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.
448
+ expect(deletes).toEqual([]);
449
+ });
450
+
451
+ test("pruning one of several of a type still works — the case that hid this", async () => {
452
+ const desired: ArmResource[] = [
453
+ { type: "Microsoft.Storage/storageAccounts", apiVersion: "2023-01-01", name: "keep" },
454
+ ];
455
+ const deletes: string[] = [];
456
+ const http = liveHttp(
457
+ [
458
+ { id: "/1", name: "keep", type: "Microsoft.Storage/storageAccounts", tags: { "managed-by": "chant" } },
459
+ { id: "/2", name: "gone", type: "Microsoft.Storage/storageAccounts", tags: { "managed-by": "chant" } },
460
+ ],
461
+ deletes,
462
+ );
463
+ const { pruned, notPrunable } = await pruneArmOrphans(desired, ctx(), http);
464
+ expect(pruned).toEqual([{ type: "Microsoft.Storage/storageAccounts", name: "gone", deleted: true }]);
465
+ expect(notPrunable).toEqual([]);
466
+ expect(deletes).toHaveLength(1);
467
+ });
468
+
469
+ test("a foreign resource of a departed type is not reported as chant's problem", async () => {
470
+ const desired: ArmResource[] = [
471
+ { type: "Microsoft.Web/sites", apiVersion: "2022-03-01", name: "site" },
472
+ ];
473
+ const deletes: string[] = [];
474
+ const http = liveHttp(
475
+ [{ id: "/1", name: "theirs", type: "Microsoft.Storage/storageAccounts", tags: { "managed-by": "terraform" } }],
476
+ deletes,
477
+ );
478
+ const { pruned, notPrunable } = await pruneArmOrphans(desired, ctx(), http);
479
+ expect(pruned).toEqual([]);
480
+ expect(notPrunable).toEqual([]);
481
+ expect(deletes).toEqual([]);
482
+ });
483
+ });
484
+
485
+ /**
486
+ * The shared apply-contract suite (#1446), run against azure's own mocked
487
+ * transport. Assertion 4 — the owned-only prune, asserted on the transport — is
488
+ * the one that matters most here: it is what #1448 violated, and what the
489
+ * two-key split found in #1446 made unreliable even after #1448.
490
+ */
491
+ describeApplyConformance({
492
+ lexicon: "azure",
493
+ scenarios: [
494
+ {
495
+ name: "a template of two resources",
496
+ plan: [
497
+ { kind: "Microsoft.Storage/storageAccounts", name: "one" },
498
+ { kind: "Microsoft.Storage/storageAccounts", name: "two" },
499
+ ],
500
+ run: async () => {
501
+ const path = `/tmp/chant-1446-az-${process.pid}.json`;
502
+ writeFileSync(
503
+ path,
504
+ JSON.stringify({
505
+ resources: [
506
+ { type: "Microsoft.Storage/storageAccounts", apiVersion: "2023-01-01", name: "one" },
507
+ { type: "Microsoft.Storage/storageAccounts", apiVersion: "2023-01-01", name: "two" },
508
+ ],
509
+ }),
510
+ );
511
+ try {
512
+ const http: AzHttp = async () => ({ status: 200, text: "{}" });
513
+ return toApplyResult(
514
+ await azApply({ templatePath: path, resourceGroup: "rg", endpoint: "http://x" }, undefined, http),
515
+ );
516
+ } finally {
517
+ unlinkSync(path);
518
+ }
519
+ },
520
+ expectApplied: [
521
+ "Microsoft.Storage/storageAccounts/one",
522
+ "Microsoft.Storage/storageAccounts/two",
523
+ ],
524
+ },
525
+ ],
526
+ pruneScenarios: [
527
+ {
528
+ name: "an owned orphan beside a foreign resource in the same group",
529
+ ownedOrphan: "orphan",
530
+ foreign: "foreign",
531
+ run: async () => {
532
+ const deletes: string[] = [];
533
+ const live = {
534
+ value: [
535
+ { id: "/1", name: "keep", type: "Microsoft.Storage/storageAccounts", tags: { "chant-managed-by": "chant" } },
536
+ { id: "/2", name: "orphan", type: "Microsoft.Storage/storageAccounts", tags: { "chant-managed-by": "chant" } },
537
+ { id: "/3", name: "foreign", type: "Microsoft.Storage/storageAccounts", tags: { "managed-by": "terraform" } },
538
+ ],
539
+ };
540
+ const path = `/tmp/chant-1446-az-prune-${process.pid}.json`;
541
+ writeFileSync(
542
+ path,
543
+ JSON.stringify({
544
+ resources: [{ type: "Microsoft.Storage/storageAccounts", apiVersion: "2023-01-01", name: "keep" }],
545
+ }),
546
+ );
547
+ try {
548
+ const http: AzHttp = async (method, url) => {
549
+ if (method === "DELETE") deletes.push(url);
550
+ return {
551
+ status: 200,
552
+ text: method === "GET" && url.includes("/resources?") ? JSON.stringify(live) : "{}",
553
+ };
554
+ };
555
+ const result = toApplyResult(
556
+ await azApply(
557
+ { templatePath: path, resourceGroup: "rg", endpoint: "http://x", prune: true },
558
+ undefined,
559
+ http,
560
+ ),
561
+ );
562
+ return { result, deletes };
563
+ } finally {
564
+ unlinkSync(path);
565
+ }
566
+ },
567
+ },
568
+ ],
569
+ });
@@ -1,6 +1,14 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
3
  import { safeHeartbeat } from "@intentius/chant/op";
4
+ import { hasOwnershipMarker, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
5
+ import { AZURE_TAG_OWNERSHIP_KEYS } from "../../ownership";
6
+ import {
7
+ applyResult,
8
+ type ApplyResult,
9
+ type AppliedResource,
10
+ type NotAttemptedResource,
11
+ } from "@intentius/chant/apply";
4
12
 
5
13
  // The floci-az default local subscription (from its ARM management-plane mock).
6
14
  const DEFAULT_SUBSCRIPTION = "00000000-0000-0000-0000-000000000001";
@@ -296,7 +304,13 @@ export async function azApply(
296
304
  args: AzApplyArgs,
297
305
  signal?: AbortSignal,
298
306
  http: AzHttp = defaultHttp,
299
- ): Promise<{ applied: Array<{ type: string; name: string }>; pruned: Array<{ type: string; name: string; deleted: boolean }> }> {
307
+ ): Promise<{
308
+ applied: Array<{ type: string; name: string }>;
309
+ pruned: Array<{ type: string; name: string; deleted: boolean }>;
310
+ /** Owned, undeclared resources the prune could not delete (#1457). Empty when
311
+ * `prune` is off, and empty on a clean prune. */
312
+ notPrunable: AzNotPrunable[];
313
+ }> {
300
314
  const base = (args.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, "");
301
315
  const ctx: ArmEvalCtx = {
302
316
  subscriptionId: args.subscriptionId ?? DEFAULT_SUBSCRIPTION,
@@ -340,8 +354,10 @@ export async function azApply(
340
354
  applied.push({ type: resource.type, name });
341
355
  }
342
356
 
343
- const pruned = args.prune ? await pruneArmOrphans(resources, ctx, http, signal) : [];
344
- return { applied, pruned };
357
+ const prune = args.prune
358
+ ? await pruneArmOrphans(resources, ctx, http, signal)
359
+ : { pruned: [], notPrunable: [] };
360
+ return { applied, pruned: prune.pruned, notPrunable: prune.notPrunable };
345
361
  }
346
362
 
347
363
  // ── Ownership + prune + delete ────────────────────────────────────────────────
@@ -351,17 +367,62 @@ export async function azApply(
351
367
  // touched. Real Azure persists resource tags; note that floci-az currently drops
352
368
  // them, so owned-only prune only takes effect against real Azure (the delete
353
369
  // mechanics themselves work against either — see azDelete).
354
- const OWNERSHIP_TAG_KEY = "managed-by";
355
- const OWNERSHIP_TAG_VALUE = "chant";
356
370
 
357
- /** The ownership tag azApply stamps on every resource it applies. */
371
+ /**
372
+ * The key this applier used before #1446, and still recognises.
373
+ *
374
+ * The lexicon declares its marker channel in `../../ownership.ts`
375
+ * (`chant-managed-by`) — the key the serializer stamps, the plugin registers as
376
+ * `ownershipChannel`, and `import/live-export.ts` filters on. This applier used
377
+ * a bare `managed-by` instead, so the two never agreed.
378
+ *
379
+ * That is not cosmetic. Both are ordinary ARM tag keys on the same surface, so
380
+ * nothing forced the difference — and it meant a resource carrying only the
381
+ * SERIALIZER's marker was invisible to this prune. Anything deployed by a path
382
+ * other than `azApply` reads as foreign and is never pruned, including
383
+ * everything `ApplyOp` deployed before #1448 routed it here from
384
+ * `az deployment --mode Complete`.
385
+ *
386
+ * (GCP has the same-looking split for a real reason: its serializer stamps a
387
+ * Config Connector object, where `app.kubernetes.io/managed-by` is a legal
388
+ * Kubernetes label, while `gcpApply` writes GCP REST labels, where it is not.
389
+ * Two surfaces, two key vocabularies. Azure has one surface.)
390
+ */
391
+ const LEGACY_OWNERSHIP_TAG_KEY = "managed-by";
392
+
393
+ /**
394
+ * The ownership tags azApply stamps on every resource it applies.
395
+ *
396
+ * Stamps the declared channel's key. The legacy key goes on too, so a resource
397
+ * applied by this chant is still recognised by an older one mid-rollout; it is
398
+ * transitional and can be dropped once no deployed estate predates #1446.
399
+ *
400
+ * Only the managed-by marker, not stack/env: this applier has no
401
+ * {@link OwnershipMarker} to hand — the serializer stamps those from project
402
+ * config. Same shape the fly serializer uses when no ownership context is set.
403
+ */
358
404
  export function chantOwnershipTags(): Record<string, string> {
359
- return { [OWNERSHIP_TAG_KEY]: OWNERSHIP_TAG_VALUE };
405
+ return {
406
+ [AZURE_TAG_OWNERSHIP_KEYS.managedBy]: OWNERSHIP_MANAGED_BY_VALUE,
407
+ [LEGACY_OWNERSHIP_TAG_KEY]: OWNERSHIP_MANAGED_BY_VALUE,
408
+ };
360
409
  }
361
410
 
362
- /** Whether a resource's tags mark it chant-owned. */
411
+ /**
412
+ * Whether a resource's tags mark it chant-owned.
413
+ *
414
+ * Resolves through core's `hasOwnershipMarker` against the lexicon's declared
415
+ * channel (#1446), so this predicate and the serializer's stamp can no longer
416
+ * drift — the arrangement the fly lexicon already had.
417
+ *
418
+ * The legacy key is accepted as well, and the union is deliberate: it is
419
+ * strictly more than this predicate recognised before, so nothing that was
420
+ * prunable stops being prunable, and resources the serializer marked become
421
+ * prunable for the first time.
422
+ */
363
423
  export function isChantOwned(tags: Record<string, string> | null | undefined): boolean {
364
- return tags?.[OWNERSHIP_TAG_KEY] === OWNERSHIP_TAG_VALUE;
424
+ if (hasOwnershipMarker(tags ?? undefined, AZURE_TAG_OWNERSHIP_KEYS)) return true;
425
+ return tags?.[LEGACY_OWNERSHIP_TAG_KEY] === OWNERSHIP_MANAGED_BY_VALUE;
365
426
  }
366
427
 
367
428
  /** One resource from the ARM resource-group listing. */
@@ -400,19 +461,92 @@ export async function deleteArmResource(
400
461
  return { type, name, deleted: true };
401
462
  }
402
463
 
464
+ /**
465
+ * Normalize an azApply/azDelete result into core's apply envelope (#1446).
466
+ *
467
+ * The lexicon keeps its own shape — ARM `type`, per-resource applied/pruned —
468
+ * and this projects it onto the shared tri-state so a caller can read any
469
+ * applier's result the same way.
470
+ */
471
+ export function toApplyResult(result: {
472
+ applied?: Array<{ type: string; name: string }>;
473
+ pruned?: Array<{ type: string; name: string; deleted: boolean }>;
474
+ deleted?: Array<{ type: string; name: string; deleted: boolean }>;
475
+ notPrunable?: AzNotPrunable[];
476
+ }): ApplyResult {
477
+ const applied: AppliedResource[] = (result.applied ?? []).map((a) => ({
478
+ kind: a.type,
479
+ name: a.name,
480
+ // ARM PUT is a create-or-update with no distinguishing response, so this
481
+ // applier cannot tell the two apart. `updated` is the honest verdict for a
482
+ // call that converged without claiming which it did.
483
+ action: "updated",
484
+ }));
485
+ const notAttempted: NotAttemptedResource[] = (result.notPrunable ?? []).map((n) => ({
486
+ kind: n.type,
487
+ name: n.name,
488
+ reason: "not-prunable" as const,
489
+ detail: n.reason,
490
+ }));
491
+ const pruned = (result.pruned ?? result.deleted ?? []).map((p) => ({
492
+ kind: p.type,
493
+ name: p.name,
494
+ deleted: p.deleted,
495
+ }));
496
+ return applyResult(applied, pruned, notAttempted);
497
+ }
498
+
499
+ /** A chant-owned live resource an owned-only prune could not delete, and why (#1457). */
500
+ export interface AzNotPrunable {
501
+ type: string;
502
+ name: string;
503
+ /**
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.
507
+ */
508
+ reason: "no-api-version";
509
+ }
510
+
403
511
  /**
404
512
  * Owned-only prune: for each resource type present in the template, delete the
405
513
  * chant-owned live resources of that type whose (evaluated) name is not in the
406
- * template. Scoped to templated types like the GCP applier — so a type chant
407
- * isn't managing this run is left alone, and the type's `apiVersion` is taken
408
- * from the template. Foreign (non-chant) resources are never touched.
514
+ * template. Foreign (non-chant) resources are never touched.
515
+ *
516
+ * ## The type-left-the-template hole (#1457)
517
+ *
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.
521
+ *
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.
528
+ *
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).
536
+ *
537
+ * This matters more since #1448, which routed `ApplyOp`'s `arm` target through
538
+ * `azApply` — before that, the composite reached `--mode Complete` and never
539
+ * came here at all.
409
540
  */
410
541
  export async function pruneArmOrphans(
411
542
  desired: ArmResource[],
412
543
  ctx: ArmEvalCtx,
413
544
  http: AzHttp = defaultHttp,
414
545
  signal?: AbortSignal,
415
- ): Promise<Array<{ type: string; name: string; deleted: boolean }>> {
546
+ ): Promise<{
547
+ pruned: Array<{ type: string; name: string; deleted: boolean }>;
548
+ notPrunable: AzNotPrunable[];
549
+ }> {
416
550
  const byType = new Map<string, { keep: Set<string>; apiVersion: string }>();
417
551
  for (const r of desired) {
418
552
  const name = String(await evalArmString(r.name, ctx));
@@ -422,15 +556,27 @@ export async function pruneArmOrphans(
422
556
  }
423
557
 
424
558
  const pruned: Array<{ type: string; name: string; deleted: boolean }> = [];
559
+ const notPrunable: AzNotPrunable[] = [];
425
560
  for (const item of await listGroupResources(ctx, http, signal)) {
561
+ // Ownership first, so a foreign resource never reaches the report either —
562
+ // it is not an orphan chant failed to prune, it is simply not chant's.
563
+ if (!isChantOwned(item.tags)) continue;
426
564
  const entry = byType.get(item.type);
427
- if (!entry || !isChantOwned(item.tags) || entry.keep.has(item.name)) continue;
565
+ if (!entry) {
566
+ 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`,
569
+ );
570
+ notPrunable.push({ type: item.type, name: item.name, reason: "no-api-version" });
571
+ continue;
572
+ }
573
+ if (entry.keep.has(item.name)) continue;
428
574
  safeHeartbeat({ step: "azPrune", type: item.type, name: item.name });
429
575
  const result = await deleteArmResource(item.type, item.name, entry.apiVersion, ctx, http, signal);
430
576
  console.log(`pruned: ${item.type}/${item.name} (${ctx.base})`);
431
577
  pruned.push(result);
432
578
  }
433
- return pruned;
579
+ return { pruned, notPrunable };
434
580
  }
435
581
 
436
582
  /**
@@ -23,6 +23,7 @@ export {
23
23
  azApply,
24
24
  azDelete,
25
25
  pruneArmOrphans,
26
+ toApplyResult,
26
27
  deleteArmResource,
27
28
  listGroupResources,
28
29
  chantOwnershipTags,