@intentius/chant-lexicon-azure 0.15.0 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/az-apply.d.ts +92 -24
- package/dist/op/activities/az-apply.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +2 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/op/activities/az-apply.test.ts +257 -41
- package/src/op/activities/az-apply.ts +315 -81
- package/src/op/activities/index.ts +16 -2
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "c71f8c6d91577879032b9b5f2c0947e2671a9b3de1b482e835765e4d50cfd61a",
|
|
5
5
|
"meta.json": "4b9c0be734f8b4a5fbba3136e14aaa1ea73ff0ee93f2aa480db83e476ce727b5",
|
|
6
6
|
"types/index.d.ts": "d62c0fd7947ddf04a9126cc8bf44a9f4437f21cf8647fc812c022398f2c00d57",
|
|
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": "
|
|
36
|
+
"composite": "80f4f69fdccb61156444408efef97a404f529b3346e6ec8287a9a5f80320b713"
|
|
37
37
|
}
|
package/dist/manifest.json
CHANGED
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
/** Context for evaluating the ARM template expressions chant emits. */
|
|
2
|
-
export interface ArmContext {
|
|
3
|
-
subscriptionId: string;
|
|
4
|
-
resourceGroup: string;
|
|
5
|
-
location: string;
|
|
6
|
-
}
|
|
7
1
|
/** One ARM resource from a `deploymentTemplate.json` `resources[]`. */
|
|
8
2
|
export interface ArmResource {
|
|
9
3
|
type: string;
|
|
@@ -14,25 +8,44 @@ export interface ArmResource {
|
|
|
14
8
|
sku?: unknown;
|
|
15
9
|
kind?: unknown;
|
|
16
10
|
tags?: Record<string, string>;
|
|
11
|
+
dependsOn?: unknown;
|
|
17
12
|
}
|
|
18
|
-
/**
|
|
19
|
-
* Evaluate an ARM template expression string (`"[...]"`). Supports the functions
|
|
20
|
-
* chant's serializer emits: `concat`, `uniqueString`, `resourceGroup()` (`.id` /
|
|
21
|
-
* `.location`), `subscription()` (`.subscriptionId`), and string literals. A
|
|
22
|
-
* non-expression string is returned unchanged. Pure.
|
|
23
|
-
*/
|
|
24
|
-
export declare function evalArmString(s: string, ctx: ArmContext): string;
|
|
25
|
-
/** Recursively evaluate every string in a value against the ARM context. Pure. */
|
|
26
|
-
export declare function evalArm(value: unknown, ctx: ArmContext): unknown;
|
|
27
13
|
/** Injectable HTTP client — mirrors the GCP applier so tests avoid the network. */
|
|
28
14
|
export type AzHttp = (method: string, url: string, body?: unknown, signal?: AbortSignal) => Promise<{
|
|
29
15
|
status: number;
|
|
30
16
|
text: string;
|
|
31
17
|
}>;
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Context for evaluating ARM template expressions. `deployed` holds the response
|
|
20
|
+
* bodies of resources already applied this run (keyed by evaluated name) so
|
|
21
|
+
* `reference()` resolves; `http`/`base` let `listKeys()` call the resource's
|
|
22
|
+
* key action.
|
|
23
|
+
*/
|
|
24
|
+
export interface ArmEvalCtx {
|
|
25
|
+
subscriptionId: string;
|
|
26
|
+
resourceGroup: string;
|
|
27
|
+
location: string;
|
|
28
|
+
deployed: Map<string, unknown>;
|
|
29
|
+
http: AzHttp;
|
|
30
|
+
base: string;
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
}
|
|
33
|
+
/** Evaluate an ARM expression string (`"[...]"`); a plain string is returned as-is. */
|
|
34
|
+
export declare function evalArmString(s: string, ctx: ArmEvalCtx): Promise<unknown>;
|
|
35
|
+
/** Recursively evaluate every string in a value against the ARM context. */
|
|
36
|
+
export declare function evalArm(value: unknown, ctx: ArmEvalCtx): Promise<unknown>;
|
|
37
|
+
/** Resource names this resource references via `resourceId('type','name')` / `reference('name')`. Pure. */
|
|
38
|
+
export declare function armDependencies(resource: ArmResource, names: Set<string>): string[];
|
|
39
|
+
/**
|
|
40
|
+
* Topologically order ARM resources so a referenced resource is applied before
|
|
41
|
+
* the resource that references it. Names that are expressions are ordered as-is
|
|
42
|
+
* (they don't match a literal reference). Throws on a cycle. Pure.
|
|
43
|
+
*/
|
|
44
|
+
export declare function orderArmResources(resources: ArmResource[]): ArmResource[];
|
|
45
|
+
/** The ARM resource-ID PUT URL for a resource (name expression evaluated). */
|
|
46
|
+
export declare function armResourceUrl(resource: ArmResource, ctx: ArmEvalCtx): Promise<string>;
|
|
47
|
+
/** The ARM resource PUT body (location/properties/sku/kind/tags), expressions evaluated. */
|
|
48
|
+
export declare function armResourceBody(resource: ArmResource, ctx: ArmEvalCtx): Promise<Record<string, unknown>>;
|
|
36
49
|
export interface AzApplyArgs {
|
|
37
50
|
/** Path to a built ARM template (`deploymentTemplate.json`). */
|
|
38
51
|
templatePath: string;
|
|
@@ -44,19 +57,74 @@ export interface AzApplyArgs {
|
|
|
44
57
|
endpoint?: string;
|
|
45
58
|
/** Subscription id. Default: floci-az's local subscription. */
|
|
46
59
|
subscriptionId?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Delete chant-owned resources of a templated type that are no longer in the
|
|
62
|
+
* template (owned-only prune). Destructive — off by default. Foreign
|
|
63
|
+
* (non-chant) resources are never touched.
|
|
64
|
+
*/
|
|
65
|
+
prune?: boolean;
|
|
47
66
|
}
|
|
48
67
|
/**
|
|
49
68
|
* The native Azure applier — read a built ARM template and PUT each resource
|
|
50
|
-
* directly to the ARM resource-CRUD API,
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* `
|
|
54
|
-
*
|
|
69
|
+
* directly to the ARM resource-CRUD API, in dependency order, resolving ARM
|
|
70
|
+
* expressions (including `reference()`/`listKeys()` against resources applied
|
|
71
|
+
* earlier this run). The Azure twin of `gcpApply`: it targets floci-az (which
|
|
72
|
+
* `az deployment` can't, floci-az having no deployments provider) or real Azure
|
|
73
|
+
* by endpoint override; the resource group is ensured first.
|
|
55
74
|
*/
|
|
56
75
|
export declare function azApply(args: AzApplyArgs, signal?: AbortSignal, http?: AzHttp): Promise<{
|
|
57
76
|
applied: Array<{
|
|
58
77
|
type: string;
|
|
59
78
|
name: string;
|
|
60
79
|
}>;
|
|
80
|
+
pruned: Array<{
|
|
81
|
+
type: string;
|
|
82
|
+
name: string;
|
|
83
|
+
deleted: boolean;
|
|
84
|
+
}>;
|
|
85
|
+
}>;
|
|
86
|
+
/** The ownership tag azApply stamps on every resource it applies. */
|
|
87
|
+
export declare function chantOwnershipTags(): Record<string, string>;
|
|
88
|
+
/** Whether a resource's tags mark it chant-owned. */
|
|
89
|
+
export declare function isChantOwned(tags: Record<string, string> | null | undefined): boolean;
|
|
90
|
+
/** One resource from the ARM resource-group listing. */
|
|
91
|
+
export interface ArmListItem {
|
|
92
|
+
id: string;
|
|
93
|
+
name: string;
|
|
94
|
+
type: string;
|
|
95
|
+
tags?: Record<string, string>;
|
|
96
|
+
}
|
|
97
|
+
/** List the resources in the group via the ARM resource-list endpoint. */
|
|
98
|
+
export declare function listGroupResources(ctx: ArmEvalCtx, http?: AzHttp, signal?: AbortSignal): Promise<ArmListItem[]>;
|
|
99
|
+
/** Idempotently delete one ARM resource by type/name/apiVersion. A 404 means it is already gone. */
|
|
100
|
+
export declare function deleteArmResource(type: string, name: string, apiVersion: string, ctx: ArmEvalCtx, http?: AzHttp, signal?: AbortSignal): Promise<{
|
|
101
|
+
type: string;
|
|
102
|
+
name: string;
|
|
103
|
+
deleted: boolean;
|
|
104
|
+
}>;
|
|
105
|
+
/**
|
|
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 types — like 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.
|
|
111
|
+
*/
|
|
112
|
+
export declare function pruneArmOrphans(desired: ArmResource[], ctx: ArmEvalCtx, http?: AzHttp, signal?: AbortSignal): Promise<Array<{
|
|
113
|
+
type: string;
|
|
114
|
+
name: string;
|
|
115
|
+
deleted: boolean;
|
|
116
|
+
}>>;
|
|
117
|
+
/**
|
|
118
|
+
* The inverse of {@link azApply} — read a built ARM template and delete the
|
|
119
|
+
* resources it declares, in reverse dependency order (a referrer goes before the
|
|
120
|
+
* resource it references). Idempotent: already-absent resources are a no-op. The
|
|
121
|
+
* Azure twin of `gcpDelete`; `http` is injectable for tests.
|
|
122
|
+
*/
|
|
123
|
+
export declare function azDelete(args: AzApplyArgs, signal?: AbortSignal, http?: AzHttp): Promise<{
|
|
124
|
+
deleted: Array<{
|
|
125
|
+
type: string;
|
|
126
|
+
name: string;
|
|
127
|
+
deleted: boolean;
|
|
128
|
+
}>;
|
|
61
129
|
}>;
|
|
62
130
|
//# sourceMappingURL=az-apply.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"az-apply.d.ts","sourceRoot":"","sources":["../../../src/op/activities/az-apply.ts"],"names":[],"mappings":"AAQA,uEAAuE;AACvE,MAAM,WAAW,
|
|
1
|
+
{"version":3,"file":"az-apply.d.ts","sourceRoot":"","sources":["../../../src/op/activities/az-apply.ts"],"names":[],"mappings":"AAQA,uEAAuE;AACvE,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,mFAAmF;AACnF,MAAM,MAAM,MAAM,GAAG,CACnB,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,EACX,IAAI,CAAC,EAAE,OAAO,EACd,MAAM,CAAC,EAAE,WAAW,KACjB,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAc/C;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,uFAAuF;AACvF,wBAAsB,aAAa,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,CAIhF;AAED,4EAA4E;AAC5E,wBAAsB,OAAO,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,CAS/E;AAgID,2GAA2G;AAC3G,wBAAgB,eAAe,CAAC,QAAQ,EAAE,WAAW,EAAE,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,EAAE,CAenF;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,WAAW,EAAE,GAAG,WAAW,EAAE,CAqBzE;AAID,8EAA8E;AAC9E,wBAAsB,cAAc,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAG5F;AAED,4FAA4F;AAC5F,wBAAsB,eAAe,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAQ9G;AAED,MAAM,WAAW,WAAW;IAC1B,gEAAgE;IAChE,YAAY,EAAE,MAAM,CAAC;IACrB,qCAAqC;IACrC,aAAa,EAAE,MAAM,CAAC;IACtB,uFAAuF;IACvF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,0FAA0F;IAC1F,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,+DAA+D;IAC/D,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,wBAAsB,OAAO,CAC3B,IAAI,EAAE,WAAW,EACjB,MAAM,CAAC,EAAE,WAAW,EACpB,IAAI,GAAE,MAAoB,GACzB,OAAO,CAAC;IAAE,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAAC,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAA;CAAE,CAAC,CA8C9H;AAYD,qEAAqE;AACrE,wBAAgB,kBAAkB,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAE3D;AAED,qDAAqD;AACrD,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAErF;AAED,wDAAwD;AACxD,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC/B;AAED,0EAA0E;AAC1E,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,GAAE,MAAoB,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CASlI;AAED,oGAAoG;AACpG,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,UAAU,EACf,IAAI,GAAE,MAAoB,EAC1B,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC,CAM3D;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,WAAW,EAAE,EACtB,GAAG,EAAE,UAAU,EACf,IAAI,GAAE,MAAoB,EAC1B,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC,CAAC,CAmBlE;AAED;;;;;GAKG;AACH,wBAAsB,QAAQ,CAC5B,IAAI,EAAE,WAAW,EACjB,MAAM,CAAC,EAAE,WAAW,EACpB,IAAI,GAAE,MAAoB,GACzB,OAAO,CAAC;IAAE,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,CAAC,CAAA;CAAE,CAAC,CAsB/E"}
|
|
@@ -5,6 +5,6 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export { azGroupEnsure, azGroupDelete, azGroupEnsureCommand, azGroupDeleteCommand } from "./azure.js";
|
|
7
7
|
export type { AzGroupEnsureArgs, AzGroupDeleteArgs } from "./azure.js";
|
|
8
|
-
export { azApply, evalArm, evalArmString, armResourceUrl, armResourceBody } from "./az-apply.js";
|
|
9
|
-
export type { AzApplyArgs,
|
|
8
|
+
export { azApply, azDelete, pruneArmOrphans, deleteArmResource, listGroupResources, chantOwnershipTags, isChantOwned, evalArm, evalArmString, armResourceUrl, armResourceBody, armDependencies, orderArmResources, } from "./az-apply.js";
|
|
9
|
+
export type { AzApplyArgs, ArmEvalCtx, ArmResource, ArmListItem, AzHttp } from "./az-apply.js";
|
|
10
10
|
//# 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;AAEpE,OAAO,
|
|
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;AAEpE,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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-azure",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"description": "Azure lexicon for chant — declarative IaC in TypeScript",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -74,6 +74,6 @@
|
|
|
74
74
|
"typescript": "^5.9.3"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
|
-
"@intentius/chant": "^0.15.
|
|
77
|
+
"@intentius/chant": "^0.15.1"
|
|
78
78
|
}
|
|
79
79
|
}
|
|
@@ -4,53 +4,119 @@ import {
|
|
|
4
4
|
evalArm,
|
|
5
5
|
armResourceUrl,
|
|
6
6
|
armResourceBody,
|
|
7
|
+
armDependencies,
|
|
8
|
+
orderArmResources,
|
|
7
9
|
azApply,
|
|
8
|
-
|
|
10
|
+
azDelete,
|
|
11
|
+
pruneArmOrphans,
|
|
12
|
+
deleteArmResource,
|
|
13
|
+
listGroupResources,
|
|
14
|
+
chantOwnershipTags,
|
|
15
|
+
isChantOwned,
|
|
16
|
+
type ArmEvalCtx,
|
|
9
17
|
type ArmResource,
|
|
10
18
|
type AzHttp,
|
|
11
19
|
} from "./az-apply";
|
|
12
20
|
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
const noHttp: AzHttp = async () => ({ status: 200, text: "{}" });
|
|
22
|
+
|
|
23
|
+
function ctx(over: Partial<ArmEvalCtx> = {}): ArmEvalCtx {
|
|
24
|
+
return {
|
|
25
|
+
subscriptionId: "sub-1",
|
|
26
|
+
resourceGroup: "chant-rg",
|
|
27
|
+
location: "eastus",
|
|
28
|
+
deployed: new Map(),
|
|
29
|
+
http: noHttp,
|
|
30
|
+
base: "http://x",
|
|
31
|
+
...over,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
18
34
|
|
|
19
|
-
describe("evalArmString (#
|
|
20
|
-
test("resourceGroup
|
|
21
|
-
expect(evalArmString("[resourceGroup().location]",
|
|
22
|
-
expect(evalArmString("[resourceGroup().id]",
|
|
35
|
+
describe("evalArmString — static functions (#707)", () => {
|
|
36
|
+
test("resourceGroup / subscription / concat / uniqueString", async () => {
|
|
37
|
+
expect(await evalArmString("[resourceGroup().location]", ctx())).toBe("eastus");
|
|
38
|
+
expect(await evalArmString("[resourceGroup().id]", ctx())).toBe("/subscriptions/sub-1/resourceGroups/chant-rg");
|
|
39
|
+
expect(await evalArmString("[subscription().subscriptionId]", ctx())).toBe("sub-1");
|
|
40
|
+
const v = await evalArmString("[concat('store', uniqueString(resourceGroup().id))]", ctx());
|
|
41
|
+
expect(String(v)).toHaveLength("store".length + 13);
|
|
23
42
|
});
|
|
24
43
|
|
|
25
|
-
test("
|
|
26
|
-
expect(evalArmString("[
|
|
44
|
+
test("resourceId('type','name') → the resource-id path", async () => {
|
|
45
|
+
expect(await evalArmString("[resourceId('Microsoft.Web/serverfarms', 'plan1')]", ctx())).toBe(
|
|
46
|
+
"/subscriptions/sub-1/resourceGroups/chant-rg/providers/Microsoft.Web/serverfarms/plan1",
|
|
47
|
+
);
|
|
27
48
|
});
|
|
28
49
|
|
|
29
|
-
test("
|
|
30
|
-
|
|
31
|
-
expect(
|
|
32
|
-
expect(v).toHaveLength("store".length + 13); // uniqueString → 13 chars
|
|
50
|
+
test("non-expression + [[ escape passthrough", async () => {
|
|
51
|
+
expect(await evalArmString("plain", ctx())).toBe("plain");
|
|
52
|
+
expect(await evalArmString("[[literal]", ctx())).toBe("[literal]");
|
|
33
53
|
});
|
|
54
|
+
});
|
|
34
55
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
56
|
+
describe("evalArmString — reference() (#707)", () => {
|
|
57
|
+
test("reference('name') → the applied resource's properties, with .prop access", async () => {
|
|
58
|
+
const deployed = new Map<string, unknown>([
|
|
59
|
+
["mystore", { properties: { primaryEndpoints: { blob: "http://mystore.blob/" } } }],
|
|
60
|
+
]);
|
|
61
|
+
expect(await evalArmString("[reference('mystore').primaryEndpoints.blob]", ctx({ deployed }))).toBe(
|
|
62
|
+
"http://mystore.blob/",
|
|
63
|
+
);
|
|
39
64
|
});
|
|
65
|
+
});
|
|
40
66
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
67
|
+
describe("evalArmString — listKeys() (#707)", () => {
|
|
68
|
+
test("listKeys(resourceId(...), v).keys[0].value → POSTs the key action and indexes", async () => {
|
|
69
|
+
const calls: string[] = [];
|
|
70
|
+
const http: AzHttp = async (method, url) => {
|
|
71
|
+
calls.push(`${method} ${url}`);
|
|
72
|
+
return { status: 200, text: JSON.stringify({ keys: [{ value: "SECRET-KEY" }, { value: "k2" }] }) };
|
|
73
|
+
};
|
|
74
|
+
const expr = "[concat('AccountKey=', listKeys(resourceId('Microsoft.Storage/storageAccounts', 'st'), '2023-01-01').keys[0].value)]";
|
|
75
|
+
expect(await evalArmString(expr, ctx({ http }))).toBe("AccountKey=SECRET-KEY");
|
|
76
|
+
expect(calls[0]).toBe("POST http://x/subscriptions/sub-1/resourceGroups/chant-rg/providers/Microsoft.Storage/storageAccounts/st/listKeys?api-version=2023-01-01");
|
|
44
77
|
});
|
|
78
|
+
});
|
|
45
79
|
|
|
46
|
-
|
|
47
|
-
|
|
80
|
+
describe("evalArm recursion (#707)", () => {
|
|
81
|
+
test("recurses objects/arrays, resolving async expressions", async () => {
|
|
82
|
+
expect(await evalArm({ a: "[resourceGroup().location]", b: ["[subscription().subscriptionId]", 1] }, ctx())).toEqual({
|
|
48
83
|
a: "eastus",
|
|
49
84
|
b: ["sub-1", 1],
|
|
50
85
|
});
|
|
51
86
|
});
|
|
52
87
|
});
|
|
53
88
|
|
|
89
|
+
describe("dependency ordering (#707)", () => {
|
|
90
|
+
const plan: ArmResource = { type: "Microsoft.Web/serverfarms", apiVersion: "2023-01-01", name: "plan1" };
|
|
91
|
+
const store: ArmResource = { type: "Microsoft.Storage/storageAccounts", apiVersion: "2023-01-01", name: "st1" };
|
|
92
|
+
const site: ArmResource = {
|
|
93
|
+
type: "Microsoft.Web/sites",
|
|
94
|
+
apiVersion: "2023-01-01",
|
|
95
|
+
name: "site1",
|
|
96
|
+
properties: {
|
|
97
|
+
serverFarmId: "[resourceId('Microsoft.Web/serverfarms', 'plan1')]",
|
|
98
|
+
conn: "[listKeys(resourceId('Microsoft.Storage/storageAccounts', 'st1'), '2023-01-01')]",
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
test("armDependencies finds referenced resource names in the template", () => {
|
|
103
|
+
expect(armDependencies(site, new Set(["plan1", "st1", "site1"])).sort()).toEqual(["plan1", "st1"]);
|
|
104
|
+
expect(armDependencies(plan, new Set(["plan1", "st1", "site1"]))).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("orderArmResources applies dependencies before the referrer", () => {
|
|
108
|
+
const ordered = orderArmResources([site, plan, store]).map((r) => r.name);
|
|
109
|
+
expect(ordered.indexOf("plan1")).toBeLessThan(ordered.indexOf("site1"));
|
|
110
|
+
expect(ordered.indexOf("st1")).toBeLessThan(ordered.indexOf("site1"));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("throws on a cycle", () => {
|
|
114
|
+
const a: ArmResource = { type: "T", apiVersion: "v", name: "a", properties: { r: "[resourceId('T', 'b')]" } };
|
|
115
|
+
const b: ArmResource = { type: "T", apiVersion: "v", name: "b", properties: { r: "[resourceId('T', 'a')]" } };
|
|
116
|
+
expect(() => orderArmResources([a, b])).toThrow(/reference cycle/);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
54
120
|
const STORAGE: ArmResource = {
|
|
55
121
|
type: "Microsoft.Storage/storageAccounts",
|
|
56
122
|
apiVersion: "2025-06-01",
|
|
@@ -62,15 +128,15 @@ const STORAGE: ArmResource = {
|
|
|
62
128
|
tags: { "managed-by": "chant" },
|
|
63
129
|
};
|
|
64
130
|
|
|
65
|
-
describe("armResourceUrl / armResourceBody (#
|
|
66
|
-
test("URL is the resource-id PUT path
|
|
67
|
-
expect(armResourceUrl(STORAGE,
|
|
131
|
+
describe("armResourceUrl / armResourceBody (#707)", () => {
|
|
132
|
+
test("URL is the resource-id PUT path", async () => {
|
|
133
|
+
expect(await armResourceUrl(STORAGE, ctx())).toBe(
|
|
68
134
|
"http://x/subscriptions/sub-1/resourceGroups/chant-rg/providers/Microsoft.Storage/storageAccounts/chantstore1?api-version=2025-06-01",
|
|
69
135
|
);
|
|
70
136
|
});
|
|
71
137
|
|
|
72
|
-
test("body evaluates location, keeps sku/kind/tags/properties", () => {
|
|
73
|
-
expect(armResourceBody(STORAGE,
|
|
138
|
+
test("body evaluates location, keeps sku/kind/tags/properties", async () => {
|
|
139
|
+
expect(await armResourceBody(STORAGE, ctx())).toEqual({
|
|
74
140
|
location: "eastus",
|
|
75
141
|
sku: { name: "Standard_LRS" },
|
|
76
142
|
kind: "StorageV2",
|
|
@@ -80,28 +146,33 @@ describe("armResourceUrl / armResourceBody (#706)", () => {
|
|
|
80
146
|
});
|
|
81
147
|
});
|
|
82
148
|
|
|
83
|
-
describe("azApply flow (#
|
|
84
|
-
test("ensures the resource group,
|
|
149
|
+
describe("azApply flow (#707)", () => {
|
|
150
|
+
test("ensures the resource group, applies in dependency order, captures state", async () => {
|
|
85
151
|
const calls: Array<{ method: string; url: string }> = [];
|
|
86
152
|
const http: AzHttp = async (method, url) => {
|
|
87
153
|
calls.push({ method, url });
|
|
88
154
|
return { status: 200, text: "{}" };
|
|
89
155
|
};
|
|
90
|
-
// Stub the template read via a data: path — azApply reads a file, so drive it
|
|
91
|
-
// through the pure pieces instead by asserting the call sequence a real run makes.
|
|
92
|
-
// Here we exercise the HTTP contract with a hand-built template file.
|
|
93
156
|
const fs = await import("node:fs");
|
|
94
157
|
const tmp = `/tmp/chant-arm-${process.pid}.json`;
|
|
95
|
-
|
|
158
|
+
const site: ArmResource = {
|
|
159
|
+
type: "Microsoft.Web/sites",
|
|
160
|
+
apiVersion: "2023-01-01",
|
|
161
|
+
name: "site1",
|
|
162
|
+
properties: { serverFarmId: "[resourceId('Microsoft.Web/serverfarms', 'plan1')]" },
|
|
163
|
+
};
|
|
164
|
+
const plan: ArmResource = { type: "Microsoft.Web/serverfarms", apiVersion: "2023-01-01", name: "plan1" };
|
|
165
|
+
fs.writeFileSync(tmp, JSON.stringify({ resources: [site, plan] })); // listed out of order
|
|
96
166
|
const res = await azApply({ templatePath: tmp, resourceGroup: "chant-rg", location: "eastus", endpoint: "http://x", subscriptionId: "sub-1" }, undefined, http);
|
|
97
167
|
fs.unlinkSync(tmp);
|
|
98
|
-
|
|
99
|
-
expect(
|
|
100
|
-
|
|
101
|
-
expect(
|
|
168
|
+
// plan (dependency) applied before site (referrer), despite manifest order.
|
|
169
|
+
expect(res.applied.map((a) => a.name)).toEqual(["plan1", "site1"]);
|
|
170
|
+
const puts = calls.filter((c) => c.method === "PUT" && c.url.includes("/providers/"));
|
|
171
|
+
expect(puts[0].url).toContain("/serverfarms/plan1");
|
|
172
|
+
expect(puts[1].url).toContain("/sites/site1");
|
|
102
173
|
});
|
|
103
174
|
|
|
104
|
-
test("surfaces a resource apply failure
|
|
175
|
+
test("surfaces a resource apply failure", async () => {
|
|
105
176
|
const fs = await import("node:fs");
|
|
106
177
|
const tmp = `/tmp/chant-arm-fail-${process.pid}.json`;
|
|
107
178
|
fs.writeFileSync(tmp, JSON.stringify({ resources: [STORAGE] }));
|
|
@@ -112,4 +183,149 @@ describe("azApply flow (#706)", () => {
|
|
|
112
183
|
).rejects.toThrow(/Microsoft.Storage\/storageAccounts chantstore1 apply failed \(400\)/);
|
|
113
184
|
fs.unlinkSync(tmp);
|
|
114
185
|
});
|
|
186
|
+
|
|
187
|
+
test("stamps chant ownership on the PUT body", async () => {
|
|
188
|
+
const fs = await import("node:fs");
|
|
189
|
+
const tmp = `/tmp/chant-arm-own-${process.pid}.json`;
|
|
190
|
+
fs.writeFileSync(tmp, JSON.stringify({ resources: [{ type: "T", apiVersion: "v", name: "r1" }] }));
|
|
191
|
+
let putBody: Record<string, unknown> | undefined;
|
|
192
|
+
const http: AzHttp = async (method, url, body) => {
|
|
193
|
+
if (method === "PUT" && url.includes("/providers/")) putBody = body as Record<string, unknown>;
|
|
194
|
+
return { status: 200, text: "{}" };
|
|
195
|
+
};
|
|
196
|
+
await azApply({ templatePath: tmp, resourceGroup: "rg", endpoint: "http://x" }, undefined, http);
|
|
197
|
+
fs.unlinkSync(tmp);
|
|
198
|
+
expect((putBody?.tags as Record<string, string>)["managed-by"]).toBe("chant");
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("ownership helpers (#azure-prune)", () => {
|
|
203
|
+
test("chantOwnershipTags / isChantOwned", () => {
|
|
204
|
+
expect(chantOwnershipTags()).toEqual({ "managed-by": "chant" });
|
|
205
|
+
expect(isChantOwned({ "managed-by": "chant" })).toBe(true);
|
|
206
|
+
expect(isChantOwned({ "managed-by": "someone-else" })).toBe(false);
|
|
207
|
+
expect(isChantOwned(undefined)).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("deleteArmResource (#azure-prune)", () => {
|
|
212
|
+
test("DELETEs the resource-id path; 404 is not-deleted", async () => {
|
|
213
|
+
const calls: string[] = [];
|
|
214
|
+
const http: AzHttp = async (method, url) => {
|
|
215
|
+
calls.push(`${method} ${url}`);
|
|
216
|
+
return { status: 200, text: "" };
|
|
217
|
+
};
|
|
218
|
+
const res = await deleteArmResource("Microsoft.Storage/storageAccounts", "st1", "2023-01-01", ctx(), http);
|
|
219
|
+
expect(res).toEqual({ type: "Microsoft.Storage/storageAccounts", name: "st1", deleted: true });
|
|
220
|
+
expect(calls[0]).toBe(
|
|
221
|
+
"DELETE http://x/subscriptions/sub-1/resourceGroups/chant-rg/providers/Microsoft.Storage/storageAccounts/st1?api-version=2023-01-01",
|
|
222
|
+
);
|
|
223
|
+
const gone = await deleteArmResource("T", "x", "v", ctx(), async () => ({ status: 404, text: "" }));
|
|
224
|
+
expect(gone.deleted).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("throws on a non-404 error", async () => {
|
|
228
|
+
await expect(
|
|
229
|
+
deleteArmResource("T", "x", "v", ctx(), async () => ({ status: 403, text: "no" })),
|
|
230
|
+
).rejects.toThrow(/T x delete failed \(403\)/);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("listGroupResources (#azure-prune)", () => {
|
|
235
|
+
test("returns the value[] items, filtering malformed entries", async () => {
|
|
236
|
+
const http: AzHttp = async () => ({
|
|
237
|
+
status: 200,
|
|
238
|
+
text: JSON.stringify({ value: [{ id: "/a", name: "a", type: "T", tags: { "managed-by": "chant" } }, { id: "/bad" }] }),
|
|
239
|
+
});
|
|
240
|
+
const items = await listGroupResources(ctx(), http);
|
|
241
|
+
expect(items.map((i) => i.name)).toEqual(["a"]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("returns [] on an error status", async () => {
|
|
245
|
+
expect(await listGroupResources(ctx(), async () => ({ status: 500, text: "" }))).toEqual([]);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("pruneArmOrphans (#azure-prune)", () => {
|
|
250
|
+
const desired: ArmResource[] = [{ type: "Microsoft.Storage/storageAccounts", apiVersion: "2023-01-01", name: "keep1" }];
|
|
251
|
+
|
|
252
|
+
test("deletes only chant-owned, templated-type resources not in the template", async () => {
|
|
253
|
+
const live = {
|
|
254
|
+
value: [
|
|
255
|
+
{ id: "/1", name: "keep1", type: "Microsoft.Storage/storageAccounts", tags: { "managed-by": "chant" } }, // in template → keep
|
|
256
|
+
{ id: "/2", name: "orphan1", type: "Microsoft.Storage/storageAccounts", tags: { "managed-by": "chant" } }, // owned, not in template → prune
|
|
257
|
+
{ id: "/3", name: "foreign", type: "Microsoft.Storage/storageAccounts", tags: {} }, // not owned → skip
|
|
258
|
+
{ id: "/4", name: "othertype", type: "Microsoft.Web/sites", tags: { "managed-by": "chant" } }, // type not templated → skip
|
|
259
|
+
],
|
|
260
|
+
};
|
|
261
|
+
const deletes: string[] = [];
|
|
262
|
+
const http: AzHttp = async (method, url) => {
|
|
263
|
+
if (method === "DELETE") deletes.push(url);
|
|
264
|
+
return { status: 200, text: method === "GET" ? JSON.stringify(live) : "" };
|
|
265
|
+
};
|
|
266
|
+
const pruned = await pruneArmOrphans(desired, ctx(), http);
|
|
267
|
+
expect(pruned).toEqual([{ type: "Microsoft.Storage/storageAccounts", name: "orphan1", deleted: true }]);
|
|
268
|
+
expect(deletes).toHaveLength(1);
|
|
269
|
+
expect(deletes[0]).toContain("/storageAccounts/orphan1?api-version=2023-01-01"); // apiVersion from the template
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe("azApply prune flag (#azure-prune)", () => {
|
|
274
|
+
test("prunes owned orphans of a templated type after applying", async () => {
|
|
275
|
+
const fs = await import("node:fs");
|
|
276
|
+
const tmp = `/tmp/chant-arm-prune-${process.pid}.json`;
|
|
277
|
+
fs.writeFileSync(tmp, JSON.stringify({ resources: [{ type: "T", apiVersion: "v", name: "keep1" }] }));
|
|
278
|
+
const live = { value: [{ id: "/o", name: "orphan1", type: "T", tags: { "managed-by": "chant" } }] };
|
|
279
|
+
const deletes: string[] = [];
|
|
280
|
+
const http: AzHttp = async (method, url) => {
|
|
281
|
+
if (method === "DELETE") deletes.push(url);
|
|
282
|
+
return { status: 200, text: method === "GET" ? JSON.stringify(live) : "{}" };
|
|
283
|
+
};
|
|
284
|
+
const res = await azApply({ templatePath: tmp, resourceGroup: "rg", endpoint: "http://x", prune: true }, undefined, http);
|
|
285
|
+
fs.unlinkSync(tmp);
|
|
286
|
+
expect(res.applied.map((a) => a.name)).toEqual(["keep1"]);
|
|
287
|
+
expect(res.pruned).toEqual([{ type: "T", name: "orphan1", deleted: true }]);
|
|
288
|
+
expect(deletes[0]).toContain("/providers/T/orphan1");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("no prune when the flag is off", async () => {
|
|
292
|
+
const fs = await import("node:fs");
|
|
293
|
+
const tmp = `/tmp/chant-arm-noprune-${process.pid}.json`;
|
|
294
|
+
fs.writeFileSync(tmp, JSON.stringify({ resources: [{ type: "T", apiVersion: "v", name: "keep1" }] }));
|
|
295
|
+
let listed = false;
|
|
296
|
+
const http: AzHttp = async (method, url) => {
|
|
297
|
+
if (method === "GET" && url.includes("/resources?")) listed = true;
|
|
298
|
+
return { status: 200, text: "{}" };
|
|
299
|
+
};
|
|
300
|
+
const res = await azApply({ templatePath: tmp, resourceGroup: "rg", endpoint: "http://x" }, undefined, http);
|
|
301
|
+
fs.unlinkSync(tmp);
|
|
302
|
+
expect(res.pruned).toEqual([]);
|
|
303
|
+
expect(listed).toBe(false);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe("azDelete (#azure-prune)", () => {
|
|
308
|
+
test("deletes declared resources in reverse dependency order", async () => {
|
|
309
|
+
const fs = await import("node:fs");
|
|
310
|
+
const tmp = `/tmp/chant-arm-del-${process.pid}.json`;
|
|
311
|
+
const site: ArmResource = {
|
|
312
|
+
type: "Microsoft.Web/sites",
|
|
313
|
+
apiVersion: "2023-01-01",
|
|
314
|
+
name: "site1",
|
|
315
|
+
properties: { serverFarmId: "[resourceId('Microsoft.Web/serverfarms', 'plan1')]" },
|
|
316
|
+
};
|
|
317
|
+
const plan: ArmResource = { type: "Microsoft.Web/serverfarms", apiVersion: "2023-01-01", name: "plan1" };
|
|
318
|
+
fs.writeFileSync(tmp, JSON.stringify({ resources: [plan, site] }));
|
|
319
|
+
const deletes: string[] = [];
|
|
320
|
+
const http: AzHttp = async (method, url) => {
|
|
321
|
+
if (method === "DELETE") deletes.push(url);
|
|
322
|
+
return { status: 200, text: "" };
|
|
323
|
+
};
|
|
324
|
+
const res = await azDelete({ templatePath: tmp, resourceGroup: "chant-rg", endpoint: "http://x" }, undefined, http);
|
|
325
|
+
fs.unlinkSync(tmp);
|
|
326
|
+
// referrer (site) deleted before the resource it references (plan).
|
|
327
|
+
expect(res.deleted.map((d) => d.name)).toEqual(["site1", "plan1"]);
|
|
328
|
+
expect(deletes[0]).toContain("/sites/site1");
|
|
329
|
+
expect(deletes[1]).toContain("/serverfarms/plan1");
|
|
330
|
+
});
|
|
115
331
|
});
|
|
@@ -6,13 +6,6 @@ import { safeHeartbeat } from "@intentius/chant/op";
|
|
|
6
6
|
const DEFAULT_SUBSCRIPTION = "00000000-0000-0000-0000-000000000001";
|
|
7
7
|
const DEFAULT_ENDPOINT = "https://management.azure.com";
|
|
8
8
|
|
|
9
|
-
/** Context for evaluating the ARM template expressions chant emits. */
|
|
10
|
-
export interface ArmContext {
|
|
11
|
-
subscriptionId: string;
|
|
12
|
-
resourceGroup: string;
|
|
13
|
-
location: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
9
|
/** One ARM resource from a `deploymentTemplate.json` `resources[]`. */
|
|
17
10
|
export interface ArmResource {
|
|
18
11
|
type: string;
|
|
@@ -23,63 +16,100 @@ export interface ArmResource {
|
|
|
23
16
|
sku?: unknown;
|
|
24
17
|
kind?: unknown;
|
|
25
18
|
tags?: Record<string, string>;
|
|
19
|
+
dependsOn?: unknown;
|
|
26
20
|
}
|
|
27
21
|
|
|
28
|
-
|
|
22
|
+
/** Injectable HTTP client — mirrors the GCP applier so tests avoid the network. */
|
|
23
|
+
export type AzHttp = (
|
|
24
|
+
method: string,
|
|
25
|
+
url: string,
|
|
26
|
+
body?: unknown,
|
|
27
|
+
signal?: AbortSignal,
|
|
28
|
+
) => Promise<{ status: number; text: string }>;
|
|
29
|
+
|
|
30
|
+
const defaultHttp: AzHttp = async (method, url, body, signal) => {
|
|
31
|
+
const res = await fetch(url, {
|
|
32
|
+
method,
|
|
33
|
+
headers: body === undefined ? undefined : { "content-type": "application/json" },
|
|
34
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
35
|
+
signal,
|
|
36
|
+
});
|
|
37
|
+
return { status: res.status, text: await res.text() };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ── ARM expression evaluation ─────────────────────────────────────────────────
|
|
29
41
|
|
|
30
42
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
43
|
+
* Context for evaluating ARM template expressions. `deployed` holds the response
|
|
44
|
+
* bodies of resources already applied this run (keyed by evaluated name) so
|
|
45
|
+
* `reference()` resolves; `http`/`base` let `listKeys()` call the resource's
|
|
46
|
+
* key action.
|
|
35
47
|
*/
|
|
36
|
-
export
|
|
48
|
+
export interface ArmEvalCtx {
|
|
49
|
+
subscriptionId: string;
|
|
50
|
+
resourceGroup: string;
|
|
51
|
+
location: string;
|
|
52
|
+
deployed: Map<string, unknown>;
|
|
53
|
+
http: AzHttp;
|
|
54
|
+
base: string;
|
|
55
|
+
signal?: AbortSignal;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Evaluate an ARM expression string (`"[...]"`); a plain string is returned as-is. */
|
|
59
|
+
export async function evalArmString(s: string, ctx: ArmEvalCtx): Promise<unknown> {
|
|
37
60
|
if (!(s.startsWith("[") && s.endsWith("]"))) return s;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return String(new ArmExpr(s.slice(1, -1), ctx).parse());
|
|
61
|
+
if (s.startsWith("[[")) return s.slice(1); // escaped literal "["
|
|
62
|
+
return new ArmExpr(s.slice(1, -1), ctx).parse();
|
|
41
63
|
}
|
|
42
64
|
|
|
43
|
-
/** Recursively evaluate every string in a value against the ARM context.
|
|
44
|
-
export function evalArm(value: unknown, ctx:
|
|
65
|
+
/** Recursively evaluate every string in a value against the ARM context. */
|
|
66
|
+
export async function evalArm(value: unknown, ctx: ArmEvalCtx): Promise<unknown> {
|
|
45
67
|
if (typeof value === "string") return evalArmString(value, ctx);
|
|
46
|
-
if (Array.isArray(value)) return value.map((v) => evalArm(v, ctx));
|
|
68
|
+
if (Array.isArray(value)) return Promise.all(value.map((v) => evalArm(v, ctx)));
|
|
47
69
|
if (value && typeof value === "object") {
|
|
48
70
|
const out: Record<string, unknown> = {};
|
|
49
|
-
for (const [k, v] of Object.entries(value)) out[k] = evalArm(v, ctx);
|
|
71
|
+
for (const [k, v] of Object.entries(value)) out[k] = await evalArm(v, ctx);
|
|
50
72
|
return out;
|
|
51
73
|
}
|
|
52
74
|
return value;
|
|
53
75
|
}
|
|
54
76
|
|
|
55
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Recursive-descent evaluator for the ARM function subset chant emits:
|
|
79
|
+
* `concat`, `uniqueString`, `resourceGroup()`, `subscription()`, `resourceId`,
|
|
80
|
+
* `reference` (runtime state of an applied resource), and `listKeys` (async key
|
|
81
|
+
* action), with `.prop` and `[index]` access.
|
|
82
|
+
*/
|
|
56
83
|
class ArmExpr {
|
|
57
84
|
private i = 0;
|
|
58
|
-
constructor(private readonly src: string, private readonly ctx:
|
|
85
|
+
constructor(private readonly src: string, private readonly ctx: ArmEvalCtx) {}
|
|
59
86
|
|
|
60
|
-
parse(): unknown {
|
|
61
|
-
|
|
62
|
-
return v;
|
|
87
|
+
async parse(): Promise<unknown> {
|
|
88
|
+
return this.access(await this.atom());
|
|
63
89
|
}
|
|
64
90
|
|
|
65
|
-
private
|
|
91
|
+
private async atom(): Promise<unknown> {
|
|
66
92
|
this.ws();
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
93
|
+
return this.src[this.i] === "'" ? this.stringLiteral() : this.call();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Postfix `.prop` / `[index]` access. */
|
|
97
|
+
private async access(v: unknown): Promise<unknown> {
|
|
98
|
+
for (;;) {
|
|
99
|
+
const c = this.peek();
|
|
100
|
+
if (c === ".") {
|
|
101
|
+
this.i++;
|
|
102
|
+
v = (v as Record<string, unknown> | undefined)?.[this.ident()];
|
|
103
|
+
} else if (c === "[") {
|
|
104
|
+
this.i++;
|
|
105
|
+
v = (v as unknown[] | undefined)?.[this.indexNumber()];
|
|
106
|
+
} else {
|
|
107
|
+
return v;
|
|
108
|
+
}
|
|
78
109
|
}
|
|
79
|
-
return v;
|
|
80
110
|
}
|
|
81
111
|
|
|
82
|
-
private call(): unknown {
|
|
112
|
+
private async call(): Promise<unknown> {
|
|
83
113
|
const name = this.ident();
|
|
84
114
|
this.ws();
|
|
85
115
|
const args: unknown[] = [];
|
|
@@ -87,11 +117,11 @@ class ArmExpr {
|
|
|
87
117
|
this.i++;
|
|
88
118
|
this.ws();
|
|
89
119
|
if (this.peek() !== ")") {
|
|
90
|
-
args.push(this.
|
|
120
|
+
args.push(await this.parse());
|
|
91
121
|
this.ws();
|
|
92
122
|
while (this.peek() === ",") {
|
|
93
123
|
this.i++;
|
|
94
|
-
args.push(this.
|
|
124
|
+
args.push(await this.parse());
|
|
95
125
|
this.ws();
|
|
96
126
|
}
|
|
97
127
|
}
|
|
@@ -100,7 +130,7 @@ class ArmExpr {
|
|
|
100
130
|
return this.applyFn(name, args);
|
|
101
131
|
}
|
|
102
132
|
|
|
103
|
-
private applyFn(name: string, args: unknown[]): unknown {
|
|
133
|
+
private async applyFn(name: string, args: unknown[]): Promise<unknown> {
|
|
104
134
|
switch (name) {
|
|
105
135
|
case "concat":
|
|
106
136
|
return args.map(String).join("");
|
|
@@ -110,6 +140,25 @@ class ArmExpr {
|
|
|
110
140
|
return { location: this.ctx.location, id: `/subscriptions/${this.ctx.subscriptionId}/resourceGroups/${this.ctx.resourceGroup}`, name: this.ctx.resourceGroup };
|
|
111
141
|
case "subscription":
|
|
112
142
|
return { subscriptionId: this.ctx.subscriptionId, id: `/subscriptions/${this.ctx.subscriptionId}` };
|
|
143
|
+
case "resourceId":
|
|
144
|
+
// resourceId('Microsoft.X/y', 'name'[, 'child']) → the resource-id path.
|
|
145
|
+
return `/subscriptions/${this.ctx.subscriptionId}/resourceGroups/${this.ctx.resourceGroup}/providers/${args.map(String).join("/")}`;
|
|
146
|
+
case "reference": {
|
|
147
|
+
// reference('name') → the runtime `properties` of an already-applied resource.
|
|
148
|
+
const dep = this.ctx.deployed.get(String(args[0]));
|
|
149
|
+
return (dep as { properties?: unknown } | undefined)?.properties ?? dep;
|
|
150
|
+
}
|
|
151
|
+
case "listKeys": {
|
|
152
|
+
// listKeys(resourceId, apiVersion) → POST the resource's key action.
|
|
153
|
+
const resId = String(args[0]);
|
|
154
|
+
const apiVersion = String(args[1] ?? "2023-01-01");
|
|
155
|
+
const res = await this.ctx.http("POST", `${this.ctx.base}${resId}/listKeys?api-version=${apiVersion}`, {}, this.ctx.signal);
|
|
156
|
+
try {
|
|
157
|
+
return JSON.parse(res.text);
|
|
158
|
+
} catch {
|
|
159
|
+
return {};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
113
162
|
default:
|
|
114
163
|
throw new Error(`unsupported ARM function: ${name}`);
|
|
115
164
|
}
|
|
@@ -130,6 +179,15 @@ class ArmExpr {
|
|
|
130
179
|
return out;
|
|
131
180
|
}
|
|
132
181
|
|
|
182
|
+
private indexNumber(): number {
|
|
183
|
+
this.ws();
|
|
184
|
+
let n = "";
|
|
185
|
+
while (this.i < this.src.length && /[0-9]/.test(this.src[this.i])) n += this.src[this.i++];
|
|
186
|
+
this.ws();
|
|
187
|
+
if (this.peek() === "]") this.i++;
|
|
188
|
+
return parseInt(n, 10);
|
|
189
|
+
}
|
|
190
|
+
|
|
133
191
|
private ws(): void {
|
|
134
192
|
while (this.i < this.src.length && /\s/.test(this.src[this.i])) this.i++;
|
|
135
193
|
}
|
|
@@ -140,40 +198,70 @@ class ArmExpr {
|
|
|
140
198
|
}
|
|
141
199
|
}
|
|
142
200
|
|
|
143
|
-
// ──
|
|
201
|
+
// ── Dependency ordering ───────────────────────────────────────────────────────
|
|
144
202
|
|
|
145
|
-
/**
|
|
146
|
-
export
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
203
|
+
/** Resource names this resource references via `resourceId('type','name')` / `reference('name')`. Pure. */
|
|
204
|
+
export function armDependencies(resource: ArmResource, names: Set<string>): string[] {
|
|
205
|
+
const deps = new Set<string>();
|
|
206
|
+
const scan = (v: unknown): void => {
|
|
207
|
+
if (typeof v === "string") {
|
|
208
|
+
for (const m of v.matchAll(/resourceId\(\s*'[^']*'\s*,\s*'([^']*)'/g)) if (names.has(m[1])) deps.add(m[1]);
|
|
209
|
+
for (const m of v.matchAll(/reference\(\s*'([^']*)'/g)) if (names.has(m[1])) deps.add(m[1]);
|
|
210
|
+
} else if (Array.isArray(v)) {
|
|
211
|
+
v.forEach(scan);
|
|
212
|
+
} else if (v && typeof v === "object") {
|
|
213
|
+
Object.values(v).forEach(scan);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
scan(resource);
|
|
217
|
+
deps.delete(resource.name);
|
|
218
|
+
return [...deps];
|
|
219
|
+
}
|
|
152
220
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
221
|
+
/**
|
|
222
|
+
* Topologically order ARM resources so a referenced resource is applied before
|
|
223
|
+
* the resource that references it. Names that are expressions are ordered as-is
|
|
224
|
+
* (they don't match a literal reference). Throws on a cycle. Pure.
|
|
225
|
+
*/
|
|
226
|
+
export function orderArmResources(resources: ArmResource[]): ArmResource[] {
|
|
227
|
+
const byName = new Map<string, ArmResource>();
|
|
228
|
+
for (const r of resources) byName.set(r.name, r);
|
|
229
|
+
const names = new Set(resources.map((r) => r.name));
|
|
230
|
+
const ordered: ArmResource[] = [];
|
|
231
|
+
const done = new Set<ArmResource>();
|
|
232
|
+
const active = new Set<ArmResource>();
|
|
233
|
+
const visit = (r: ArmResource): void => {
|
|
234
|
+
if (done.has(r)) return;
|
|
235
|
+
if (active.has(r)) throw new Error(`ARM reference cycle involving ${r.name}`);
|
|
236
|
+
active.add(r);
|
|
237
|
+
for (const dep of armDependencies(r, names)) {
|
|
238
|
+
const target = byName.get(dep);
|
|
239
|
+
if (target && target !== r) visit(target);
|
|
240
|
+
}
|
|
241
|
+
active.delete(r);
|
|
242
|
+
done.add(r);
|
|
243
|
+
ordered.push(r);
|
|
244
|
+
};
|
|
245
|
+
for (const r of resources) visit(r);
|
|
246
|
+
return ordered;
|
|
247
|
+
}
|
|
162
248
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
249
|
+
// ── URL + body + apply ────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
/** The ARM resource-ID PUT URL for a resource (name expression evaluated). */
|
|
252
|
+
export async function armResourceUrl(resource: ArmResource, ctx: ArmEvalCtx): Promise<string> {
|
|
253
|
+
const name = await evalArmString(resource.name, ctx);
|
|
254
|
+
return `${ctx.base}/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}/providers/${resource.type}/${name}?api-version=${resource.apiVersion}`;
|
|
167
255
|
}
|
|
168
256
|
|
|
169
|
-
/** The ARM resource PUT body (location/properties/sku/kind/tags), expressions evaluated.
|
|
170
|
-
export function armResourceBody(resource: ArmResource, ctx:
|
|
257
|
+
/** The ARM resource PUT body (location/properties/sku/kind/tags), expressions evaluated. */
|
|
258
|
+
export async function armResourceBody(resource: ArmResource, ctx: ArmEvalCtx): Promise<Record<string, unknown>> {
|
|
171
259
|
const body: Record<string, unknown> = {};
|
|
172
|
-
if (resource.location) body.location = evalArmString(resource.location, ctx);
|
|
173
|
-
if (resource.properties !== undefined) body.properties = evalArm(resource.properties, ctx);
|
|
174
|
-
if (resource.sku !== undefined) body.sku = evalArm(resource.sku, ctx);
|
|
175
|
-
if (resource.kind !== undefined) body.kind = resource.kind;
|
|
176
|
-
if (resource.tags !== undefined) body.tags = resource.tags;
|
|
260
|
+
if (resource.location) body.location = await evalArmString(resource.location, ctx);
|
|
261
|
+
if (resource.properties !== undefined) body.properties = await evalArm(resource.properties, ctx);
|
|
262
|
+
if (resource.sku !== undefined) body.sku = await evalArm(resource.sku, ctx);
|
|
263
|
+
if (resource.kind !== undefined) body.kind = await evalArm(resource.kind, ctx);
|
|
264
|
+
if (resource.tags !== undefined) body.tags = await evalArm(resource.tags, ctx);
|
|
177
265
|
return body;
|
|
178
266
|
}
|
|
179
267
|
|
|
@@ -188,26 +276,36 @@ export interface AzApplyArgs {
|
|
|
188
276
|
endpoint?: string;
|
|
189
277
|
/** Subscription id. Default: floci-az's local subscription. */
|
|
190
278
|
subscriptionId?: string;
|
|
279
|
+
/**
|
|
280
|
+
* Delete chant-owned resources of a templated type that are no longer in the
|
|
281
|
+
* template (owned-only prune). Destructive — off by default. Foreign
|
|
282
|
+
* (non-chant) resources are never touched.
|
|
283
|
+
*/
|
|
284
|
+
prune?: boolean;
|
|
191
285
|
}
|
|
192
286
|
|
|
193
287
|
/**
|
|
194
288
|
* The native Azure applier — read a built ARM template and PUT each resource
|
|
195
|
-
* directly to the ARM resource-CRUD API,
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
* `
|
|
199
|
-
*
|
|
289
|
+
* directly to the ARM resource-CRUD API, in dependency order, resolving ARM
|
|
290
|
+
* expressions (including `reference()`/`listKeys()` against resources applied
|
|
291
|
+
* earlier this run). The Azure twin of `gcpApply`: it targets floci-az (which
|
|
292
|
+
* `az deployment` can't, floci-az having no deployments provider) or real Azure
|
|
293
|
+
* by endpoint override; the resource group is ensured first.
|
|
200
294
|
*/
|
|
201
295
|
export async function azApply(
|
|
202
296
|
args: AzApplyArgs,
|
|
203
297
|
signal?: AbortSignal,
|
|
204
298
|
http: AzHttp = defaultHttp,
|
|
205
|
-
): Promise<{ applied: Array<{ type: string; name: string }> }> {
|
|
299
|
+
): Promise<{ applied: Array<{ type: string; name: string }>; pruned: Array<{ type: string; name: string; deleted: boolean }> }> {
|
|
206
300
|
const base = (args.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, "");
|
|
207
|
-
const ctx:
|
|
301
|
+
const ctx: ArmEvalCtx = {
|
|
208
302
|
subscriptionId: args.subscriptionId ?? DEFAULT_SUBSCRIPTION,
|
|
209
303
|
resourceGroup: args.resourceGroup,
|
|
210
304
|
location: args.location ?? "eastus",
|
|
305
|
+
deployed: new Map(),
|
|
306
|
+
http,
|
|
307
|
+
base,
|
|
308
|
+
signal,
|
|
211
309
|
};
|
|
212
310
|
|
|
213
311
|
// Ensure the resource group exists (ARM rejects resource PUTs without it).
|
|
@@ -219,16 +317,152 @@ export async function azApply(
|
|
|
219
317
|
);
|
|
220
318
|
|
|
221
319
|
const template = JSON.parse(readFileSync(args.templatePath, "utf8")) as { resources?: ArmResource[] };
|
|
320
|
+
const resources = template.resources ?? [];
|
|
222
321
|
const applied: Array<{ type: string; name: string }> = [];
|
|
223
|
-
for (const resource of
|
|
224
|
-
const name = evalArmString(resource.name, ctx);
|
|
322
|
+
for (const resource of orderArmResources(resources)) {
|
|
323
|
+
const name = String(await evalArmString(resource.name, ctx));
|
|
225
324
|
safeHeartbeat({ step: "azApply", type: resource.type, name });
|
|
226
|
-
|
|
325
|
+
// Stamp chant ownership so a later prune can tell chant-managed resources
|
|
326
|
+
// apart from foreign ones in the same group.
|
|
327
|
+
const body = await armResourceBody(resource, ctx);
|
|
328
|
+
body.tags = { ...((body.tags as Record<string, string> | undefined) ?? {}), ...chantOwnershipTags() };
|
|
329
|
+
const res = await http("PUT", await armResourceUrl(resource, ctx), body, signal);
|
|
227
330
|
if (res.status >= 300) {
|
|
228
331
|
throw new Error(`${resource.type} ${name} apply failed (${res.status}): ${res.text}`);
|
|
229
332
|
}
|
|
333
|
+
// Capture the applied resource so later reference()/dependents resolve.
|
|
334
|
+
try {
|
|
335
|
+
ctx.deployed.set(name, JSON.parse(res.text));
|
|
336
|
+
} catch {
|
|
337
|
+
// non-JSON response — reference() to this resource resolves to undefined
|
|
338
|
+
}
|
|
230
339
|
console.log(`applied: ${resource.type}/${name} (${base})`);
|
|
231
340
|
applied.push({ type: resource.type, name });
|
|
232
341
|
}
|
|
233
|
-
|
|
342
|
+
|
|
343
|
+
const pruned = args.prune ? await pruneArmOrphans(resources, ctx, http, signal) : [];
|
|
344
|
+
return { applied, pruned };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── Ownership + prune + delete ────────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
// chant stamps this tag on every resource it applies; prune only ever deletes
|
|
350
|
+
// resources carrying it, so a foreign resource sharing the group is never
|
|
351
|
+
// touched. Real Azure persists resource tags; note that floci-az currently drops
|
|
352
|
+
// them, so owned-only prune only takes effect against real Azure (the delete
|
|
353
|
+
// mechanics themselves work against either — see azDelete).
|
|
354
|
+
const OWNERSHIP_TAG_KEY = "managed-by";
|
|
355
|
+
const OWNERSHIP_TAG_VALUE = "chant";
|
|
356
|
+
|
|
357
|
+
/** The ownership tag azApply stamps on every resource it applies. */
|
|
358
|
+
export function chantOwnershipTags(): Record<string, string> {
|
|
359
|
+
return { [OWNERSHIP_TAG_KEY]: OWNERSHIP_TAG_VALUE };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Whether a resource's tags mark it chant-owned. */
|
|
363
|
+
export function isChantOwned(tags: Record<string, string> | null | undefined): boolean {
|
|
364
|
+
return tags?.[OWNERSHIP_TAG_KEY] === OWNERSHIP_TAG_VALUE;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** One resource from the ARM resource-group listing. */
|
|
368
|
+
export interface ArmListItem {
|
|
369
|
+
id: string;
|
|
370
|
+
name: string;
|
|
371
|
+
type: string;
|
|
372
|
+
tags?: Record<string, string>;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** List the resources in the group via the ARM resource-list endpoint. */
|
|
376
|
+
export async function listGroupResources(ctx: ArmEvalCtx, http: AzHttp = defaultHttp, signal?: AbortSignal): Promise<ArmListItem[]> {
|
|
377
|
+
const url = `${ctx.base}/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}/resources?api-version=2021-04-01`;
|
|
378
|
+
const res = await http("GET", url, undefined, signal);
|
|
379
|
+
if (res.status >= 300) return [];
|
|
380
|
+
try {
|
|
381
|
+
return ((JSON.parse(res.text) as { value?: ArmListItem[] }).value ?? []).filter((r) => r?.type && r?.name);
|
|
382
|
+
} catch {
|
|
383
|
+
return [];
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Idempotently delete one ARM resource by type/name/apiVersion. A 404 means it is already gone. */
|
|
388
|
+
export async function deleteArmResource(
|
|
389
|
+
type: string,
|
|
390
|
+
name: string,
|
|
391
|
+
apiVersion: string,
|
|
392
|
+
ctx: ArmEvalCtx,
|
|
393
|
+
http: AzHttp = defaultHttp,
|
|
394
|
+
signal?: AbortSignal,
|
|
395
|
+
): Promise<{ type: string; name: string; deleted: boolean }> {
|
|
396
|
+
const url = `${ctx.base}/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}/providers/${type}/${name}?api-version=${apiVersion}`;
|
|
397
|
+
const res = await http("DELETE", url, undefined, signal);
|
|
398
|
+
if (res.status === 404) return { type, name, deleted: false };
|
|
399
|
+
if (res.status >= 300) throw new Error(`${type} ${name} delete failed (${res.status}): ${res.text}`);
|
|
400
|
+
return { type, name, deleted: true };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Owned-only prune: for each resource type present in the template, delete the
|
|
405
|
+
* 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.
|
|
409
|
+
*/
|
|
410
|
+
export async function pruneArmOrphans(
|
|
411
|
+
desired: ArmResource[],
|
|
412
|
+
ctx: ArmEvalCtx,
|
|
413
|
+
http: AzHttp = defaultHttp,
|
|
414
|
+
signal?: AbortSignal,
|
|
415
|
+
): Promise<Array<{ type: string; name: string; deleted: boolean }>> {
|
|
416
|
+
const byType = new Map<string, { keep: Set<string>; apiVersion: string }>();
|
|
417
|
+
for (const r of desired) {
|
|
418
|
+
const name = String(await evalArmString(r.name, ctx));
|
|
419
|
+
const entry = byType.get(r.type) ?? { keep: new Set<string>(), apiVersion: r.apiVersion };
|
|
420
|
+
entry.keep.add(name);
|
|
421
|
+
byType.set(r.type, entry);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const pruned: Array<{ type: string; name: string; deleted: boolean }> = [];
|
|
425
|
+
for (const item of await listGroupResources(ctx, http, signal)) {
|
|
426
|
+
const entry = byType.get(item.type);
|
|
427
|
+
if (!entry || !isChantOwned(item.tags) || entry.keep.has(item.name)) continue;
|
|
428
|
+
safeHeartbeat({ step: "azPrune", type: item.type, name: item.name });
|
|
429
|
+
const result = await deleteArmResource(item.type, item.name, entry.apiVersion, ctx, http, signal);
|
|
430
|
+
console.log(`pruned: ${item.type}/${item.name} (${ctx.base})`);
|
|
431
|
+
pruned.push(result);
|
|
432
|
+
}
|
|
433
|
+
return pruned;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* The inverse of {@link azApply} — read a built ARM template and delete the
|
|
438
|
+
* resources it declares, in reverse dependency order (a referrer goes before the
|
|
439
|
+
* resource it references). Idempotent: already-absent resources are a no-op. The
|
|
440
|
+
* Azure twin of `gcpDelete`; `http` is injectable for tests.
|
|
441
|
+
*/
|
|
442
|
+
export async function azDelete(
|
|
443
|
+
args: AzApplyArgs,
|
|
444
|
+
signal?: AbortSignal,
|
|
445
|
+
http: AzHttp = defaultHttp,
|
|
446
|
+
): Promise<{ deleted: Array<{ type: string; name: string; deleted: boolean }> }> {
|
|
447
|
+
const base = (args.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, "");
|
|
448
|
+
const ctx: ArmEvalCtx = {
|
|
449
|
+
subscriptionId: args.subscriptionId ?? DEFAULT_SUBSCRIPTION,
|
|
450
|
+
resourceGroup: args.resourceGroup,
|
|
451
|
+
location: args.location ?? "eastus",
|
|
452
|
+
deployed: new Map(),
|
|
453
|
+
http,
|
|
454
|
+
base,
|
|
455
|
+
signal,
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const template = JSON.parse(readFileSync(args.templatePath, "utf8")) as { resources?: ArmResource[] };
|
|
459
|
+
const deleted: Array<{ type: string; name: string; deleted: boolean }> = [];
|
|
460
|
+
for (const resource of orderArmResources(template.resources ?? []).reverse()) {
|
|
461
|
+
const name = String(await evalArmString(resource.name, ctx));
|
|
462
|
+
safeHeartbeat({ step: "azDelete", type: resource.type, name });
|
|
463
|
+
const result = await deleteArmResource(resource.type, name, resource.apiVersion, ctx, http, signal);
|
|
464
|
+
console.log(`${result.deleted ? "deleted" : "absent"}: ${resource.type}/${name} (${base})`);
|
|
465
|
+
deleted.push(result);
|
|
466
|
+
}
|
|
467
|
+
return { deleted };
|
|
234
468
|
}
|
|
@@ -6,5 +6,19 @@
|
|
|
6
6
|
export { azGroupEnsure, azGroupDelete, azGroupEnsureCommand, azGroupDeleteCommand } from "./azure";
|
|
7
7
|
export type { AzGroupEnsureArgs, AzGroupDeleteArgs } from "./azure";
|
|
8
8
|
|
|
9
|
-
export {
|
|
10
|
-
|
|
9
|
+
export {
|
|
10
|
+
azApply,
|
|
11
|
+
azDelete,
|
|
12
|
+
pruneArmOrphans,
|
|
13
|
+
deleteArmResource,
|
|
14
|
+
listGroupResources,
|
|
15
|
+
chantOwnershipTags,
|
|
16
|
+
isChantOwned,
|
|
17
|
+
evalArm,
|
|
18
|
+
evalArmString,
|
|
19
|
+
armResourceUrl,
|
|
20
|
+
armResourceBody,
|
|
21
|
+
armDependencies,
|
|
22
|
+
orderArmResources,
|
|
23
|
+
} from "./az-apply";
|
|
24
|
+
export type { AzApplyArgs, ArmEvalCtx, ArmResource, ArmListItem, AzHttp } from "./az-apply";
|