@intentius/chant-lexicon-azure 0.124.30 → 0.124.32
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/actions/appservice.js +7 -0
- package/dist/actions/compute.js +9 -0
- package/dist/actions/container.js +14 -0
- package/dist/actions/identity.js +11 -0
- package/dist/actions/index.js +11 -0
- package/dist/actions/keyvault.js +12 -0
- package/dist/actions/network.js +9 -0
- package/dist/actions/sql.js +9 -0
- package/dist/actions/storage.js +15 -0
- package/dist/api/read-client.js +146 -0
- package/dist/codegen/docs-cli.js +2 -0
- package/dist/codegen/docs.js +83 -0
- package/dist/codegen/generate-cli.js +11 -0
- package/dist/codegen/generate-lexicon.js +77 -0
- package/dist/codegen/generate-typescript.js +163 -0
- package/dist/codegen/generate.js +106 -0
- package/dist/codegen/naming.js +139 -0
- package/dist/codegen/package.js +64 -0
- package/dist/composites/aks-cluster.js +68 -0
- package/dist/composites/app-service.js +62 -0
- package/dist/composites/application-gateway.js +107 -0
- package/dist/composites/catalog.js +856 -0
- package/dist/composites/container-instance.js +36 -0
- package/dist/composites/container-registry.js +62 -0
- package/dist/composites/cosmos-database.js +56 -0
- package/dist/composites/from-arm.js +47 -0
- package/dist/composites/function-app.js +57 -0
- package/dist/composites/index.js +16 -0
- package/dist/composites/keyvault.js +69 -0
- package/dist/composites/landing-zone.js +119 -0
- package/dist/composites/private-endpoint.js +61 -0
- package/dist/composites/redis-cache.js +21 -0
- package/dist/composites/service-bus-pipeline.js +54 -0
- package/dist/composites/sql-database.js +62 -0
- package/dist/composites/storage-account.js +59 -0
- package/dist/composites/vm-linux.js +139 -0
- package/dist/composites/vnet-default.js +102 -0
- package/dist/coverage.js +28 -0
- package/dist/deep-observe.js +356 -0
- package/dist/default-tags.js +50 -0
- package/dist/deploy-scopes.d.ts.map +1 -1
- package/dist/deploy-scopes.js +157 -0
- package/dist/describe-resources.js +187 -0
- package/dist/detect.js +17 -0
- package/dist/export-resources.js +108 -0
- package/dist/generated/index.js +16598 -0
- package/dist/generated/runtime.js +4 -0
- package/dist/governance.js +144 -0
- package/dist/import/generator.js +140 -0
- package/dist/import/live-export.js +39 -0
- package/dist/import/parser.js +192 -0
- package/dist/index.js +44 -0
- package/dist/integrity.json +2 -2
- package/dist/intrinsics.js +179 -0
- package/dist/lint/audit-catalog.js +36 -0
- package/dist/lint/audit-lineage.js +71 -0
- package/dist/lint/post-synth/arm-refs.js +94 -0
- package/dist/lint/post-synth/azr010.js +45 -0
- package/dist/lint/post-synth/azr011.js +43 -0
- package/dist/lint/post-synth/azr012.js +41 -0
- package/dist/lint/post-synth/azr013.js +50 -0
- package/dist/lint/post-synth/azr014.js +49 -0
- package/dist/lint/post-synth/azr015.js +66 -0
- package/dist/lint/post-synth/azr016.js +37 -0
- package/dist/lint/post-synth/azr017.js +37 -0
- package/dist/lint/post-synth/azr018.js +42 -0
- package/dist/lint/post-synth/azr019.js +41 -0
- package/dist/lint/post-synth/azr020.js +37 -0
- package/dist/lint/post-synth/azr021.js +36 -0
- package/dist/lint/post-synth/azr022.js +39 -0
- package/dist/lint/post-synth/azr023.js +39 -0
- package/dist/lint/post-synth/azr024.js +39 -0
- package/dist/lint/post-synth/azr025.js +37 -0
- package/dist/lint/post-synth/azr026.js +38 -0
- package/dist/lint/post-synth/azr027.js +37 -0
- package/dist/lint/post-synth/azr028.js +37 -0
- package/dist/lint/post-synth/azr029.js +38 -0
- package/dist/lint/post-synth/azr030.js +44 -0
- package/dist/lint/post-synth/azr031.js +36 -0
- package/dist/lint/post-synth/azr032.js +53 -0
- package/dist/lint/post-synth/azr033.js +35 -0
- package/dist/lint/post-synth/index.js +50 -0
- package/dist/lint/rules/hardcoded-location.js +82 -0
- package/dist/lint/rules/index.js +6 -0
- package/dist/lint/rules/nsg-wildcard.js +44 -0
- package/dist/lint/rules/storage-https.js +62 -0
- package/dist/lsp/completions.d.ts.map +1 -1
- package/dist/lsp/completions.js +18 -0
- package/dist/lsp/hover.d.ts.map +1 -1
- package/dist/lsp/hover.js +44 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/az-apply.js +563 -0
- package/dist/op/activities/azure.js +48 -0
- package/dist/op/activities/floci-az.js +37 -0
- package/dist/op/activities/index.js +10 -0
- package/dist/op/builders.js +70 -0
- package/dist/ownership.js +13 -0
- package/dist/package-cli.js +21 -0
- package/dist/parameter.js +13 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +472 -0
- package/dist/pseudo.js +40 -0
- package/dist/serializer.js +457 -0
- package/dist/spec/api-versions.js +130 -0
- package/dist/spec/fetch.js +196 -0
- package/dist/spec/parse.js +308 -0
- package/dist/taggable.js +34 -0
- package/dist/validate-cli.js +16 -0
- package/dist/validate.js +53 -0
- package/package.json +8 -8
- package/src/codegen/package.ts +2 -2
- package/src/deploy-scopes.ts +2 -1
- package/src/lsp/completions.ts +2 -1
- package/src/lsp/hover.ts +2 -1
- package/src/plugin.ts +3 -2
- package/src/serializer.ts +2 -2
- package/src/taggable.ts +2 -2
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { hasOwnershipMarker, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
|
|
4
|
+
import { AZURE_TAG_OWNERSHIP_KEYS } from "../../ownership.js";
|
|
5
|
+
import { lookupApiVersion } from "../../serializer.js";
|
|
6
|
+
import { compareApiDates } from "../../spec/api-versions.js";
|
|
7
|
+
import { applyResult, } from "@intentius/chant/apply";
|
|
8
|
+
// The floci-az default local subscription (from its ARM management-plane mock).
|
|
9
|
+
const DEFAULT_SUBSCRIPTION = "00000000-0000-0000-0000-000000000001";
|
|
10
|
+
const DEFAULT_ENDPOINT = "https://management.azure.com";
|
|
11
|
+
const defaultHttp = async (method, url, body, signal) => {
|
|
12
|
+
const res = await fetch(url, {
|
|
13
|
+
method,
|
|
14
|
+
headers: body === undefined ? undefined : { "content-type": "application/json" },
|
|
15
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
16
|
+
signal,
|
|
17
|
+
});
|
|
18
|
+
return { status: res.status, text: await res.text() };
|
|
19
|
+
};
|
|
20
|
+
/** Evaluate an ARM expression string (`"[...]"`); a plain string is returned as-is. */
|
|
21
|
+
export async function evalArmString(s, ctx) {
|
|
22
|
+
if (!(s.startsWith("[") && s.endsWith("]")))
|
|
23
|
+
return s;
|
|
24
|
+
if (s.startsWith("[["))
|
|
25
|
+
return s.slice(1); // escaped literal "["
|
|
26
|
+
return new ArmExpr(s.slice(1, -1), ctx).parse();
|
|
27
|
+
}
|
|
28
|
+
/** Recursively evaluate every string in a value against the ARM context. */
|
|
29
|
+
export async function evalArm(value, ctx) {
|
|
30
|
+
if (typeof value === "string")
|
|
31
|
+
return evalArmString(value, ctx);
|
|
32
|
+
if (Array.isArray(value))
|
|
33
|
+
return Promise.all(value.map((v) => evalArm(v, ctx)));
|
|
34
|
+
if (value && typeof value === "object") {
|
|
35
|
+
const out = {};
|
|
36
|
+
for (const [k, v] of Object.entries(value))
|
|
37
|
+
out[k] = await evalArm(v, ctx);
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Recursive-descent evaluator for the ARM function subset chant emits:
|
|
44
|
+
* `concat`, `uniqueString`, `resourceGroup()`, `subscription()`, `resourceId`,
|
|
45
|
+
* `reference` (runtime state of an applied resource), and `listKeys` (async key
|
|
46
|
+
* action), with `.prop` and `[index]` access.
|
|
47
|
+
*/
|
|
48
|
+
class ArmExpr {
|
|
49
|
+
constructor(src, ctx) {
|
|
50
|
+
this.src = src;
|
|
51
|
+
this.ctx = ctx;
|
|
52
|
+
this.i = 0;
|
|
53
|
+
}
|
|
54
|
+
async parse() {
|
|
55
|
+
return this.access(await this.atom());
|
|
56
|
+
}
|
|
57
|
+
async atom() {
|
|
58
|
+
this.ws();
|
|
59
|
+
return this.src[this.i] === "'" ? this.stringLiteral() : this.call();
|
|
60
|
+
}
|
|
61
|
+
/** Postfix `.prop` / `[index]` access. */
|
|
62
|
+
async access(v) {
|
|
63
|
+
for (;;) {
|
|
64
|
+
const c = this.peek();
|
|
65
|
+
if (c === ".") {
|
|
66
|
+
this.i++;
|
|
67
|
+
v = v?.[this.ident()];
|
|
68
|
+
}
|
|
69
|
+
else if (c === "[") {
|
|
70
|
+
this.i++;
|
|
71
|
+
v = v?.[this.indexNumber()];
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
return v;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async call() {
|
|
79
|
+
const name = this.ident();
|
|
80
|
+
this.ws();
|
|
81
|
+
const args = [];
|
|
82
|
+
if (this.peek() === "(") {
|
|
83
|
+
this.i++;
|
|
84
|
+
this.ws();
|
|
85
|
+
if (this.peek() !== ")") {
|
|
86
|
+
args.push(await this.parse());
|
|
87
|
+
this.ws();
|
|
88
|
+
while (this.peek() === ",") {
|
|
89
|
+
this.i++;
|
|
90
|
+
args.push(await this.parse());
|
|
91
|
+
this.ws();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (this.peek() === ")")
|
|
95
|
+
this.i++;
|
|
96
|
+
}
|
|
97
|
+
return this.applyFn(name, args);
|
|
98
|
+
}
|
|
99
|
+
async applyFn(name, args) {
|
|
100
|
+
switch (name) {
|
|
101
|
+
case "concat":
|
|
102
|
+
return args.map(String).join("");
|
|
103
|
+
case "uniqueString":
|
|
104
|
+
return createHash("sha256").update(args.map(String).join("-")).digest("hex").slice(0, 13);
|
|
105
|
+
case "resourceGroup":
|
|
106
|
+
return { location: this.ctx.location, id: `/subscriptions/${this.ctx.subscriptionId}/resourceGroups/${this.ctx.resourceGroup}`, name: this.ctx.resourceGroup };
|
|
107
|
+
case "subscription":
|
|
108
|
+
return { subscriptionId: this.ctx.subscriptionId, id: `/subscriptions/${this.ctx.subscriptionId}` };
|
|
109
|
+
case "resourceId":
|
|
110
|
+
// resourceId('Microsoft.X/y', 'name'[, 'child']) → the resource-id path.
|
|
111
|
+
return `/subscriptions/${this.ctx.subscriptionId}/resourceGroups/${this.ctx.resourceGroup}/providers/${args.map(String).join("/")}`;
|
|
112
|
+
case "reference": {
|
|
113
|
+
// reference('name') → the runtime `properties` of an already-applied resource.
|
|
114
|
+
const dep = this.ctx.deployed.get(String(args[0]));
|
|
115
|
+
return dep?.properties ?? dep;
|
|
116
|
+
}
|
|
117
|
+
case "listKeys": {
|
|
118
|
+
// listKeys(resourceId, apiVersion) → POST the resource's key action.
|
|
119
|
+
const resId = String(args[0]);
|
|
120
|
+
const apiVersion = String(args[1] ?? "2023-01-01");
|
|
121
|
+
const res = await this.ctx.http("POST", `${this.ctx.base}${resId}/listKeys?api-version=${apiVersion}`, {}, this.ctx.signal);
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(res.text);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return {};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
default:
|
|
130
|
+
throw new Error(`unsupported ARM function: ${name}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
stringLiteral() {
|
|
134
|
+
this.i++; // opening '
|
|
135
|
+
let out = "";
|
|
136
|
+
while (this.i < this.src.length && this.src[this.i] !== "'")
|
|
137
|
+
out += this.src[this.i++];
|
|
138
|
+
this.i++; // closing '
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
ident() {
|
|
142
|
+
this.ws();
|
|
143
|
+
let out = "";
|
|
144
|
+
while (this.i < this.src.length && /[A-Za-z0-9_]/.test(this.src[this.i]))
|
|
145
|
+
out += this.src[this.i++];
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
indexNumber() {
|
|
149
|
+
this.ws();
|
|
150
|
+
let n = "";
|
|
151
|
+
while (this.i < this.src.length && /[0-9]/.test(this.src[this.i]))
|
|
152
|
+
n += this.src[this.i++];
|
|
153
|
+
this.ws();
|
|
154
|
+
if (this.peek() === "]")
|
|
155
|
+
this.i++;
|
|
156
|
+
return parseInt(n, 10);
|
|
157
|
+
}
|
|
158
|
+
ws() {
|
|
159
|
+
while (this.i < this.src.length && /\s/.test(this.src[this.i]))
|
|
160
|
+
this.i++;
|
|
161
|
+
}
|
|
162
|
+
peek() {
|
|
163
|
+
this.ws();
|
|
164
|
+
return this.src[this.i];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// ── Dependency ordering ───────────────────────────────────────────────────────
|
|
168
|
+
/** Resource names this resource references via `resourceId('type','name')` / `reference('name')`. Pure. */
|
|
169
|
+
export function armDependencies(resource, names) {
|
|
170
|
+
const deps = new Set();
|
|
171
|
+
const scan = (v) => {
|
|
172
|
+
if (typeof v === "string") {
|
|
173
|
+
for (const m of v.matchAll(/resourceId\(\s*'[^']*'\s*,\s*'([^']*)'/g))
|
|
174
|
+
if (names.has(m[1]))
|
|
175
|
+
deps.add(m[1]);
|
|
176
|
+
for (const m of v.matchAll(/reference\(\s*'([^']*)'/g))
|
|
177
|
+
if (names.has(m[1]))
|
|
178
|
+
deps.add(m[1]);
|
|
179
|
+
}
|
|
180
|
+
else if (Array.isArray(v)) {
|
|
181
|
+
v.forEach(scan);
|
|
182
|
+
}
|
|
183
|
+
else if (v && typeof v === "object") {
|
|
184
|
+
Object.values(v).forEach(scan);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
scan(resource);
|
|
188
|
+
deps.delete(resource.name);
|
|
189
|
+
return [...deps];
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Topologically order ARM resources so a referenced resource is applied before
|
|
193
|
+
* the resource that references it. Names that are expressions are ordered as-is
|
|
194
|
+
* (they don't match a literal reference). Throws on a cycle. Pure.
|
|
195
|
+
*/
|
|
196
|
+
export function orderArmResources(resources) {
|
|
197
|
+
const byName = new Map();
|
|
198
|
+
for (const r of resources)
|
|
199
|
+
byName.set(r.name, r);
|
|
200
|
+
const names = new Set(resources.map((r) => r.name));
|
|
201
|
+
const ordered = [];
|
|
202
|
+
const done = new Set();
|
|
203
|
+
const active = new Set();
|
|
204
|
+
const visit = (r) => {
|
|
205
|
+
if (done.has(r))
|
|
206
|
+
return;
|
|
207
|
+
if (active.has(r))
|
|
208
|
+
throw new Error(`ARM reference cycle involving ${r.name}`);
|
|
209
|
+
active.add(r);
|
|
210
|
+
for (const dep of armDependencies(r, names)) {
|
|
211
|
+
const target = byName.get(dep);
|
|
212
|
+
if (target && target !== r)
|
|
213
|
+
visit(target);
|
|
214
|
+
}
|
|
215
|
+
active.delete(r);
|
|
216
|
+
done.add(r);
|
|
217
|
+
ordered.push(r);
|
|
218
|
+
};
|
|
219
|
+
for (const r of resources)
|
|
220
|
+
visit(r);
|
|
221
|
+
return ordered;
|
|
222
|
+
}
|
|
223
|
+
// ── URL + body + apply ────────────────────────────────────────────────────────
|
|
224
|
+
/** The ARM resource-ID PUT URL for a resource (name expression evaluated). */
|
|
225
|
+
export async function armResourceUrl(resource, ctx) {
|
|
226
|
+
const name = await evalArmString(resource.name, ctx);
|
|
227
|
+
return `${ctx.base}/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}/providers/${resource.type}/${name}?api-version=${resource.apiVersion}`;
|
|
228
|
+
}
|
|
229
|
+
/** The ARM resource PUT body (location/properties/sku/kind/tags), expressions evaluated. */
|
|
230
|
+
export async function armResourceBody(resource, ctx) {
|
|
231
|
+
const body = {};
|
|
232
|
+
if (resource.location)
|
|
233
|
+
body.location = await evalArmString(resource.location, ctx);
|
|
234
|
+
if (resource.properties !== undefined)
|
|
235
|
+
body.properties = await evalArm(resource.properties, ctx);
|
|
236
|
+
if (resource.sku !== undefined)
|
|
237
|
+
body.sku = await evalArm(resource.sku, ctx);
|
|
238
|
+
if (resource.kind !== undefined)
|
|
239
|
+
body.kind = await evalArm(resource.kind, ctx);
|
|
240
|
+
if (resource.tags !== undefined)
|
|
241
|
+
body.tags = await evalArm(resource.tags, ctx);
|
|
242
|
+
return body;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The native Azure applier — read a built ARM template and PUT each resource
|
|
246
|
+
* directly to the ARM resource-CRUD API, in dependency order, resolving ARM
|
|
247
|
+
* expressions (including `reference()`/`listKeys()` against resources applied
|
|
248
|
+
* earlier this run). The Azure twin of `gcpApply`: it targets floci-az (which
|
|
249
|
+
* `az deployment` can't, floci-az having no deployments provider) or real Azure
|
|
250
|
+
* by endpoint override; the resource group is ensured first.
|
|
251
|
+
*/
|
|
252
|
+
export async function azApply(args, signal, http = defaultHttp) {
|
|
253
|
+
const base = (args.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, "");
|
|
254
|
+
const ctx = {
|
|
255
|
+
subscriptionId: args.subscriptionId ?? DEFAULT_SUBSCRIPTION,
|
|
256
|
+
resourceGroup: args.resourceGroup,
|
|
257
|
+
location: args.location ?? "eastus",
|
|
258
|
+
deployed: new Map(),
|
|
259
|
+
http,
|
|
260
|
+
base,
|
|
261
|
+
signal,
|
|
262
|
+
};
|
|
263
|
+
// Ensure the resource group exists (ARM rejects resource PUTs without it).
|
|
264
|
+
await http("PUT", `${base}/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}?api-version=2021-04-01`, { location: ctx.location }, signal);
|
|
265
|
+
const template = JSON.parse(readFileSync(args.templatePath, "utf8"));
|
|
266
|
+
const resources = template.resources ?? [];
|
|
267
|
+
const applied = [];
|
|
268
|
+
for (const resource of orderArmResources(resources)) {
|
|
269
|
+
const name = String(await evalArmString(resource.name, ctx));
|
|
270
|
+
// Stamp chant ownership so a later prune can tell chant-managed resources
|
|
271
|
+
// apart from foreign ones in the same group.
|
|
272
|
+
const body = await armResourceBody(resource, ctx);
|
|
273
|
+
body.tags = { ...(body.tags ?? {}), ...chantOwnershipTags() };
|
|
274
|
+
const res = await http("PUT", await armResourceUrl(resource, ctx), body, signal);
|
|
275
|
+
if (res.status >= 300) {
|
|
276
|
+
throw new Error(`${resource.type} ${name} apply failed (${res.status}): ${res.text}`);
|
|
277
|
+
}
|
|
278
|
+
// Capture the applied resource so later reference()/dependents resolve.
|
|
279
|
+
try {
|
|
280
|
+
ctx.deployed.set(name, JSON.parse(res.text));
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
// non-JSON response — reference() to this resource resolves to undefined
|
|
284
|
+
}
|
|
285
|
+
console.log(`applied: ${resource.type}/${name} (${base})`);
|
|
286
|
+
applied.push({ type: resource.type, name });
|
|
287
|
+
}
|
|
288
|
+
const prune = args.prune
|
|
289
|
+
? await pruneArmOrphans(resources, ctx, http, signal)
|
|
290
|
+
: { pruned: [], notPrunable: [] };
|
|
291
|
+
return { applied, pruned: prune.pruned, notPrunable: prune.notPrunable };
|
|
292
|
+
}
|
|
293
|
+
// ── Ownership + prune + delete ────────────────────────────────────────────────
|
|
294
|
+
// chant stamps this tag on every resource it applies; prune only ever deletes
|
|
295
|
+
// resources carrying it, so a foreign resource sharing the group is never
|
|
296
|
+
// touched. Real Azure persists resource tags; note that floci-az currently drops
|
|
297
|
+
// them, so owned-only prune only takes effect against real Azure (the delete
|
|
298
|
+
// mechanics themselves work against either — see azDelete).
|
|
299
|
+
/**
|
|
300
|
+
* The key this applier used before #1446, and still recognises.
|
|
301
|
+
*
|
|
302
|
+
* The lexicon declares its marker channel in `../../ownership.ts`
|
|
303
|
+
* (`chant-managed-by`) — the key the serializer stamps, the plugin registers as
|
|
304
|
+
* `ownershipChannel`, and `import/live-export.ts` filters on. This applier used
|
|
305
|
+
* a bare `managed-by` instead, so the two never agreed.
|
|
306
|
+
*
|
|
307
|
+
* That is not cosmetic. Both are ordinary ARM tag keys on the same surface, so
|
|
308
|
+
* nothing forced the difference — and it meant a resource carrying only the
|
|
309
|
+
* SERIALIZER's marker was invisible to this prune. Anything deployed by a path
|
|
310
|
+
* other than `azApply` reads as foreign and is never pruned, including
|
|
311
|
+
* everything `ApplyOp` deployed before #1448 routed it here from
|
|
312
|
+
* `az deployment --mode Complete`.
|
|
313
|
+
*
|
|
314
|
+
* (GCP has the same-looking split for a real reason: its serializer stamps a
|
|
315
|
+
* Config Connector object, where `app.kubernetes.io/managed-by` is a legal
|
|
316
|
+
* Kubernetes label, while `gcpApply` writes GCP REST labels, where it is not.
|
|
317
|
+
* Two surfaces, two key vocabularies. Azure has one surface.)
|
|
318
|
+
*/
|
|
319
|
+
export const LEGACY_OWNERSHIP_TAG_KEY = "managed-by";
|
|
320
|
+
/**
|
|
321
|
+
* The ownership tags azApply stamps on every resource it applies.
|
|
322
|
+
*
|
|
323
|
+
* Stamps the declared channel's key. The legacy key goes on too, so a resource
|
|
324
|
+
* applied by this chant is still recognised by an older one mid-rollout; it is
|
|
325
|
+
* transitional and can be dropped once no deployed estate predates #1446.
|
|
326
|
+
*
|
|
327
|
+
* Only the managed-by marker, not stack/env: this applier has no
|
|
328
|
+
* {@link OwnershipMarker} to hand — the serializer stamps those from project
|
|
329
|
+
* config. Same shape the fly serializer uses when no ownership context is set.
|
|
330
|
+
*/
|
|
331
|
+
export function chantOwnershipTags() {
|
|
332
|
+
return {
|
|
333
|
+
[AZURE_TAG_OWNERSHIP_KEYS.managedBy]: OWNERSHIP_MANAGED_BY_VALUE,
|
|
334
|
+
[LEGACY_OWNERSHIP_TAG_KEY]: OWNERSHIP_MANAGED_BY_VALUE,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Whether a resource's tags mark it chant-owned.
|
|
339
|
+
*
|
|
340
|
+
* Resolves through core's `hasOwnershipMarker` against the lexicon's declared
|
|
341
|
+
* channel (#1446), so this predicate and the serializer's stamp can no longer
|
|
342
|
+
* drift — the arrangement the fly lexicon already had.
|
|
343
|
+
*
|
|
344
|
+
* The legacy key is accepted as well, and the union is deliberate: it is
|
|
345
|
+
* strictly more than this predicate recognised before, so nothing that was
|
|
346
|
+
* prunable stops being prunable, and resources the serializer marked become
|
|
347
|
+
* prunable for the first time.
|
|
348
|
+
*/
|
|
349
|
+
export function isChantOwned(tags) {
|
|
350
|
+
if (hasOwnershipMarker(tags ?? undefined, AZURE_TAG_OWNERSHIP_KEYS))
|
|
351
|
+
return true;
|
|
352
|
+
return tags?.[LEGACY_OWNERSHIP_TAG_KEY] === OWNERSHIP_MANAGED_BY_VALUE;
|
|
353
|
+
}
|
|
354
|
+
/** List the resources in the group via the ARM resource-list endpoint. */
|
|
355
|
+
export async function listGroupResources(ctx, http = defaultHttp, signal) {
|
|
356
|
+
const url = `${ctx.base}/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}/resources?api-version=2021-04-01`;
|
|
357
|
+
const res = await http("GET", url, undefined, signal);
|
|
358
|
+
if (res.status >= 300)
|
|
359
|
+
return [];
|
|
360
|
+
try {
|
|
361
|
+
return (JSON.parse(res.text).value ?? []).filter((r) => r?.type && r?.name);
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return [];
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/** Idempotently delete one ARM resource by type/name/apiVersion. A 404 means it is already gone. */
|
|
368
|
+
export async function deleteArmResource(type, name, apiVersion, ctx, http = defaultHttp, signal) {
|
|
369
|
+
const url = `${ctx.base}/subscriptions/${ctx.subscriptionId}/resourceGroups/${ctx.resourceGroup}/providers/${type}/${name}?api-version=${apiVersion}`;
|
|
370
|
+
const res = await http("DELETE", url, undefined, signal);
|
|
371
|
+
if (res.status === 404)
|
|
372
|
+
return { type, name, deleted: false };
|
|
373
|
+
if (res.status >= 300)
|
|
374
|
+
throw new Error(`${type} ${name} delete failed (${res.status}): ${res.text}`);
|
|
375
|
+
return { type, name, deleted: true };
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Normalize an azApply/azDelete result into core's apply envelope (#1446).
|
|
379
|
+
*
|
|
380
|
+
* The lexicon keeps its own shape — ARM `type`, per-resource applied/pruned —
|
|
381
|
+
* and this projects it onto the shared tri-state so a caller can read any
|
|
382
|
+
* applier's result the same way.
|
|
383
|
+
*/
|
|
384
|
+
export function toApplyResult(result) {
|
|
385
|
+
const applied = (result.applied ?? []).map((a) => ({
|
|
386
|
+
kind: a.type,
|
|
387
|
+
name: a.name,
|
|
388
|
+
// ARM PUT is a create-or-update with no distinguishing response, so this
|
|
389
|
+
// applier cannot tell the two apart. `updated` is the honest verdict for a
|
|
390
|
+
// call that converged without claiming which it did.
|
|
391
|
+
action: "updated",
|
|
392
|
+
}));
|
|
393
|
+
const notAttempted = (result.notPrunable ?? []).map((n) => ({
|
|
394
|
+
kind: n.type,
|
|
395
|
+
name: n.name,
|
|
396
|
+
reason: "not-prunable",
|
|
397
|
+
detail: n.reason,
|
|
398
|
+
}));
|
|
399
|
+
const pruned = (result.pruned ?? result.deleted ?? []).map((p) => ({
|
|
400
|
+
kind: p.type,
|
|
401
|
+
name: p.name,
|
|
402
|
+
deleted: p.deleted,
|
|
403
|
+
}));
|
|
404
|
+
return applyResult(applied, pruned, notAttempted);
|
|
405
|
+
}
|
|
406
|
+
/** The ARM provider-metadata apiVersion (`GET /subscriptions/{sub}/providers/{ns}`). */
|
|
407
|
+
const PROVIDER_METADATA_API_VERSION = "2021-04-01";
|
|
408
|
+
/**
|
|
409
|
+
* Resolve an apiVersion for a live resource type from ARM's provider metadata:
|
|
410
|
+
* `GET /subscriptions/{sub}/providers/{namespace}?api-version=2021-04-01`
|
|
411
|
+
* returns `resourceTypes[].apiVersions[]`. Picks the newest stable version,
|
|
412
|
+
* falling back to the newest preview when no stable one exists. One call per
|
|
413
|
+
* namespace per prune run; the result is memoised in `cache` so a group with
|
|
414
|
+
* many orphans of one provider costs one round trip. Returns `undefined` when
|
|
415
|
+
* the endpoint is unavailable (floci-az does not implement it) or the type is
|
|
416
|
+
* not listed.
|
|
417
|
+
*/
|
|
418
|
+
export async function providerApiVersion(resourceType, ctx, http = defaultHttp, signal, cache = new Map()) {
|
|
419
|
+
const slash = resourceType.indexOf("/");
|
|
420
|
+
if (slash < 0)
|
|
421
|
+
return undefined;
|
|
422
|
+
const namespace = resourceType.slice(0, slash);
|
|
423
|
+
const typeName = resourceType.slice(slash + 1).toLowerCase();
|
|
424
|
+
let byType = cache.get(namespace.toLowerCase());
|
|
425
|
+
if (!byType) {
|
|
426
|
+
byType = new Map();
|
|
427
|
+
cache.set(namespace.toLowerCase(), byType);
|
|
428
|
+
const url = `${ctx.base}/subscriptions/${ctx.subscriptionId}/providers/${namespace}?api-version=${PROVIDER_METADATA_API_VERSION}`;
|
|
429
|
+
let res;
|
|
430
|
+
try {
|
|
431
|
+
res = await http("GET", url, undefined, signal);
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
if (res.status >= 300)
|
|
437
|
+
return undefined;
|
|
438
|
+
let parsed;
|
|
439
|
+
try {
|
|
440
|
+
parsed = JSON.parse(res.text);
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
return undefined;
|
|
444
|
+
}
|
|
445
|
+
for (const rt of parsed.resourceTypes ?? []) {
|
|
446
|
+
if (!rt?.resourceType || !Array.isArray(rt.apiVersions) || rt.apiVersions.length === 0)
|
|
447
|
+
continue;
|
|
448
|
+
const versions = rt.apiVersions.filter((v) => typeof v === "string");
|
|
449
|
+
const stable = versions.filter((v) => !v.endsWith("-preview"));
|
|
450
|
+
const pool = stable.length > 0 ? stable : versions;
|
|
451
|
+
if (pool.length === 0)
|
|
452
|
+
continue;
|
|
453
|
+
const newest = pool.reduce((a, b) => (compareApiDates(b, a) > 0 ? b : a));
|
|
454
|
+
byType.set(rt.resourceType.toLowerCase(), newest);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return byType.get(typeName);
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Owned-only prune: delete every chant-owned live resource in the group whose
|
|
461
|
+
* (evaluated) name is not declared in the template. Foreign (non-chant)
|
|
462
|
+
* resources are never touched.
|
|
463
|
+
*
|
|
464
|
+
* ## Where the apiVersion comes from (#1457, #1472)
|
|
465
|
+
*
|
|
466
|
+
* `deleteArmResource` needs an `apiVersion`, and ARM's resource-group listing
|
|
467
|
+
* does not return one per resource. The template is the obvious source, but it
|
|
468
|
+
* only covers types it still declares: declare one storage account, apply,
|
|
469
|
+
* delete it from source, and the template has zero resources of that type.
|
|
470
|
+
* Keying the delete scope off the template alone made the last resource of a
|
|
471
|
+
* type a permanent orphan (#1457 made that a reported skip rather than a
|
|
472
|
+
* silent one).
|
|
473
|
+
*
|
|
474
|
+
* So the apiVersion is resolved in order:
|
|
475
|
+
*
|
|
476
|
+
* 1. the template, when it still declares the type;
|
|
477
|
+
* 2. the lexicon's pinned per-provider registry (`lookupApiVersion`, the same
|
|
478
|
+
* registry the serializer stamps into the template) — deterministic, no
|
|
479
|
+
* extra call, covers every type the lexicon can emit;
|
|
480
|
+
* 3. ARM's provider metadata (`providerApiVersion`) — covers a type the
|
|
481
|
+
* lexicon has never heard of, at one extra call per namespace per run.
|
|
482
|
+
*
|
|
483
|
+
* Only when all three come up empty is the resource reported as
|
|
484
|
+
* `notPrunable: no-api-version`.
|
|
485
|
+
*
|
|
486
|
+
* This matters more since #1448, which routed `ApplyOp`'s `arm` target through
|
|
487
|
+
* `azApply` — before that, the composite reached `--mode Complete` and never
|
|
488
|
+
* came here at all.
|
|
489
|
+
*/
|
|
490
|
+
export async function pruneArmOrphans(desired, ctx, http = defaultHttp, signal) {
|
|
491
|
+
const byType = new Map();
|
|
492
|
+
for (const r of desired) {
|
|
493
|
+
const name = String(await evalArmString(r.name, ctx));
|
|
494
|
+
const entry = byType.get(r.type) ?? { keep: new Set(), apiVersion: r.apiVersion };
|
|
495
|
+
entry.keep.add(name);
|
|
496
|
+
byType.set(r.type, entry);
|
|
497
|
+
}
|
|
498
|
+
const providerCache = new Map();
|
|
499
|
+
const resolveApiVersion = async (type) => {
|
|
500
|
+
const fromTemplate = byType.get(type)?.apiVersion;
|
|
501
|
+
if (fromTemplate)
|
|
502
|
+
return fromTemplate;
|
|
503
|
+
let fromRegistry;
|
|
504
|
+
try {
|
|
505
|
+
fromRegistry = lookupApiVersion(type);
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
// A missing registry is the serializer's problem to report; prune still
|
|
509
|
+
// has the provider metadata to fall back on.
|
|
510
|
+
}
|
|
511
|
+
if (fromRegistry)
|
|
512
|
+
return fromRegistry;
|
|
513
|
+
return providerApiVersion(type, ctx, http, signal, providerCache);
|
|
514
|
+
};
|
|
515
|
+
const pruned = [];
|
|
516
|
+
const notPrunable = [];
|
|
517
|
+
for (const item of await listGroupResources(ctx, http, signal)) {
|
|
518
|
+
// Ownership first, so a foreign resource never reaches the report either —
|
|
519
|
+
// it is not an orphan chant failed to prune, it is simply not chant's.
|
|
520
|
+
if (!isChantOwned(item.tags))
|
|
521
|
+
continue;
|
|
522
|
+
if (byType.get(item.type)?.keep.has(item.name))
|
|
523
|
+
continue;
|
|
524
|
+
const apiVersion = await resolveApiVersion(item.type);
|
|
525
|
+
if (!apiVersion) {
|
|
526
|
+
console.log(`prune: ${item.type}/${item.name} is chant-owned and undeclared, but no apiVersion is ` +
|
|
527
|
+
`available for its type (not in the template, the lexicon registry, or ARM provider metadata) — not deleted`);
|
|
528
|
+
notPrunable.push({ type: item.type, name: item.name, reason: "no-api-version" });
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
const result = await deleteArmResource(item.type, item.name, apiVersion, ctx, http, signal);
|
|
532
|
+
console.log(`pruned: ${item.type}/${item.name} (${ctx.base})`);
|
|
533
|
+
pruned.push(result);
|
|
534
|
+
}
|
|
535
|
+
return { pruned, notPrunable };
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* The inverse of {@link azApply} — read a built ARM template and delete the
|
|
539
|
+
* resources it declares, in reverse dependency order (a referrer goes before the
|
|
540
|
+
* resource it references). Idempotent: already-absent resources are a no-op. The
|
|
541
|
+
* Azure twin of `gcpDelete`; `http` is injectable for tests.
|
|
542
|
+
*/
|
|
543
|
+
export async function azDelete(args, signal, http = defaultHttp) {
|
|
544
|
+
const base = (args.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, "");
|
|
545
|
+
const ctx = {
|
|
546
|
+
subscriptionId: args.subscriptionId ?? DEFAULT_SUBSCRIPTION,
|
|
547
|
+
resourceGroup: args.resourceGroup,
|
|
548
|
+
location: args.location ?? "eastus",
|
|
549
|
+
deployed: new Map(),
|
|
550
|
+
http,
|
|
551
|
+
base,
|
|
552
|
+
signal,
|
|
553
|
+
};
|
|
554
|
+
const template = JSON.parse(readFileSync(args.templatePath, "utf8"));
|
|
555
|
+
const deleted = [];
|
|
556
|
+
for (const resource of orderArmResources(template.resources ?? []).reverse()) {
|
|
557
|
+
const name = String(await evalArmString(resource.name, ctx));
|
|
558
|
+
const result = await deleteArmResource(resource.type, name, resource.apiVersion, ctx, http, signal);
|
|
559
|
+
console.log(`${result.deleted ? "deleted" : "absent"}: ${resource.type}/${name} (${base})`);
|
|
560
|
+
deleted.push(result);
|
|
561
|
+
}
|
|
562
|
+
return { deleted };
|
|
563
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const execAsync = promisify(exec);
|
|
4
|
+
const DEFAULT_LOCATION = "eastus";
|
|
5
|
+
/**
|
|
6
|
+
* Build the `az group create` command. `az group create` is idempotent — a group
|
|
7
|
+
* that already exists is returned unchanged — so no existence check is needed.
|
|
8
|
+
* Pure — exported for testing.
|
|
9
|
+
*/
|
|
10
|
+
export function azGroupEnsureCommand(resourceGroup, location) {
|
|
11
|
+
return `az group create --name ${resourceGroup} --location ${location} --output none`;
|
|
12
|
+
}
|
|
13
|
+
/** Build the `az group delete` command (non-blocking). Pure — exported for testing. */
|
|
14
|
+
export function azGroupDeleteCommand(resourceGroup) {
|
|
15
|
+
return `az group delete --name ${resourceGroup} --yes --no-wait`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Ensure an Azure resource group exists before an ARM deployment.
|
|
19
|
+
*
|
|
20
|
+
* `az deployment group create` (the `arm` target of `nativeApply`) fails if its
|
|
21
|
+
* resource group is absent — unlike CloudFormation, which creates its own stack.
|
|
22
|
+
* Run this before the apply. Uses fastIdempotent profile — 5m timeout.
|
|
23
|
+
*
|
|
24
|
+
* This targets real Azure (via the logged-in `az` context). Local emulation
|
|
25
|
+
* against floci-az awaits an upstream `Microsoft.Resources/deployments` provider
|
|
26
|
+
* (see #705); when that lands, an endpoint/custom-cloud override slots in here.
|
|
27
|
+
*/
|
|
28
|
+
export async function azGroupEnsure(args, signal) {
|
|
29
|
+
const location = args.location ?? DEFAULT_LOCATION;
|
|
30
|
+
const { stdout, stderr } = await execAsync(azGroupEnsureCommand(args.resourceGroup, location), { signal });
|
|
31
|
+
if (stdout)
|
|
32
|
+
console.log(stdout);
|
|
33
|
+
if (stderr)
|
|
34
|
+
console.error(stderr);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Delete an Azure resource group and everything in it (non-blocking). A no-op
|
|
38
|
+
* success when the group is already gone. Uses fastIdempotent profile — 5m
|
|
39
|
+
* timeout (`--no-wait` returns immediately).
|
|
40
|
+
*/
|
|
41
|
+
export async function azGroupDelete(args, signal) {
|
|
42
|
+
try {
|
|
43
|
+
await execAsync(azGroupDeleteCommand(args.resourceGroup), { signal });
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Group already absent — treat as success.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { emulatorLifecycle } from "@intentius/chant/op";
|
|
2
|
+
// floci-az is a bespoke ARM fake (not LocalStack) — a plain 200 on its health
|
|
3
|
+
// endpoint means ready. Shared lifecycle: emulatorLifecycle (#746).
|
|
4
|
+
// Pinned rather than `:latest` (#1345): an image that moves underneath a
|
|
5
|
+
// passing local suite is exactly the drift a pin exists to stop.
|
|
6
|
+
export const FLOCI_AZ_SPEC = {
|
|
7
|
+
name: "chant-floci-az",
|
|
8
|
+
image: "floci/floci-az:0.10.0",
|
|
9
|
+
containerPort: 4577,
|
|
10
|
+
healthPath: "/_floci/health",
|
|
11
|
+
upstream: { repo: "floci-io/floci-az" },
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The azure plugin's emulator capability (#1345).
|
|
15
|
+
*
|
|
16
|
+
* The spec below has existed since #746; what was missing was declaring it, so
|
|
17
|
+
* `chant emulator up --all` booted Floci and nothing else even though floci-az
|
|
18
|
+
* was one line away. `AZURE_ENDPOINT_URL` is the var azure's own
|
|
19
|
+
* `describe-resources.ts` and `deep-observe.ts` already read on every live
|
|
20
|
+
* call, which is also what makes `--live --env <local>` reach the emulator
|
|
21
|
+
* instead of real Azure.
|
|
22
|
+
*/
|
|
23
|
+
export const FLOCI_AZ_EMULATOR = {
|
|
24
|
+
spec: FLOCI_AZ_SPEC,
|
|
25
|
+
env: (endpoint) => ({ AZURE_ENDPOINT_URL: endpoint }),
|
|
26
|
+
};
|
|
27
|
+
const az = emulatorLifecycle(FLOCI_AZ_SPEC);
|
|
28
|
+
export const flociAzExistsCommand = az.existsCommand;
|
|
29
|
+
export const flociAzRmCommand = az.rmCommand;
|
|
30
|
+
export const flociAzHealthUrl = az.healthUrl;
|
|
31
|
+
/** The ARM endpoint URL (what `azApply`'s `endpoint` should point at). */
|
|
32
|
+
export const flociAzEndpoint = az.endpoint;
|
|
33
|
+
export const flociAzRunCommand = (args = {}) => az.runCommand(args);
|
|
34
|
+
/** Boot a local floci-az (Azure emulator) in Docker and return its ARM endpoint. */
|
|
35
|
+
export const flociAzUp = (args = {}, signal) => az.up(args, signal);
|
|
36
|
+
/** Stop and remove the local floci-az container (no-op if already gone). */
|
|
37
|
+
export const flociAzDown = (args = {}, signal) => az.down(args, signal);
|
|
@@ -0,0 +1,10 @@
|
|
|
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.js";
|
|
7
|
+
// floci-az (Azure emulator) lifecycle — the typed twin of aws's flociUp/Down, so
|
|
8
|
+
// the trio's Azure op boots/tears down the emulator as a modeled step, not a shell.
|
|
9
|
+
export { flociAzUp, flociAzDown, flociAzRunCommand, flociAzRmCommand, flociAzExistsCommand, flociAzHealthUrl, flociAzEndpoint, } from "./floci-az.js";
|
|
10
|
+
export { azApply, azDelete, pruneArmOrphans, providerApiVersion, toApplyResult, deleteArmResource, listGroupResources, chantOwnershipTags, isChantOwned, evalArm, evalArmString, armResourceUrl, armResourceBody, armDependencies, orderArmResources, } from "./az-apply.js";
|