@intentius/chant-lexicon-azure 0.13.1 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/import/live-export.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/lint/audit-catalog.d.ts +7 -0
- package/dist/lint/audit-catalog.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/az-apply.d.ts +130 -0
- package/dist/op/activities/az-apply.d.ts.map +1 -0
- package/dist/op/activities/azure.d.ts +37 -0
- package/dist/op/activities/azure.d.ts.map +1 -0
- package/dist/op/activities/index.d.ts +10 -0
- package/dist/op/activities/index.d.ts.map +1 -0
- package/dist/ownership.d.ts +11 -0
- package/dist/ownership.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/serializer.d.ts.map +1 -1
- package/package.json +7 -2
- package/src/import/live-export.ts +2 -1
- package/src/lint/audit-catalog.ts +30 -0
- package/src/op/activities/az-apply.test.ts +331 -0
- package/src/op/activities/az-apply.ts +468 -0
- package/src/op/activities/azure.test.ts +22 -0
- package/src/op/activities/azure.ts +66 -0
- package/src/op/activities/index.ts +24 -0
- package/src/ownership.test.ts +19 -0
- package/src/ownership.ts +16 -0
- package/src/plugin.ts +2 -0
- package/src/serializer.ts +2 -1
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { safeHeartbeat } from "@intentius/chant/op";
|
|
4
|
+
|
|
5
|
+
// The floci-az default local subscription (from its ARM management-plane mock).
|
|
6
|
+
const DEFAULT_SUBSCRIPTION = "00000000-0000-0000-0000-000000000001";
|
|
7
|
+
const DEFAULT_ENDPOINT = "https://management.azure.com";
|
|
8
|
+
|
|
9
|
+
/** One ARM resource from a `deploymentTemplate.json` `resources[]`. */
|
|
10
|
+
export interface ArmResource {
|
|
11
|
+
type: string;
|
|
12
|
+
apiVersion: string;
|
|
13
|
+
name: string;
|
|
14
|
+
location?: string;
|
|
15
|
+
properties?: unknown;
|
|
16
|
+
sku?: unknown;
|
|
17
|
+
kind?: unknown;
|
|
18
|
+
tags?: Record<string, string>;
|
|
19
|
+
dependsOn?: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
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 ─────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
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.
|
|
47
|
+
*/
|
|
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> {
|
|
60
|
+
if (!(s.startsWith("[") && s.endsWith("]"))) return s;
|
|
61
|
+
if (s.startsWith("[[")) return s.slice(1); // escaped literal "["
|
|
62
|
+
return new ArmExpr(s.slice(1, -1), ctx).parse();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Recursively evaluate every string in a value against the ARM context. */
|
|
66
|
+
export async function evalArm(value: unknown, ctx: ArmEvalCtx): Promise<unknown> {
|
|
67
|
+
if (typeof value === "string") return evalArmString(value, ctx);
|
|
68
|
+
if (Array.isArray(value)) return Promise.all(value.map((v) => evalArm(v, ctx)));
|
|
69
|
+
if (value && typeof value === "object") {
|
|
70
|
+
const out: Record<string, unknown> = {};
|
|
71
|
+
for (const [k, v] of Object.entries(value)) out[k] = await evalArm(v, ctx);
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
|
|
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
|
+
*/
|
|
83
|
+
class ArmExpr {
|
|
84
|
+
private i = 0;
|
|
85
|
+
constructor(private readonly src: string, private readonly ctx: ArmEvalCtx) {}
|
|
86
|
+
|
|
87
|
+
async parse(): Promise<unknown> {
|
|
88
|
+
return this.access(await this.atom());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private async atom(): Promise<unknown> {
|
|
92
|
+
this.ws();
|
|
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
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async call(): Promise<unknown> {
|
|
113
|
+
const name = this.ident();
|
|
114
|
+
this.ws();
|
|
115
|
+
const args: unknown[] = [];
|
|
116
|
+
if (this.peek() === "(") {
|
|
117
|
+
this.i++;
|
|
118
|
+
this.ws();
|
|
119
|
+
if (this.peek() !== ")") {
|
|
120
|
+
args.push(await this.parse());
|
|
121
|
+
this.ws();
|
|
122
|
+
while (this.peek() === ",") {
|
|
123
|
+
this.i++;
|
|
124
|
+
args.push(await this.parse());
|
|
125
|
+
this.ws();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (this.peek() === ")") this.i++;
|
|
129
|
+
}
|
|
130
|
+
return this.applyFn(name, args);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async applyFn(name: string, args: unknown[]): Promise<unknown> {
|
|
134
|
+
switch (name) {
|
|
135
|
+
case "concat":
|
|
136
|
+
return args.map(String).join("");
|
|
137
|
+
case "uniqueString":
|
|
138
|
+
return createHash("sha256").update(args.map(String).join("-")).digest("hex").slice(0, 13);
|
|
139
|
+
case "resourceGroup":
|
|
140
|
+
return { location: this.ctx.location, id: `/subscriptions/${this.ctx.subscriptionId}/resourceGroups/${this.ctx.resourceGroup}`, name: this.ctx.resourceGroup };
|
|
141
|
+
case "subscription":
|
|
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
|
+
}
|
|
162
|
+
default:
|
|
163
|
+
throw new Error(`unsupported ARM function: ${name}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private stringLiteral(): string {
|
|
168
|
+
this.i++; // opening '
|
|
169
|
+
let out = "";
|
|
170
|
+
while (this.i < this.src.length && this.src[this.i] !== "'") out += this.src[this.i++];
|
|
171
|
+
this.i++; // closing '
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private ident(): string {
|
|
176
|
+
this.ws();
|
|
177
|
+
let out = "";
|
|
178
|
+
while (this.i < this.src.length && /[A-Za-z0-9_]/.test(this.src[this.i])) out += this.src[this.i++];
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
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
|
+
|
|
191
|
+
private ws(): void {
|
|
192
|
+
while (this.i < this.src.length && /\s/.test(this.src[this.i])) this.i++;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private peek(): string {
|
|
196
|
+
this.ws();
|
|
197
|
+
return this.src[this.i];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Dependency ordering ───────────────────────────────────────────────────────
|
|
202
|
+
|
|
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
|
+
}
|
|
220
|
+
|
|
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
|
+
}
|
|
248
|
+
|
|
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}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
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>> {
|
|
259
|
+
const body: Record<string, unknown> = {};
|
|
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);
|
|
265
|
+
return body;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export interface AzApplyArgs {
|
|
269
|
+
/** Path to a built ARM template (`deploymentTemplate.json`). */
|
|
270
|
+
templatePath: string;
|
|
271
|
+
/** Resource group to deploy into. */
|
|
272
|
+
resourceGroup: string;
|
|
273
|
+
/** Region for the resource group and `resourceGroup().location`. Default: `eastus`. */
|
|
274
|
+
location?: string;
|
|
275
|
+
/** ARM endpoint override (e.g. floci-az `http://localhost:4577`). Default: real Azure. */
|
|
276
|
+
endpoint?: string;
|
|
277
|
+
/** Subscription id. Default: floci-az's local subscription. */
|
|
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;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The native Azure applier — read a built ARM template and PUT each resource
|
|
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.
|
|
294
|
+
*/
|
|
295
|
+
export async function azApply(
|
|
296
|
+
args: AzApplyArgs,
|
|
297
|
+
signal?: AbortSignal,
|
|
298
|
+
http: AzHttp = defaultHttp,
|
|
299
|
+
): Promise<{ applied: Array<{ type: string; name: string }>; pruned: Array<{ type: string; name: string; deleted: boolean }> }> {
|
|
300
|
+
const base = (args.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, "");
|
|
301
|
+
const ctx: ArmEvalCtx = {
|
|
302
|
+
subscriptionId: args.subscriptionId ?? DEFAULT_SUBSCRIPTION,
|
|
303
|
+
resourceGroup: args.resourceGroup,
|
|
304
|
+
location: args.location ?? "eastus",
|
|
305
|
+
deployed: new Map(),
|
|
306
|
+
http,
|
|
307
|
+
base,
|
|
308
|
+
signal,
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
// Ensure the resource group exists (ARM rejects resource PUTs without it).
|
|
312
|
+
await http(
|
|
313
|
+
"PUT",
|
|
314
|
+
`${base}/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}?api-version=2021-04-01`,
|
|
315
|
+
{ location: ctx.location },
|
|
316
|
+
signal,
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const template = JSON.parse(readFileSync(args.templatePath, "utf8")) as { resources?: ArmResource[] };
|
|
320
|
+
const resources = template.resources ?? [];
|
|
321
|
+
const applied: Array<{ type: string; name: string }> = [];
|
|
322
|
+
for (const resource of orderArmResources(resources)) {
|
|
323
|
+
const name = String(await evalArmString(resource.name, ctx));
|
|
324
|
+
safeHeartbeat({ step: "azApply", type: resource.type, name });
|
|
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);
|
|
330
|
+
if (res.status >= 300) {
|
|
331
|
+
throw new Error(`${resource.type} ${name} apply failed (${res.status}): ${res.text}`);
|
|
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
|
+
}
|
|
339
|
+
console.log(`applied: ${resource.type}/${name} (${base})`);
|
|
340
|
+
applied.push({ type: resource.type, name });
|
|
341
|
+
}
|
|
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 };
|
|
468
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { azGroupEnsureCommand, azGroupDeleteCommand } from "./azure";
|
|
3
|
+
|
|
4
|
+
describe("azGroupEnsureCommand (#707)", () => {
|
|
5
|
+
test("creates the resource group in the given location, quietly", () => {
|
|
6
|
+
expect(azGroupEnsureCommand("chant-rg", "eastus")).toBe(
|
|
7
|
+
"az group create --name chant-rg --location eastus --output none",
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("honors a non-default location", () => {
|
|
12
|
+
expect(azGroupEnsureCommand("rg", "westeurope")).toContain("--location westeurope");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("azGroupDeleteCommand (#707)", () => {
|
|
17
|
+
test("force-deletes the group without blocking", () => {
|
|
18
|
+
expect(azGroupDeleteCommand("chant-rg")).toBe(
|
|
19
|
+
"az group delete --name chant-rg --yes --no-wait",
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execAsync = promisify(exec);
|
|
5
|
+
|
|
6
|
+
const DEFAULT_LOCATION = "eastus";
|
|
7
|
+
|
|
8
|
+
export interface AzGroupEnsureArgs {
|
|
9
|
+
/** Resource group name — the same value passed as `env` to a `arm` `nativeApply`. */
|
|
10
|
+
resourceGroup: string;
|
|
11
|
+
/** Azure region for the group. Default: `eastus`. */
|
|
12
|
+
location?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AzGroupDeleteArgs {
|
|
16
|
+
/** Resource group to delete. */
|
|
17
|
+
resourceGroup: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build the `az group create` command. `az group create` is idempotent — a group
|
|
22
|
+
* that already exists is returned unchanged — so no existence check is needed.
|
|
23
|
+
* Pure — exported for testing.
|
|
24
|
+
*/
|
|
25
|
+
export function azGroupEnsureCommand(resourceGroup: string, location: string): string {
|
|
26
|
+
return `az group create --name ${resourceGroup} --location ${location} --output none`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Build the `az group delete` command (non-blocking). Pure — exported for testing. */
|
|
30
|
+
export function azGroupDeleteCommand(resourceGroup: string): string {
|
|
31
|
+
return `az group delete --name ${resourceGroup} --yes --no-wait`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Ensure an Azure resource group exists before an ARM deployment.
|
|
36
|
+
*
|
|
37
|
+
* `az deployment group create` (the `arm` target of `nativeApply`) fails if its
|
|
38
|
+
* resource group is absent — unlike CloudFormation, which creates its own stack.
|
|
39
|
+
* Run this before the apply. Uses fastIdempotent profile — 5m timeout.
|
|
40
|
+
*
|
|
41
|
+
* This targets real Azure (via the logged-in `az` context). Local emulation
|
|
42
|
+
* against floci-az awaits an upstream `Microsoft.Resources/deployments` provider
|
|
43
|
+
* (see #705); when that lands, an endpoint/custom-cloud override slots in here.
|
|
44
|
+
*/
|
|
45
|
+
export async function azGroupEnsure(args: AzGroupEnsureArgs, signal?: AbortSignal): Promise<void> {
|
|
46
|
+
const location = args.location ?? DEFAULT_LOCATION;
|
|
47
|
+
const { stdout, stderr } = await execAsync(
|
|
48
|
+
azGroupEnsureCommand(args.resourceGroup, location),
|
|
49
|
+
{ signal },
|
|
50
|
+
);
|
|
51
|
+
if (stdout) console.log(stdout);
|
|
52
|
+
if (stderr) console.error(stderr);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Delete an Azure resource group and everything in it (non-blocking). A no-op
|
|
57
|
+
* success when the group is already gone. Uses fastIdempotent profile — 5m
|
|
58
|
+
* timeout (`--no-wait` returns immediately).
|
|
59
|
+
*/
|
|
60
|
+
export async function azGroupDelete(args: AzGroupDeleteArgs, signal?: AbortSignal): Promise<void> {
|
|
61
|
+
try {
|
|
62
|
+
await execAsync(azGroupDeleteCommand(args.resourceGroup), { signal });
|
|
63
|
+
} catch {
|
|
64
|
+
// Group already absent — treat as success.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure Op activities — resolved by the core activity registry when a project's
|
|
3
|
+
* `chant.config.ts` lists the `azure` lexicon. Contributes the resource-group
|
|
4
|
+
* lifecycle (`azGroupEnsure`/`azGroupDelete`) that an ARM `nativeApply` needs.
|
|
5
|
+
*/
|
|
6
|
+
export { azGroupEnsure, azGroupDelete, azGroupEnsureCommand, azGroupDeleteCommand } from "./azure";
|
|
7
|
+
export type { AzGroupEnsureArgs, AzGroupDeleteArgs } from "./azure";
|
|
8
|
+
|
|
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";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { ownershipEntries } from "@intentius/chant/ownership";
|
|
3
|
+
import { AZURE_TAG_OWNERSHIP_KEYS } from "./ownership";
|
|
4
|
+
|
|
5
|
+
describe("AZURE_TAG_OWNERSHIP_KEYS", () => {
|
|
6
|
+
test("uses Azure hyphen-form tag keys (no slash, which Azure forbids)", () => {
|
|
7
|
+
expect(AZURE_TAG_OWNERSHIP_KEYS).toEqual({
|
|
8
|
+
managedBy: "chant-managed-by",
|
|
9
|
+
stack: "chant-stack",
|
|
10
|
+
env: "chant-env",
|
|
11
|
+
});
|
|
12
|
+
expect(Object.values(AZURE_TAG_OWNERSHIP_KEYS).some((k) => k.includes("/"))).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("stamps the hyphen keys via the core ownershipEntries helper", () => {
|
|
16
|
+
const e = ownershipEntries(AZURE_TAG_OWNERSHIP_KEYS, { stack: "billing", env: "stg" });
|
|
17
|
+
expect(e).toEqual({ "chant-managed-by": "chant", "chant-stack": "billing", "chant-env": "stg" });
|
|
18
|
+
});
|
|
19
|
+
});
|
package/src/ownership.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The azure lexicon's ownership tag convention. Azure tag keys forbid `/`, so
|
|
3
|
+
* chant's markers use a hyphenated `chant-<name>` form (distinct from the
|
|
4
|
+
* label-based convention core keeps for k8s/gcp, and from AWS's `chant:<name>`).
|
|
5
|
+
* Core owns the generic stamp/detect logic (@intentius/chant/ownership); this is
|
|
6
|
+
* the azure-specific key naming it stamps.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ChannelKeys } from "@intentius/chant/ownership";
|
|
10
|
+
|
|
11
|
+
/** Azure tag keys for chant's ownership markers. */
|
|
12
|
+
export const AZURE_TAG_OWNERSHIP_KEYS: ChannelKeys = {
|
|
13
|
+
managedBy: "chant-managed-by",
|
|
14
|
+
stack: "chant-stack",
|
|
15
|
+
env: "chant-env",
|
|
16
|
+
};
|
package/src/plugin.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { TypeScriptGenerator } from "@intentius/chant/import/generator";
|
|
|
6
6
|
import type { CompletionContext, CompletionItem, HoverContext, HoverInfo } from "@intentius/chant/lsp/types";
|
|
7
7
|
import type { McpToolContribution, McpResourceContribution } from "@intentius/chant/mcp/types";
|
|
8
8
|
import { postSynthChecks as postSynthCheckList } from "./lint/post-synth";
|
|
9
|
+
import { azureAuditCatalog } from "./lint/audit-catalog";
|
|
9
10
|
import { createSkillsLoader } from "@intentius/chant/lexicon-plugin-helpers";
|
|
10
11
|
import { readFileSync, readdirSync, existsSync } from "fs";
|
|
11
12
|
import { join, dirname } from "path";
|
|
@@ -27,6 +28,7 @@ import { azureHover } from "./lsp/hover";
|
|
|
27
28
|
*/
|
|
28
29
|
export const azurePlugin: LexiconPlugin = {
|
|
29
30
|
name: "azure",
|
|
31
|
+
auditCatalog: () => azureAuditCatalog,
|
|
30
32
|
serializer: azureSerializer,
|
|
31
33
|
|
|
32
34
|
lintRules(): LintRule[] {
|
package/src/serializer.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { Declarable, CoreParameter } from "@intentius/chant/declarable";
|
|
|
14
14
|
import { isPropertyDeclarable } from "@intentius/chant/declarable";
|
|
15
15
|
import type { Serializer, SerializerResult, SerializeContext } from "@intentius/chant/serializer";
|
|
16
16
|
import { ownershipEntries, type OwnershipMarker } from "@intentius/chant/ownership";
|
|
17
|
+
import { AZURE_TAG_OWNERSHIP_KEYS } from "./ownership";
|
|
17
18
|
import type { LexiconOutput } from "@intentius/chant/lexicon-output";
|
|
18
19
|
import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
|
|
19
20
|
import { isChildProject, type ChildProjectInstance } from "@intentius/chant/child-project";
|
|
@@ -191,7 +192,7 @@ function serializeToTemplate(
|
|
|
191
192
|
// first so user default tags (and explicit per-resource tags) win.
|
|
192
193
|
const defaultTagEntries: TagEntry[] = [];
|
|
193
194
|
if (ownership) {
|
|
194
|
-
for (const [key, value] of Object.entries(ownershipEntries(
|
|
195
|
+
for (const [key, value] of Object.entries(ownershipEntries(AZURE_TAG_OWNERSHIP_KEYS, ownership))) {
|
|
195
196
|
defaultTagEntries.push({ key, value });
|
|
196
197
|
}
|
|
197
198
|
}
|