@intentius/chant-lexicon-azure 0.15.0 → 0.15.2

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.
@@ -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
- // ── ARM expression evaluation (the subset chant emits) ────────────────────────
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
- * Evaluate an ARM template expression string (`"[...]"`). Supports the functions
32
- * chant's serializer emits: `concat`, `uniqueString`, `resourceGroup()` (`.id` /
33
- * `.location`), `subscription()` (`.subscriptionId`), and string literals. A
34
- * non-expression string is returned unchanged. Pure.
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 function evalArmString(s: string, ctx: ArmContext): string {
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
- // "[[" is an escaped literal "[".
39
- if (s.startsWith("[[")) return s.slice(1);
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. Pure. */
44
- export function evalArm(value: unknown, ctx: ArmContext): unknown {
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
- /** Minimal recursive-descent evaluator for the ARM function subset. */
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: ArmContext) {}
85
+ constructor(private readonly src: string, private readonly ctx: ArmEvalCtx) {}
59
86
 
60
- parse(): unknown {
61
- const v = this.expr();
62
- return v;
87
+ async parse(): Promise<unknown> {
88
+ return this.access(await this.atom());
63
89
  }
64
90
 
65
- private expr(): unknown {
91
+ private async atom(): Promise<unknown> {
66
92
  this.ws();
67
- let v: unknown;
68
- if (this.src[this.i] === "'") {
69
- v = this.stringLiteral();
70
- } else {
71
- v = this.call();
72
- }
73
- // Property access: resourceGroup().location, subscription().subscriptionId
74
- while (this.peek() === ".") {
75
- this.i++;
76
- const prop = this.ident();
77
- v = (v as Record<string, unknown>)?.[prop];
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.expr());
120
+ args.push(await this.parse());
91
121
  this.ws();
92
122
  while (this.peek() === ",") {
93
123
  this.i++;
94
- args.push(this.expr());
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
- // ── HTTP + apply ──────────────────────────────────────────────────────────────
201
+ // ── Dependency ordering ───────────────────────────────────────────────────────
144
202
 
145
- /** Injectable HTTP client mirrors the GCP applier so tests avoid the network. */
146
- export type AzHttp = (
147
- method: string,
148
- url: string,
149
- body?: unknown,
150
- signal?: AbortSignal,
151
- ) => Promise<{ status: number; text: string }>;
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
- const defaultHttp: AzHttp = async (method, url, body, signal) => {
154
- const res = await fetch(url, {
155
- method,
156
- headers: body === undefined ? undefined : { "content-type": "application/json" },
157
- body: body === undefined ? undefined : JSON.stringify(body),
158
- signal,
159
- });
160
- return { status: res.status, text: await res.text() };
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
- /** The ARM resource-ID PUT URL for a resource under a resource group. Pure. */
164
- export function armResourceUrl(resource: ArmResource, ctx: ArmContext, base: string): string {
165
- const name = evalArmString(resource.name, ctx);
166
- return `${base}/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}/providers/${resource.type}/${name}?api-version=${resource.apiVersion}`;
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. Pure. */
170
- export function armResourceBody(resource: ArmResource, ctx: ArmContext): Record<string, unknown> {
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, resolving ARM expressions first. This
196
- * is the direct-apply path (the Azure twin of `gcpApply`): it targets floci-az's
197
- * ARM resource endpoints which `az deployment` cannot, since floci-az has no
198
- * `Microsoft.Resources/deployments` provider or real Azure by endpoint override.
199
- * The resource group is ensured first. `http` is injectable for tests.
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: ArmContext = {
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 template.resources ?? []) {
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
- const res = await http("PUT", armResourceUrl(resource, ctx, base), armResourceBody(resource, ctx), signal);
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
- return { applied };
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
  }
@@ -0,0 +1,29 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ flociAzRunCommand,
4
+ flociAzRmCommand,
5
+ flociAzExistsCommand,
6
+ flociAzHealthUrl,
7
+ flociAzEndpoint,
8
+ } from "./floci-az";
9
+
10
+ describe("floci-az lifecycle commands (typed emulator, not shell)", () => {
11
+ test("run command uses defaults and maps the port", () => {
12
+ expect(flociAzRunCommand({})).toBe(
13
+ "docker run -d --rm --name chant-floci-az -p 4577:4577 floci/floci-az:latest",
14
+ );
15
+ });
16
+
17
+ test("run command honors name/port/image overrides", () => {
18
+ expect(flociAzRunCommand({ name: "az2", port: 4599, image: "floci/floci-az:0.8.0" })).toBe(
19
+ "docker run -d --rm --name az2 -p 4599:4577 floci/floci-az:0.8.0",
20
+ );
21
+ });
22
+
23
+ test("rm / exists / health / endpoint", () => {
24
+ expect(flociAzRmCommand("az2")).toBe("docker rm -f az2");
25
+ expect(flociAzExistsCommand("az2")).toBe("docker ps -q -f name=az2");
26
+ expect(flociAzHealthUrl(4577)).toBe("http://localhost:4577/_floci/health");
27
+ expect(flociAzEndpoint(4577)).toBe("http://localhost:4577");
28
+ });
29
+ });
@@ -0,0 +1,123 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { safeHeartbeat, sleep } from "@intentius/chant/op";
4
+
5
+ const execAsync = promisify(exec);
6
+
7
+ const DEFAULT_NAME = "chant-floci-az";
8
+ const DEFAULT_PORT = 4577;
9
+ const DEFAULT_IMAGE = "floci/floci-az:latest";
10
+
11
+ export interface FlociAzUpArgs {
12
+ /** Container name. Default: `chant-floci-az`. */
13
+ name?: string;
14
+ /** Host port mapped to the emulator's `:4577`. Default: `4577`. */
15
+ port?: number;
16
+ /** Image. Default: `floci/floci-az:latest`. */
17
+ image?: string;
18
+ /** Readiness timeout in ms. Default: `60000`. */
19
+ timeoutMs?: number;
20
+ /** Health poll interval in ms. Default: `2000`. */
21
+ intervalMs?: number;
22
+ }
23
+
24
+ export interface FlociAzDownArgs {
25
+ /** Container name to remove. Default: `chant-floci-az`. */
26
+ name?: string;
27
+ }
28
+
29
+ /** `docker ps -q -f name=<name>` — non-empty stdout means the container is running. */
30
+ export function flociAzExistsCommand(name: string): string {
31
+ return `docker ps -q -f name=${name}`;
32
+ }
33
+
34
+ /** Build the `docker run` command that boots floci-az. */
35
+ export function flociAzRunCommand(args: FlociAzUpArgs): string {
36
+ const name = args.name ?? DEFAULT_NAME;
37
+ const port = args.port ?? DEFAULT_PORT;
38
+ const image = args.image ?? DEFAULT_IMAGE;
39
+ return ["docker", "run", "-d", "--rm", "--name", name, "-p", `${port}:4577`, image].join(" ");
40
+ }
41
+
42
+ /** Build the `docker rm -f` command. */
43
+ export function flociAzRmCommand(name: string): string {
44
+ return `docker rm -f ${name}`;
45
+ }
46
+
47
+ /** The floci-az health endpoint URL for a host port. */
48
+ export function flociAzHealthUrl(port: number): string {
49
+ return `http://localhost:${port}/_floci/health`;
50
+ }
51
+
52
+ /** The ARM endpoint URL (what `azApply`'s `endpoint` should point at). */
53
+ export function flociAzEndpoint(port: number): string {
54
+ return `http://localhost:${port}`;
55
+ }
56
+
57
+ /**
58
+ * Boot a local floci-az (Azure emulator) in Docker and return its ARM endpoint.
59
+ *
60
+ * Idempotent: reuses a running container of the same name. Waits for the health
61
+ * endpoint to answer, then returns `{ endpoint }` for `azApply({ endpoint })`.
62
+ * The typed twin of the AWS `flociUp` — replaces a raw `docker run` shell step so
63
+ * the emulator lifecycle is modeled, not scripted. Uses longInfra profile — 20m
64
+ * timeout, heartbeat every poll (the image may pull).
65
+ */
66
+ export async function flociAzUp(args: FlociAzUpArgs, signal?: AbortSignal): Promise<{ endpoint: string }> {
67
+ const name = args.name ?? DEFAULT_NAME;
68
+ const port = args.port ?? DEFAULT_PORT;
69
+ const timeoutMs = args.timeoutMs ?? 60_000;
70
+ const intervalMs = args.intervalMs ?? 2_000;
71
+
72
+ let running = false;
73
+ try {
74
+ const { stdout } = await execAsync(flociAzExistsCommand(name), { signal });
75
+ running = Boolean(stdout.trim());
76
+ } catch {
77
+ // `docker ps` failed — assume not running and try to start it.
78
+ }
79
+
80
+ if (running) {
81
+ console.log(`floci-az container "${name}" already running — reusing`);
82
+ } else {
83
+ await execAsync(flociAzRunCommand({ ...args, name, port }), { signal });
84
+ }
85
+
86
+ const url = flociAzHealthUrl(port);
87
+ const deadline = Date.now() + timeoutMs;
88
+ let ready = false;
89
+ while (Date.now() < deadline) {
90
+ if (signal?.aborted) throw new Error("flociAzUp aborted");
91
+ safeHeartbeat({ step: "flociAzUp", container: name });
92
+ try {
93
+ const res = await fetch(url, { signal });
94
+ if (res.ok) {
95
+ ready = true;
96
+ break;
97
+ }
98
+ } catch {
99
+ // Not up yet (connection refused / non-2xx) — retry.
100
+ }
101
+ await sleep(intervalMs, signal);
102
+ }
103
+ if (!ready) {
104
+ throw new Error(`floci-az "${name}" did not become ready within ${timeoutMs}ms`);
105
+ }
106
+
107
+ const endpoint = flociAzEndpoint(port);
108
+ console.log(`floci-az ready on ${endpoint}`);
109
+ return { endpoint };
110
+ }
111
+
112
+ /**
113
+ * Stop and remove the local floci-az container. A no-op success when the
114
+ * container is already gone. Uses fastIdempotent profile — 5m timeout.
115
+ */
116
+ export async function flociAzDown(args: FlociAzDownArgs, signal?: AbortSignal): Promise<void> {
117
+ const name = args.name ?? DEFAULT_NAME;
118
+ try {
119
+ await execAsync(flociAzRmCommand(name), { signal });
120
+ } catch {
121
+ // Already removed (`--rm` on exit, or never started) — treat as success.
122
+ }
123
+ }