@meshxdata/fops 0.1.45 → 0.1.47

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +202 -17
  2. package/package.json +1 -1
  3. package/src/commands/lifecycle.js +81 -5
  4. package/src/commands/setup.js +45 -4
  5. package/src/plugins/bundled/fops-plugin-azure/index.js +29 -0
  6. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-core.js +1185 -0
  7. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-flux.js +1180 -0
  8. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-ingress.js +393 -0
  9. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-naming.js +104 -0
  10. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-network.js +296 -0
  11. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-postgres.js +768 -0
  12. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-reconcilers.js +538 -0
  13. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-secrets.js +849 -0
  14. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-stacks.js +643 -0
  15. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-state.js +145 -0
  16. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-storage.js +496 -0
  17. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-terraform.js +1032 -0
  18. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks.js +155 -4245
  19. package/src/plugins/bundled/fops-plugin-azure/lib/azure-keyvault.js +186 -0
  20. package/src/plugins/bundled/fops-plugin-azure/lib/azure-results.js +5 -0
  21. package/src/plugins/bundled/fops-plugin-azure/lib/commands/infra-cmds.js +758 -0
  22. package/src/plugins/bundled/fops-plugin-azure/lib/commands/registry-cmds.js +250 -0
  23. package/src/plugins/bundled/fops-plugin-azure/lib/commands/test-cmds.js +2 -1
  24. package/src/plugins/bundled/fops-plugin-foundation/lib/apply.js +3 -2
  25. package/src/plugins/bundled/fops-plugin-foundation/lib/helpers.js +21 -0
  26. package/src/plugins/bundled/fops-plugin-foundation/lib/tools-read.js +3 -5
  27. package/src/ui/tui/App.js +13 -13
  28. package/src/web/dist/assets/index-NXC8Hvnp.css +1 -0
  29. package/src/web/dist/assets/index-QH1N4ejK.js +112 -0
  30. package/src/web/dist/index.html +2 -2
  31. package/src/web/server.js +4 -4
  32. package/src/web/dist/assets/index-BphVaAUd.css +0 -1
  33. package/src/web/dist/assets/index-CSckLzuG.js +0 -129
@@ -0,0 +1,1185 @@
1
+ /**
2
+ * azure-aks-core.js – Core AKS CLI commands and helpers
3
+ *
4
+ * Dependencies: azure.js, azure-aks-naming.js, azure-aks-state.js,
5
+ * azure-aks-reconcilers.js, azure-aks-flux.js, azure-aks-network.js,
6
+ * azure-aks-postgres.js, azure-aks-ingress.js
7
+ */
8
+
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
12
+ import chalk from "chalk";
13
+ import {
14
+ DEFAULTS, DIM, OK, WARN, ERR, LABEL, ACCENT,
15
+ banner, hint, kvLine,
16
+ lazyExeca, ensureAzCli, ensureAzAuth, subArgs, buildTags, fetchMyIp,
17
+ readState,
18
+ resolveGithubToken,
19
+ } from "./azure.js";
20
+ import { AKS_DEFAULTS, PG_REPLICA_REGIONS, timeSince } from "./azure-aks-naming.js";
21
+ import {
22
+ readAksClusters,
23
+ readClusterState,
24
+ writeClusterState,
25
+ clearClusterState,
26
+ requireCluster,
27
+ } from "./azure-aks-state.js";
28
+ import { reconcileCluster } from "./azure-aks-reconcilers.js";
29
+ import {
30
+ TEMPLATE_DEFAULTS,
31
+ provisionFluxFromTemplate,
32
+ bootstrapFlux,
33
+ reconcileFluxPrereqs,
34
+ } from "./azure-aks-flux.js";
35
+ import { reconcileNetworkAccess } from "./azure-aks-network.js";
36
+ import { reconcilePostgres, aksPostgresReplicaCreate, reconcileEventHubs } from "./azure-aks-postgres.js";
37
+ import { clusterDomain } from "./azure-aks-ingress.js";
38
+ import { printClusterInfo } from "./azure-aks-stacks.js";
39
+
40
+ /** Resolve a module path under the CLI's src/ directory (works from source and ~/.fops/plugins). */
41
+ function resolveCliSrc(relPath) {
42
+ const thisDir = path.dirname(fileURLToPath(import.meta.url));
43
+ const fromSource = path.resolve(thisDir, "../../../..", relPath);
44
+ if (fs.existsSync(fromSource)) return pathToFileURL(fromSource).href;
45
+ const fopsBin = process.argv[1];
46
+ if (fopsBin) {
47
+ try {
48
+ const cliRoot = path.dirname(fs.realpathSync(fopsBin));
49
+ const fromCli = path.resolve(cliRoot, "src", relPath);
50
+ if (fs.existsSync(fromCli)) return pathToFileURL(fromCli).href;
51
+ } catch { /* fall through */ }
52
+ }
53
+ return "../../../../" + relPath;
54
+ }
55
+
56
+ // ── K8s version resolver ─────────────────────────────────────────────────────
57
+
58
+ /**
59
+ * Query the latest GA (non-preview) Kubernetes version available in a region.
60
+ * Falls back to AKS_DEFAULTS.kubernetesVersion if the query fails.
61
+ */
62
+ export async function resolveK8sVersion(execa, { location, subscription } = {}) {
63
+ try {
64
+ const args = [
65
+ "aks", "get-versions",
66
+ "--location", location || DEFAULTS.location,
67
+ "--output", "json",
68
+ ];
69
+ if (subscription) args.push("--subscription", subscription);
70
+
71
+ const { stdout } = await execa("az", args, { timeout: 30000 });
72
+ const data = JSON.parse(stdout);
73
+
74
+ // data.values is an array of { version, isPreview, patchVersions }
75
+ const gaVersions = (data.values || [])
76
+ .filter((v) => !v.isPreview)
77
+ .map((v) => v.version)
78
+ .sort((a, b) => {
79
+ const pa = a.split(".").map(Number);
80
+ const pb = b.split(".").map(Number);
81
+ return pb[0] - pa[0] || pb[1] - pa[1] || (pb[2] || 0) - (pa[2] || 0);
82
+ });
83
+
84
+ if (gaVersions.length > 0) return gaVersions[0];
85
+ } catch { /* fall through to default */ }
86
+ return AKS_DEFAULTS.kubernetesVersion;
87
+ }
88
+
89
+ // ── CLI prerequisite checkers ────────────────────────────────────────────────
90
+
91
+ export async function ensureFluxCli(execa) {
92
+ try {
93
+ await execa("flux", ["--version"], { timeout: 10000 });
94
+ } catch {
95
+ console.error(ERR("\n Flux CLI is not installed."));
96
+ hint("Install: brew install fluxcd/tap/flux");
97
+ hint("Or: curl -s https://fluxcd.io/install.sh | sudo bash\n");
98
+ process.exit(1);
99
+ }
100
+ }
101
+
102
+ export async function ensureKubectl(execa) {
103
+ try {
104
+ await execa("kubectl", ["version", "--client", "--output=json"], { timeout: 10000 });
105
+ } catch {
106
+ console.error(ERR("\n kubectl is not installed."));
107
+ hint("Install: brew install kubectl\n");
108
+ process.exit(1);
109
+ }
110
+ }
111
+
112
+ // ── Shared internals ──────────────────────────────────────────────────────────
113
+
114
+ export async function ensureGhcrPullSecret(execa, { clusterName, githubToken, namespace = "default" }) {
115
+ if (!githubToken) return;
116
+
117
+ banner("GHCR Pull Secret");
118
+ hint("Creating image pull secret for ghcr.io…");
119
+
120
+ const secretName = "ghcr-pull-secret";
121
+
122
+ // Create the secret in the target namespace
123
+ const { exitCode } = await execa("kubectl", [
124
+ "create", "secret", "docker-registry", secretName,
125
+ "--docker-server=ghcr.io",
126
+ "--docker-username=x-access-token",
127
+ `--docker-password=${githubToken}`,
128
+ "--namespace", namespace,
129
+ "--context", clusterName,
130
+ "--dry-run=client", "-o", "yaml",
131
+ ], { timeout: 15000, reject: false }).then(async (dryRun) => {
132
+ if (dryRun.exitCode !== 0) return dryRun;
133
+ // Pipe through kubectl apply so it's idempotent
134
+ return execa("kubectl", [
135
+ "apply", "-f", "-", "--context", clusterName,
136
+ ], { input: dryRun.stdout, timeout: 15000, reject: false });
137
+ });
138
+
139
+ if (exitCode === 0) {
140
+ console.log(OK(` ✓ Pull secret "${secretName}" in namespace "${namespace}"`));
141
+ } else {
142
+ console.log(WARN(` ⚠ Could not create pull secret — create manually:`));
143
+ hint(` kubectl create secret docker-registry ${secretName} --docker-server=ghcr.io --docker-username=x-access-token --docker-password=<token>`);
144
+ }
145
+
146
+ // Also create in foundation namespace (app pods need it)
147
+ const foundationNsCode = await execa("kubectl", [
148
+ "create", "namespace", "foundation",
149
+ "--context", clusterName, "--dry-run=client", "-o", "yaml",
150
+ ], { timeout: 10000, reject: false }).then(async (ns) => {
151
+ if (ns.exitCode !== 0) return ns;
152
+ return execa("kubectl", ["apply", "-f", "-", "--context", clusterName],
153
+ { input: ns.stdout, timeout: 10000, reject: false });
154
+ });
155
+
156
+ if (foundationNsCode.exitCode === 0) {
157
+ const { exitCode: fnCode } = await execa("kubectl", [
158
+ "create", "secret", "docker-registry", secretName,
159
+ "--docker-server=ghcr.io",
160
+ "--docker-username=x-access-token",
161
+ `--docker-password=${githubToken}`,
162
+ "--namespace", "foundation",
163
+ "--context", clusterName,
164
+ "--dry-run=client", "-o", "yaml",
165
+ ], { timeout: 15000, reject: false }).then(async (dryRun) => {
166
+ if (dryRun.exitCode !== 0) return dryRun;
167
+ return execa("kubectl", ["apply", "-f", "-", "--context", clusterName],
168
+ { input: dryRun.stdout, timeout: 15000, reject: false });
169
+ });
170
+ if (fnCode === 0) {
171
+ console.log(OK(` ✓ Pull secret "${secretName}" in namespace "foundation"`));
172
+ }
173
+ }
174
+
175
+ // Also create in flux-system namespace (Flux needs it for image automation)
176
+ const { exitCode: fluxNsCode } = await execa("kubectl", [
177
+ "create", "namespace", "flux-system",
178
+ "--context", clusterName, "--dry-run=client", "-o", "yaml",
179
+ ], { timeout: 10000, reject: false }).then(async (ns) => {
180
+ if (ns.exitCode !== 0) return ns;
181
+ return execa("kubectl", ["apply", "-f", "-", "--context", clusterName],
182
+ { input: ns.stdout, timeout: 10000, reject: false });
183
+ });
184
+
185
+ if (fluxNsCode === 0) {
186
+ const { exitCode: fsCode } = await execa("kubectl", [
187
+ "create", "secret", "docker-registry", secretName,
188
+ "--docker-server=ghcr.io",
189
+ "--docker-username=x-access-token",
190
+ `--docker-password=${githubToken}`,
191
+ "--namespace", "flux-system",
192
+ "--context", clusterName,
193
+ "--dry-run=client", "-o", "yaml",
194
+ ], { timeout: 15000, reject: false }).then(async (dryRun) => {
195
+ if (dryRun.exitCode !== 0) return dryRun;
196
+ return execa("kubectl", ["apply", "-f", "-", "--context", clusterName],
197
+ { input: dryRun.stdout, timeout: 15000, reject: false });
198
+ });
199
+ if (fsCode === 0) {
200
+ console.log(OK(` ✓ Pull secret "${secretName}" in namespace "flux-system"`));
201
+ }
202
+ }
203
+ }
204
+
205
+ export async function getCredentials(execa, { clusterName, rg, sub, admin } = {}) {
206
+ hint("Fetching kubeconfig…");
207
+ const args = [
208
+ "aks", "get-credentials",
209
+ "--resource-group", rg,
210
+ "--name", clusterName,
211
+ "--overwrite-existing",
212
+ "--output", "none",
213
+ ...subArgs(sub),
214
+ ];
215
+ if (admin) args.push("--admin");
216
+ await execa("az", args, { timeout: 30000 });
217
+ console.log(OK(` ✓ Kubeconfig merged`));
218
+ }
219
+
220
+ // ── aks up ────────────────────────────────────────────────────────────────────
221
+
222
+ export async function aksUp(opts = {}) {
223
+ const execa = await lazyExeca();
224
+ const clusterName = opts.clusterName || AKS_DEFAULTS.clusterName;
225
+ const rg = opts.resourceGroup || AKS_DEFAULTS.resourceGroup;
226
+ const location = opts.location || AKS_DEFAULTS.location;
227
+ const nodeCount = opts.nodeCount || AKS_DEFAULTS.nodeCount;
228
+ const minCount = opts.minCount || AKS_DEFAULTS.minCount;
229
+ const maxCount = opts.maxCount || AKS_DEFAULTS.maxCount;
230
+ const nodeVmSize = opts.nodeVmSize || AKS_DEFAULTS.nodeVmSize;
231
+ const tier = opts.tier || AKS_DEFAULTS.tier;
232
+ const networkPlugin = opts.networkPlugin || AKS_DEFAULTS.networkPlugin;
233
+ const sub = opts.profile;
234
+
235
+ await ensureAzCli(execa);
236
+ await ensureKubectl(execa);
237
+ const account = await ensureAzAuth(execa, { subscription: sub });
238
+
239
+ // Resolve K8s version: use explicit opt, otherwise auto-detect latest GA for region
240
+ const k8sVersion = opts.kubernetesVersion ||
241
+ await resolveK8sVersion(execa, { location, subscription: sub });
242
+
243
+ banner(`AKS Up: ${clusterName}`);
244
+ kvLine("Account", DIM(`${account.name} (${account.id})`));
245
+ kvLine("Location", DIM(location));
246
+ kvLine("Nodes", DIM(`${nodeCount} x ${nodeVmSize} (autoscale ${minCount}–${maxCount})`));
247
+ kvLine("K8s", DIM(k8sVersion));
248
+ kvLine("Tier", DIM(tier));
249
+
250
+ // Check if cluster already exists
251
+ const { exitCode: exists } = await execa("az", [
252
+ "aks", "show", "-g", rg, "-n", clusterName, "--output", "none",
253
+ ...subArgs(sub),
254
+ ], { reject: false, timeout: 30000 });
255
+
256
+ if (exists === 0) {
257
+ console.log(WARN(`\n Cluster "${clusterName}" already exists — reconciling…`));
258
+
259
+ const maxPods = opts.maxPods || 110;
260
+ const ctx = { execa, clusterName, rg, sub, opts, minCount, maxCount, maxPods };
261
+ await reconcileCluster(ctx);
262
+
263
+ // Re-provision templates if --reprovision flag is set
264
+ if (opts.reprovision) {
265
+ const githubToken = resolveGithubToken(opts);
266
+ if (!githubToken) {
267
+ console.log(WARN(" ⚠ Cannot reprovision — no GitHub token found."));
268
+ hint("Authenticate with: gh auth login (writes to ~/.netrc)");
269
+ } else {
270
+ const fluxRepo = opts.fluxRepo ?? AKS_DEFAULTS.fluxRepo;
271
+ const fluxOwner = opts.fluxOwner ?? AKS_DEFAULTS.fluxOwner;
272
+ const fluxBranch = opts.fluxBranch ?? AKS_DEFAULTS.fluxBranch;
273
+ const templateRepo = opts.templateRepo ?? TEMPLATE_DEFAULTS.templateRepo;
274
+ const templateOwner = opts.templateOwner ?? TEMPLATE_DEFAULTS.templateOwner;
275
+ const templateBranch = opts.templateBranch ?? TEMPLATE_DEFAULTS.templateBranch;
276
+ const environment = opts.environment || "demo";
277
+
278
+ banner("Re-provisioning cluster templates");
279
+ try {
280
+ const result = await provisionFluxFromTemplate(execa, {
281
+ clusterName,
282
+ region: location,
283
+ domain: clusterDomain(clusterName),
284
+ keyvaultUrl: opts.keyvaultUrl || `https://fops-${clusterName}-kv.vault.azure.net`,
285
+ environment,
286
+ githubToken,
287
+ fluxRepo,
288
+ fluxOwner,
289
+ fluxBranch,
290
+ templateRepo,
291
+ templateOwner,
292
+ templateBranch,
293
+ dryRun: opts.dryRun,
294
+ noCommit: opts.noCommit,
295
+ });
296
+ if (result && result.committed !== false) {
297
+ console.log(OK(" ✓ Templates re-provisioned and committed"));
298
+ }
299
+ } catch (err) {
300
+ console.log(WARN(` ⚠ Template re-provisioning failed: ${(err.message || "").split("\n")[0]}`));
301
+ }
302
+ }
303
+ }
304
+
305
+ const tracked = readClusterState(clusterName);
306
+ if (tracked) printClusterInfo(tracked);
307
+ return tracked;
308
+ }
309
+
310
+ // Create resource group
311
+ hint(`Ensuring resource group ${rg}…`);
312
+ await execa("az", [
313
+ "group", "create", "--name", rg, "--location", location,
314
+ "--output", "none", ...subArgs(sub),
315
+ ], { timeout: 30000 });
316
+
317
+ // Create AKS cluster
318
+ banner(`Creating AKS cluster "${clusterName}"`);
319
+
320
+ // Detect operator IP to lock down the API server
321
+ hint("Detecting your public IP…");
322
+ const myIp = await fetchMyIp();
323
+ if (myIp) {
324
+ console.log(OK(` ✓ API server will be scoped to ${myIp}`));
325
+ } else {
326
+ console.log(WARN(" ⚠ Could not detect public IP — API server will be open to all"));
327
+ hint("Lock it down later: az aks update -g <rg> -n <name> --api-server-authorized-ip-ranges <ip>/32");
328
+ }
329
+
330
+ hint("This takes 5–10 minutes…\n");
331
+
332
+ const tags = buildTags(clusterName, {
333
+ createdBy: account.user?.name || "fops",
334
+ type: "aks",
335
+ });
336
+ const tagStr = Object.entries(tags).map(([k, v]) => `${k}=${v}`).join(" ");
337
+
338
+ const maxPods = opts.maxPods || 110;
339
+
340
+ // Zone redundancy: query available zones for VM size in region
341
+ const useZones = opts.zones !== false && (opts.zones === true || nodeCount >= 3);
342
+ let zones = [];
343
+ if (useZones) {
344
+ try {
345
+ const { stdout: skuJson } = await execa("az", [
346
+ "vm", "list-skus",
347
+ "--location", location,
348
+ "--size", nodeVmSize,
349
+ "--resource-type", "virtualMachines",
350
+ "--query", "[0].locationInfo[0].zones",
351
+ "--output", "json",
352
+ ...subArgs(sub),
353
+ ], { timeout: 30000 });
354
+ const availableZones = JSON.parse(skuJson || "[]");
355
+ zones = Array.isArray(availableZones) ? availableZones.sort() : [];
356
+ } catch {
357
+ // Fallback to no zones if query fails
358
+ zones = [];
359
+ }
360
+ }
361
+
362
+ const createArgs = [
363
+ "aks", "create",
364
+ "--resource-group", rg,
365
+ "--name", clusterName,
366
+ "--location", location,
367
+ "--node-count", String(nodeCount),
368
+ "--node-vm-size", nodeVmSize,
369
+ "--max-pods", String(maxPods),
370
+ "--enable-cluster-autoscaler",
371
+ "--min-count", String(minCount),
372
+ "--max-count", String(maxCount),
373
+ "--kubernetes-version", k8sVersion,
374
+ "--tier", tier,
375
+ "--network-plugin", networkPlugin,
376
+ "--generate-ssh-keys",
377
+ "--enable-managed-identity",
378
+ "--enable-oidc-issuer",
379
+ "--enable-workload-identity",
380
+ "--ssh-access", "disabled",
381
+ "--tags", ...tagStr.split(" "),
382
+ "--output", "json",
383
+ ...subArgs(sub),
384
+ ];
385
+
386
+ // Add availability zones for HA
387
+ if (zones.length > 0) {
388
+ createArgs.push("--zones", ...zones);
389
+ console.log(OK(` ✓ Zone redundancy enabled (zones ${zones.join(", ")})`));
390
+ }
391
+
392
+ if (myIp) {
393
+ createArgs.push("--api-server-authorized-ip-ranges", `${myIp}/32`);
394
+ }
395
+
396
+ let cluster;
397
+ try {
398
+ const { stdout: clusterJson } = await execa("az", createArgs, { timeout: 900000 });
399
+ cluster = JSON.parse(clusterJson);
400
+ } catch (err) {
401
+ const msg = (err.stderr || err.message || "").replace(/^.*ERROR:\s*/m, "");
402
+ console.error(ERR(`\n ✗ Cluster creation failed:\n ${msg.split("\n")[0]}`));
403
+ throw err;
404
+ }
405
+ console.log(OK(` ✓ AKS cluster created`));
406
+
407
+ // Get kubeconfig
408
+ await getCredentials(execa, { clusterName, rg, sub });
409
+
410
+ // Save state
411
+ writeClusterState(clusterName, {
412
+ resourceGroup: rg,
413
+ location,
414
+ nodeCount,
415
+ nodeVmSize,
416
+ kubernetesVersion: cluster.kubernetesVersion || k8sVersion,
417
+ subscriptionId: account.id,
418
+ fqdn: cluster.fqdn,
419
+ provisioningState: cluster.provisioningState,
420
+ zones: zones.length > 0 ? zones : undefined,
421
+ createdAt: new Date().toISOString(),
422
+ });
423
+
424
+ // Create GHCR pull secret so the cluster can pull private images
425
+ const githubToken = resolveGithubToken(opts);
426
+ if (githubToken) {
427
+ await ensureGhcrPullSecret(execa, { clusterName, githubToken });
428
+ }
429
+
430
+ // Bootstrap Flux — defaults to meshxdata/platform-flux-template
431
+ const fluxRepo = opts.fluxRepo ?? AKS_DEFAULTS.fluxRepo;
432
+ const fluxOwner = opts.fluxOwner ?? AKS_DEFAULTS.fluxOwner;
433
+ const fluxBranch = opts.fluxBranch ?? AKS_DEFAULTS.fluxBranch;
434
+ const fluxPath = opts.fluxPath || `clusters/${clusterName}`;
435
+
436
+ // Template-based provisioning options
437
+ const templateRepo = opts.templateRepo ?? TEMPLATE_DEFAULTS.templateRepo;
438
+ const templateOwner = opts.templateOwner ?? TEMPLATE_DEFAULTS.templateOwner;
439
+ const templateBranch = opts.templateBranch ?? TEMPLATE_DEFAULTS.templateBranch;
440
+ const environment = opts.environment || "demo";
441
+
442
+ // Get Azure identity info for ExternalSecrets
443
+ let azureIdentityId = "";
444
+ let azureTenantId = "";
445
+ try {
446
+ const { stdout: identityJson } = await execa("az", [
447
+ "aks", "show", "-g", rg, "-n", clusterName,
448
+ "--query", "identityProfile.kubeletidentity.clientId", "-o", "tsv",
449
+ ...subArgs(sub),
450
+ ], { reject: false, timeout: 30000 });
451
+ azureIdentityId = (identityJson || "").trim();
452
+ const { stdout: tenantJson } = await execa("az", [
453
+ "account", "show", "--query", "tenantId", "-o", "tsv",
454
+ ], { reject: false, timeout: 10000 });
455
+ azureTenantId = (tenantJson || "").trim();
456
+ } catch { /* ignore */ }
457
+
458
+ if (opts.noFlux) {
459
+ console.log("");
460
+ hint("Flux not bootstrapped (--no-flux).");
461
+ hint("Bootstrap later: fops azure aks flux bootstrap <name>");
462
+ } else if (!githubToken) {
463
+ console.log("");
464
+ console.log(WARN(" ⚠ Skipping Flux bootstrap — no GitHub token found."));
465
+ hint("Authenticate with: gh auth login (writes to ~/.netrc)");
466
+ hint("Or set GITHUB_TOKEN, or pass --github-token, then run:");
467
+ hint(` fops azure aks flux bootstrap ${clusterName}`);
468
+ } else {
469
+ // Provision cluster manifests from template and commit to flux repo
470
+ let templateProvisioned = false;
471
+ if (!opts.noTemplate) {
472
+ try {
473
+ const result = await provisionFluxFromTemplate(execa, {
474
+ clusterName,
475
+ region: location,
476
+ domain: clusterDomain(clusterName),
477
+ keyvaultUrl: opts.keyvaultUrl || `https://fops-${clusterName}-kv.vault.azure.net`,
478
+ environment,
479
+ azureIdentityId,
480
+ azureTenantId,
481
+ githubToken,
482
+ fluxRepo,
483
+ fluxOwner,
484
+ fluxBranch,
485
+ templateRepo,
486
+ templateOwner,
487
+ templateBranch,
488
+ dryRun: opts.dryRun,
489
+ noCommit: opts.noCommit,
490
+ });
491
+ templateProvisioned = result && result.committed !== false;
492
+ } catch (err) {
493
+ console.log(WARN(` ⚠ Template provisioning failed: ${(err.message || "").split("\n")[0]}`));
494
+ hint("Falling back to legacy Flux bootstrap");
495
+ }
496
+ }
497
+
498
+ // Bootstrap Flux to point to the cluster path
499
+ await bootstrapFlux(execa, {
500
+ clusterName, rg, sub,
501
+ githubToken,
502
+ repo: fluxRepo,
503
+ owner: fluxOwner,
504
+ path: fluxPath,
505
+ branch: fluxBranch,
506
+ });
507
+ writeClusterState(clusterName, {
508
+ flux: {
509
+ repo: fluxRepo, owner: fluxOwner, path: fluxPath, branch: fluxBranch,
510
+ templateProvisioned,
511
+ },
512
+ });
513
+ }
514
+
515
+ // Pre-install CRDs and fix webhook scheduling so Flux kustomizations can reconcile
516
+ if (!opts.noFlux) {
517
+ try {
518
+ await reconcileFluxPrereqs({ execa, clusterName, rg, sub, opts });
519
+ } catch (err) {
520
+ console.log(WARN(` ⚠ Flux prereqs: ${(err.message || "").split("\n")[0]}`));
521
+ hint("Run again with: fops azure aks up " + clusterName);
522
+ }
523
+ }
524
+
525
+ // Configure network access (Key Vault, Storage Account service endpoints)
526
+ try {
527
+ const { stdout: freshJson } = await execa("az", [
528
+ "aks", "show", "-g", rg, "-n", clusterName, "--output", "json", ...subArgs(sub),
529
+ ], { timeout: 30000 });
530
+ const freshCluster = JSON.parse(freshJson);
531
+ await reconcileNetworkAccess({ execa, clusterName, rg, sub, cluster: freshCluster, opts });
532
+ } catch (err) {
533
+ console.log(WARN(` ⚠ Network access config: ${(err.message || "").split("\n")[0]}`));
534
+ }
535
+
536
+ // Provision Postgres Flexible Server in the AKS VNet
537
+ if (opts.noPostgres !== true) {
538
+ try {
539
+ // Re-fetch cluster to get nodeResourceGroup
540
+ const { stdout: freshJson } = await execa("az", [
541
+ "aks", "show", "-g", rg, "-n", clusterName, "--output", "json",
542
+ ...subArgs(sub),
543
+ ], { timeout: 30000 });
544
+ const freshCluster = JSON.parse(freshJson);
545
+ const pgCtx = { execa, clusterName, rg, sub, cluster: freshCluster, opts };
546
+ await reconcilePostgres(pgCtx);
547
+
548
+ // Create geo-replica for HA (default: enabled when in a supported region)
549
+ const geoReplicaRegion = PG_REPLICA_REGIONS[location];
550
+ const createGeoReplica = opts.geoReplica !== false && geoReplicaRegion;
551
+ if (createGeoReplica) {
552
+ try {
553
+ console.log(OK(` ✓ Creating geo-replica in ${geoReplicaRegion} for HA…`));
554
+ await aksPostgresReplicaCreate({
555
+ clusterName,
556
+ profile: sub,
557
+ region: geoReplicaRegion,
558
+ });
559
+ } catch (replicaErr) {
560
+ console.log(WARN(` ⚠ Geo-replica creation failed: ${replicaErr.message?.split("\n")[0] || replicaErr}`));
561
+ hint(` Create manually: fops azure aks postgres replica create --region ${geoReplicaRegion}`);
562
+ }
563
+ }
564
+ } catch (err) {
565
+ const msg = err.message || "";
566
+ const stderr = err.stderr ? err.stderr.trim().split("\n").slice(-2).join(" ") : "";
567
+ console.log(WARN(` ⚠ Postgres provisioning failed: ${msg.split("\n")[0]}`));
568
+ if (stderr && !msg.includes(stderr)) hint(stderr);
569
+ hint("Retry with: fops azure aks up " + clusterName);
570
+ }
571
+ }
572
+
573
+ // Provision Event Hubs (managed Kafka) if requested
574
+ if (opts.managedKafka === true) {
575
+ try {
576
+ const { stdout: freshJson } = await execa("az", [
577
+ "aks", "show", "-g", rg, "-n", clusterName, "--output", "json",
578
+ ...subArgs(sub),
579
+ ], { timeout: 30000 });
580
+ const freshCluster = JSON.parse(freshJson);
581
+ const ehCtx = { execa, clusterName, rg, sub, cluster: freshCluster, opts };
582
+ await reconcileEventHubs(ehCtx);
583
+ } catch (err) {
584
+ const msg = err.message || "";
585
+ console.log(WARN(` ⚠ Event Hubs provisioning failed: ${msg.split("\n")[0]}`));
586
+ hint("Retry with: fops azure aks up " + clusterName + " --managed-kafka");
587
+ }
588
+ }
589
+
590
+ const info = readClusterState(clusterName);
591
+ printClusterInfo(info);
592
+ return info;
593
+ }
594
+
595
+ // ── aks down ──────────────────────────────────────────────────────────────────
596
+
597
+ export async function aksDown(opts = {}) {
598
+ const execa = await lazyExeca();
599
+ const sub = opts.profile;
600
+ await ensureAzCli(execa);
601
+ await ensureAzAuth(execa, { subscription: sub });
602
+
603
+ const name = opts.clusterName;
604
+ let clusterName, rg;
605
+
606
+ // Try local state first
607
+ const tracked = readClusterState(name);
608
+ if (tracked?.clusterName) {
609
+ clusterName = tracked.clusterName;
610
+ rg = tracked.resourceGroup;
611
+ } else if (name) {
612
+ // Not in local state — probe Azure directly for residual clusters
613
+ rg = AKS_DEFAULTS.resourceGroup;
614
+ const { exitCode, stdout } = await execa("az", [
615
+ "aks", "show", "-g", rg, "-n", name, "--output", "json", ...subArgs(sub),
616
+ ], { reject: false, timeout: 30000 });
617
+
618
+ if (exitCode === 0 && stdout) {
619
+ clusterName = name;
620
+ const info = JSON.parse(stdout);
621
+ rg = info.resourceGroup || rg;
622
+ console.log(WARN(`\n Cluster "${name}" not in local state but found in Azure (residual).`));
623
+ } else {
624
+ // Also try listing all clusters in the default RG
625
+ const { stdout: listJson } = await execa("az", [
626
+ "aks", "list", "-g", rg, "--output", "json", ...subArgs(sub),
627
+ ], { reject: false, timeout: 30000 });
628
+ const clusters = listJson ? JSON.parse(listJson) : [];
629
+ const match = clusters.find((c) => c.name === name);
630
+ if (match) {
631
+ clusterName = match.name;
632
+ rg = match.resourceGroup || rg;
633
+ console.log(WARN(`\n Cluster "${name}" not in local state but found in Azure (residual).`));
634
+ } else {
635
+ console.error(ERR(`\n Cluster "${name}" not found in local state or Azure (RG: ${rg}).`));
636
+ hint("List Azure clusters: az aks list -g foundation-aks-rg -o table");
637
+ hint("List tracked: fops azure aks list\n");
638
+ process.exit(1);
639
+ }
640
+ }
641
+ } else {
642
+ // No name given, require tracked state
643
+ requireCluster(name);
644
+ return; // unreachable, requireCluster exits
645
+ }
646
+
647
+ banner(`Destroying AKS cluster "${clusterName}"`);
648
+ kvLine("RG", DIM(rg));
649
+
650
+ if (!opts.yes) {
651
+ const { confirm } = await import(resolveCliSrc("ui/confirm.js"));
652
+ const ok = await confirm(` Destroy cluster "${clusterName}" and all workloads?`);
653
+ if (!ok) { console.log(DIM("\n Cancelled.\n")); return; }
654
+ }
655
+
656
+ hint("This takes 3–5 minutes…\n");
657
+
658
+ // Delete Postgres Flexible Server first (releases subnet delegation blocking VNet deletion)
659
+ const pgServerNameVal = `fops-${clusterName}-psql`;
660
+ const { exitCode: pgExists } = await execa("az", [
661
+ "postgres", "flexible-server", "show",
662
+ "--name", pgServerNameVal, "--resource-group", rg,
663
+ "--output", "none", ...subArgs(sub),
664
+ ], { reject: false, timeout: 30000 });
665
+
666
+ if (pgExists === 0) {
667
+ console.log(DIM(` Deleting Postgres server "${pgServerNameVal}"…`));
668
+ await execa("az", [
669
+ "postgres", "flexible-server", "delete",
670
+ "--name", pgServerNameVal, "--resource-group", rg,
671
+ "--yes", "--output", "none", ...subArgs(sub),
672
+ ], { reject: false, timeout: 300000 });
673
+ console.log(OK(" ✓ Postgres server deleted"));
674
+ }
675
+
676
+ // Delete Private DNS zone (also blocks VNet deletion)
677
+ const dnsZone = `${pgServerNameVal}.private.postgres.database.azure.com`;
678
+ const { exitCode: dnsExists } = await execa("az", [
679
+ "network", "private-dns", "zone", "show",
680
+ "-g", rg, "-n", dnsZone, "--output", "none", ...subArgs(sub),
681
+ ], { reject: false, timeout: 15000 });
682
+
683
+ if (dnsExists === 0) {
684
+ console.log(DIM(` Deleting Private DNS zone "${dnsZone}"…`));
685
+ await execa("az", [
686
+ "network", "private-dns", "zone", "delete",
687
+ "-g", rg, "-n", dnsZone, "--yes", "--output", "none", ...subArgs(sub),
688
+ ], { reject: false, timeout: 60000 });
689
+ console.log(OK(" ✓ Private DNS zone deleted"));
690
+ }
691
+
692
+ // Delete KeyVault (best-effort cleanup)
693
+ const kvNameVal = `fops-${clusterName}-kv`.replace(/[^a-zA-Z0-9-]/g, "").slice(0, 24);
694
+ await execa("az", [
695
+ "keyvault", "delete", "--name", kvNameVal, "--resource-group", rg,
696
+ "--output", "none", ...subArgs(sub),
697
+ ], { reject: false, timeout: 60000 });
698
+
699
+ await execa("az", [
700
+ "aks", "delete", "--resource-group", rg, "--name", clusterName,
701
+ "--yes", "--no-wait", "--output", "none",
702
+ ...subArgs(sub),
703
+ ], { timeout: 300000 });
704
+ console.log(OK(" ✓ Cluster deletion initiated"));
705
+
706
+ // Remove kubeconfig context
707
+ try {
708
+ await execa("kubectl", ["config", "delete-context", clusterName], { reject: false, timeout: 10000 });
709
+ await execa("kubectl", ["config", "delete-cluster", clusterName], { reject: false, timeout: 10000 });
710
+ console.log(OK(" ✓ Kubeconfig context removed"));
711
+ } catch { /* best-effort */ }
712
+
713
+ clearClusterState(clusterName);
714
+ console.log(OK("\n ✓ Done.") + DIM(" State cleared.\n"));
715
+ }
716
+
717
+ // ── aks list ──────────────────────────────────────────────────────────────────
718
+
719
+ export async function aksList(opts = {}) {
720
+ let { activeCluster, clusters } = readAksClusters();
721
+ let names = Object.keys(clusters);
722
+
723
+ banner("AKS Clusters");
724
+
725
+ // If no clusters tracked locally, try to discover fops-managed clusters from Azure
726
+ if (names.length === 0) {
727
+ const execa = await lazyExeca();
728
+ try {
729
+ await ensureAzCli(execa);
730
+ await ensureAzAuth(execa, { subscription: opts.profile });
731
+ } catch {
732
+ hint("No clusters tracked.");
733
+ hint("Create one: fops azure aks up <name>\n");
734
+ return;
735
+ }
736
+
737
+ hint("No clusters tracked locally — checking Azure for fops-managed clusters…\n");
738
+
739
+ try {
740
+ // Query all AKS clusters and filter by managed=fops tag
741
+ const { stdout, exitCode } = await execa("az", [
742
+ "aks", "list",
743
+ "--query", "[?tags.managed=='fops']",
744
+ "--output", "json",
745
+ ...subArgs(opts.profile),
746
+ ], { timeout: 60000, reject: false });
747
+
748
+ if (exitCode === 0 && stdout?.trim()) {
749
+ const discovered = JSON.parse(stdout);
750
+ if (discovered.length > 0) {
751
+ for (const cl of discovered) {
752
+ const name = cl.name;
753
+ const info = {
754
+ resourceGroup: cl.resourceGroup,
755
+ location: cl.location,
756
+ kubernetesVersion: cl.kubernetesVersion,
757
+ fqdn: cl.fqdn,
758
+ nodeCount: cl.agentPoolProfiles?.reduce((s, p) => s + (p.count || 0), 0) || 0,
759
+ nodeVmSize: cl.agentPoolProfiles?.[0]?.vmSize || "unknown",
760
+ subscriptionId: cl.id?.split("/")[2],
761
+ createdAt: cl.provisioningState === "Succeeded" ? new Date().toISOString() : null,
762
+ };
763
+ writeClusterState(name, info);
764
+ console.log(OK(` + Discovered ${name} (${cl.location})`));
765
+ }
766
+ console.log("");
767
+ // Re-read after discovery
768
+ const updated = readAksClusters();
769
+ activeCluster = updated.activeCluster;
770
+ clusters = updated.clusters;
771
+ names = Object.keys(clusters);
772
+ }
773
+ }
774
+ } catch {
775
+ // Discovery failed, continue with empty list
776
+ }
777
+
778
+ if (names.length === 0) {
779
+ hint("No fops-managed clusters found in Azure.");
780
+ hint("Create one: fops azure aks up <name>\n");
781
+ return;
782
+ }
783
+ }
784
+
785
+ // Refresh each tracked cluster from Azure so RG, Location, Nodes, FQDN, etc. are current
786
+ try {
787
+ const execa = await lazyExeca();
788
+ await ensureAzCli(execa);
789
+ await ensureAzAuth(execa, { subscription: opts.profile });
790
+ for (const name of names) {
791
+ const cl = clusters[name];
792
+ let azCluster = null;
793
+ if (cl.resourceGroup) {
794
+ const { stdout, exitCode } = await execa("az", [
795
+ "aks", "show", "-g", cl.resourceGroup, "-n", name, "--output", "json",
796
+ ...subArgs(opts.profile),
797
+ ], { timeout: 15000, reject: false });
798
+ if (exitCode === 0 && stdout?.trim()) azCluster = JSON.parse(stdout);
799
+ }
800
+ if (!azCluster) {
801
+ const { stdout, exitCode } = await execa("az", [
802
+ "aks", "list", "--output", "json", ...subArgs(opts.profile),
803
+ ], { timeout: 60000, reject: false });
804
+ if (exitCode === 0 && stdout?.trim()) {
805
+ const list = JSON.parse(stdout);
806
+ azCluster = list.find((c) => c.name === name) || null;
807
+ }
808
+ }
809
+ if (azCluster) {
810
+ const info = {
811
+ resourceGroup: azCluster.resourceGroup,
812
+ location: azCluster.location,
813
+ kubernetesVersion: azCluster.kubernetesVersion,
814
+ fqdn: azCluster.fqdn,
815
+ nodeCount: azCluster.agentPoolProfiles?.reduce((s, p) => s + (p.count || 0), 0) ?? null,
816
+ nodeVmSize: azCluster.agentPoolProfiles?.[0]?.vmSize || null,
817
+ subscriptionId: azCluster.id?.split("/")[2],
818
+ };
819
+ if (!cl.createdAt && azCluster.provisioningState === "Succeeded") {
820
+ info.createdAt = new Date().toISOString();
821
+ }
822
+ writeClusterState(name, info);
823
+ }
824
+ }
825
+ const updated = readAksClusters();
826
+ clusters = updated.clusters;
827
+ } catch {
828
+ // Keep existing state if refresh fails (e.g. az not logged in)
829
+ }
830
+
831
+ for (const name of names) {
832
+ const cl = clusters[name];
833
+ const active = name === activeCluster ? OK(" ◀ active") : "";
834
+ console.log(`\n ${LABEL(name)}${active}`);
835
+ kvLine(" RG", DIM(cl.resourceGroup || "—"), { pad: 12 });
836
+ kvLine(" Location", DIM(cl.location || "—"), { pad: 12 });
837
+ kvLine(" Nodes", DIM(`${cl.nodeCount || "?"} x ${cl.nodeVmSize || "?"}`), { pad: 12 });
838
+ kvLine(" K8s", DIM(cl.kubernetesVersion || "—"), { pad: 12 });
839
+ if (cl.zones?.length > 0) {
840
+ kvLine(" Zones", DIM(cl.zones.join(", ")), { pad: 12 });
841
+ }
842
+ kvLine(" FQDN", DIM(cl.fqdn || "—"), { pad: 12 });
843
+ if (cl.flux) {
844
+ kvLine(" Flux", DIM(`${cl.flux.owner}/${cl.flux.repo} (${cl.flux.path})`), { pad: 12 });
845
+ }
846
+ kvLine(" Created", DIM(cl.createdAt ? new Date(cl.createdAt).toLocaleString() : "—"), { pad: 12 });
847
+ if (cl.domain) {
848
+ kvLine(" Domain", DIM(cl.domain), { pad: 12 });
849
+ }
850
+ if (cl.qa) {
851
+ const qaAge = timeSince(cl.qa.at);
852
+ const qaLabel = cl.qa.passed ? OK(`✓ passed (${qaAge} ago)`) : ERR(`✗ failed (${qaAge} ago)`);
853
+ kvLine(" QA", qaLabel, { pad: 12 });
854
+ }
855
+ }
856
+ console.log("");
857
+ }
858
+
859
+ // ── aks status ────────────────────────────────────────────────────────────────
860
+
861
+ export async function aksStatus(opts = {}) {
862
+ const execa = await lazyExeca();
863
+ const sub = opts.profile;
864
+ await ensureAzCli(execa);
865
+ await ensureAzAuth(execa, { subscription: sub });
866
+ const { clusterName, resourceGroup: rg } = requireCluster(opts.clusterName);
867
+
868
+ banner(`AKS Status: ${clusterName}`);
869
+
870
+ // Cluster info from Azure
871
+ const { stdout: showJson } = await execa("az", [
872
+ "aks", "show", "-g", rg, "-n", clusterName, "--output", "json",
873
+ ...subArgs(sub),
874
+ ], { timeout: 30000 });
875
+ const info = JSON.parse(showJson);
876
+
877
+ kvLine("State", info.provisioningState === "Succeeded" ? OK(info.provisioningState) : WARN(info.provisioningState));
878
+ kvLine("Power", info.powerState?.code === "Running" ? OK(info.powerState.code) : WARN(info.powerState?.code || "unknown"));
879
+ kvLine("K8s", DIM(info.kubernetesVersion));
880
+ kvLine("FQDN", DIM(info.fqdn));
881
+ kvLine("Location", DIM(info.location));
882
+ kvLine("Tier", DIM(info.sku?.tier || "—"));
883
+
884
+ // Node pools
885
+ const pools = info.agentPoolProfiles || [];
886
+ console.log(`\n ${LABEL("Node Pools")}`);
887
+ for (const pool of pools) {
888
+ const status = pool.provisioningState === "Succeeded" ? OK("ready") : WARN(pool.provisioningState);
889
+ const zones = pool.availabilityZones?.length > 0 ? ` zones:${pool.availabilityZones.join(",")}` : " no-zones";
890
+ const scaling = pool.enableAutoScaling ? ` (${pool.minCount}-${pool.maxCount})` : "";
891
+ console.log(` ${pool.name}: ${pool.count}${scaling} x ${pool.vmSize} [${status}]${DIM(zones)}`);
892
+ }
893
+
894
+ // Flux status (if available)
895
+ try {
896
+ await execa("flux", ["--version"], { timeout: 5000 });
897
+ console.log(`\n ${LABEL("Flux Status")}`);
898
+ const { stdout: fluxStatus } = await execa("flux", [
899
+ "get", "all", "--context", clusterName, "--no-header",
900
+ ], { timeout: 30000, reject: false });
901
+ if (fluxStatus?.trim()) {
902
+ for (const line of fluxStatus.trim().split("\n").slice(0, 20)) {
903
+ console.log(` ${DIM(line)}`);
904
+ }
905
+ } else {
906
+ hint(" Flux not installed or no resources found.");
907
+ }
908
+ } catch {
909
+ hint(" Flux CLI not available — skipping Flux status.");
910
+ }
911
+
912
+ console.log("");
913
+ }
914
+
915
+ // ── aks config versions ──────────────────────────────────────────────────────
916
+
917
+ const AKS_FOUNDATION_COMPONENTS = {
918
+ "foundation-backend": { label: "Backend", short: "be" },
919
+ "foundation-frontend": { label: "Frontend", short: "fe" },
920
+ "foundation-processor": { label: "Processor", short: "pr" },
921
+ "foundation-watcher": { label: "Watcher", short: "wa" },
922
+ "foundation-scheduler": { label: "Scheduler", short: "sc" },
923
+ "foundation-storage-engine": { label: "Storage", short: "se" },
924
+ };
925
+
926
+ export async function aksConfigVersions(opts = {}) {
927
+ const execa = await lazyExeca();
928
+ const { clusterName } = requireCluster(opts.clusterName);
929
+
930
+ banner(`Service Versions: ${clusterName}`);
931
+
932
+ const kubectl = (args) =>
933
+ execa("kubectl", ["--context", clusterName, ...args], { timeout: 15000, reject: false });
934
+
935
+ const { stdout, exitCode } = await kubectl([
936
+ "get", "deployments", "-n", "foundation",
937
+ "-o", "jsonpath={range .items[*]}{.metadata.name}={.spec.template.spec.containers[0].image}{\"\\n\"}{end}",
938
+ ]);
939
+
940
+ if (exitCode !== 0 || !stdout?.trim()) {
941
+ hint("Could not read deployments. Is kubeconfig merged?");
942
+ hint(` Run: fops azure aks kubeconfig ${clusterName}\n`);
943
+ return;
944
+ }
945
+
946
+ const versions = {};
947
+ for (const line of stdout.trim().split("\n")) {
948
+ const eq = line.indexOf("=");
949
+ if (eq < 0) continue;
950
+ const name = line.slice(0, eq).trim();
951
+ const image = line.slice(eq + 1).trim();
952
+ const comp = AKS_FOUNDATION_COMPONENTS[name];
953
+ if (!comp) continue;
954
+ const colon = image.lastIndexOf(":");
955
+ const tag = colon >= 0 ? image.slice(colon + 1) : "latest";
956
+ versions[name] = { ...comp, image, tag };
957
+ }
958
+
959
+ if (Object.keys(versions).length === 0) {
960
+ hint("No Foundation services found in the 'foundation' namespace.\n");
961
+ return;
962
+ }
963
+
964
+ const maxLabel = Math.max(...Object.values(versions).map(v => v.label.length));
965
+ for (const [, v] of Object.entries(versions)) {
966
+ console.log(` ${ACCENT(v.label.padEnd(maxLabel + 2))} ${chalk.white(v.tag)} ${DIM(v.image)}`);
967
+ }
968
+ console.log("");
969
+
970
+ // Check for version drift
971
+ const tags = [...new Set(Object.values(versions).map(v => v.tag))];
972
+ if (tags.length === 1) {
973
+ console.log(OK(` ✓ All services on ${tags[0]}`));
974
+ } else {
975
+ console.log(WARN(` ⚠ Version drift detected — ${tags.length} different tags in use`));
976
+ }
977
+ console.log("");
978
+ }
979
+
980
+ // ── aks kubeconfig ────────────────────────────────────────────────────────────
981
+
982
+ export async function aksKubeconfig(opts = {}) {
983
+ const execa = await lazyExeca();
984
+ const sub = opts.profile;
985
+ await ensureAzCli(execa);
986
+ await ensureAzAuth(execa, { subscription: sub });
987
+ const { clusterName, resourceGroup: rg } = requireCluster(opts.clusterName);
988
+
989
+ await getCredentials(execa, { clusterName, rg, sub, admin: opts.admin });
990
+ console.log(OK(`\n ✓ Kubeconfig merged for "${clusterName}"`));
991
+ hint(`kubectl config use-context ${clusterName}\n`);
992
+ }
993
+
994
+ // ── aks node-pool add ─────────────────────────────────────────────────────────
995
+
996
+ export async function aksNodePoolAdd(opts = {}) {
997
+ const execa = await lazyExeca();
998
+ const sub = opts.profile;
999
+ await ensureAzCli(execa);
1000
+ await ensureAzAuth(execa, { subscription: sub });
1001
+ const { clusterName, resourceGroup: rg } = requireCluster(opts.clusterName);
1002
+
1003
+ const poolName = opts.poolName;
1004
+ const nodeCount = opts.nodeCount || 3;
1005
+ const vmSize = opts.nodeVmSize || AKS_DEFAULTS.nodeVmSize;
1006
+
1007
+ if (!poolName) {
1008
+ console.error(ERR("\n --pool-name is required.\n"));
1009
+ process.exit(1);
1010
+ }
1011
+
1012
+ banner(`Adding node pool "${poolName}" to ${clusterName}`);
1013
+ kvLine("Size", DIM(`${nodeCount} x ${vmSize}`));
1014
+ hint("This takes a few minutes…\n");
1015
+
1016
+ const args = [
1017
+ "aks", "nodepool", "add",
1018
+ "--resource-group", rg,
1019
+ "--cluster-name", clusterName,
1020
+ "--name", poolName,
1021
+ "--node-count", String(nodeCount),
1022
+ "--node-vm-size", vmSize,
1023
+ "--ssh-access", "disabled",
1024
+ "--output", "none",
1025
+ ...subArgs(sub),
1026
+ ];
1027
+ if (opts.mode) args.push("--mode", opts.mode);
1028
+ if (opts.labels) args.push("--labels", opts.labels);
1029
+ if (opts.taints) args.push("--node-taints", opts.taints);
1030
+ if (opts.maxPods) args.push("--max-pods", String(opts.maxPods));
1031
+
1032
+ // Zone support - can specify "1,2,3" or use --zones flag
1033
+ const zones = opts.zones === true ? ["1", "2", "3"] : (opts.zones ? opts.zones.split(",") : []);
1034
+ if (zones.length > 0) {
1035
+ args.push("--zones", ...zones);
1036
+ kvLine("Zones", zones.join(", "));
1037
+ }
1038
+
1039
+ await execa("az", args, { timeout: 600000 });
1040
+ const zoneInfo = zones.length > 0 ? ` (zones ${zones.join(",")})` : "";
1041
+ console.log(OK(` ✓ Node pool "${poolName}" added${zoneInfo}\n`));
1042
+ }
1043
+
1044
+ // ── aks node-pool remove ──────────────────────────────────────────────────────
1045
+
1046
+ export async function aksNodePoolRemove(opts = {}) {
1047
+ const execa = await lazyExeca();
1048
+ const sub = opts.profile;
1049
+ await ensureAzCli(execa);
1050
+ await ensureAzAuth(execa, { subscription: sub });
1051
+ const { clusterName, resourceGroup: rg } = requireCluster(opts.clusterName);
1052
+ const poolName = opts.poolName;
1053
+
1054
+ if (!poolName) {
1055
+ console.error(ERR("\n Pool name is required.\n"));
1056
+ process.exit(1);
1057
+ }
1058
+
1059
+ banner(`Removing node pool "${poolName}" from ${clusterName}`);
1060
+ hint("This takes a few minutes…\n");
1061
+
1062
+ await execa("az", [
1063
+ "aks", "nodepool", "delete",
1064
+ "--resource-group", rg,
1065
+ "--cluster-name", clusterName,
1066
+ "--name", poolName,
1067
+ "--output", "none",
1068
+ ...subArgs(sub),
1069
+ ], { timeout: 600000 });
1070
+ console.log(OK(` ✓ Node pool "${poolName}" removed\n`));
1071
+ }
1072
+
1073
+ // ── aks grant admin ──────────────────────────────────────────────────────────
1074
+
1075
+ export async function aksGrantAdmin(opts = {}) {
1076
+ const execa = await lazyExeca();
1077
+ const { clusterName } = requireCluster(opts.clusterName);
1078
+ const email = opts.email;
1079
+
1080
+ if (!email) {
1081
+ console.error(ERR("\n Email is required.\n"));
1082
+ process.exit(1);
1083
+ }
1084
+
1085
+ banner(`Granting admin to ${email}`);
1086
+
1087
+ const kubectl = async (args, execaOpts = {}) => {
1088
+ const { stdout, stderr, exitCode } = await execa("kubectl", ["--context", clusterName, ...args], {
1089
+ timeout: 60000, reject: false, ...execaOpts,
1090
+ });
1091
+ return { stdout, stderr, exitCode };
1092
+ };
1093
+
1094
+ // Read postgres host from HelmRelease values (try config.postgres.host first, then env.postgres.host)
1095
+ let { stdout: pgHost } = await kubectl([
1096
+ "get", "helmrelease", "foundation-backend", "-n", "foundation",
1097
+ "-o", "jsonpath={.spec.values.config.postgres.host}",
1098
+ ]);
1099
+ if (!pgHost) {
1100
+ ({ stdout: pgHost } = await kubectl([
1101
+ "get", "helmrelease", "foundation-backend", "-n", "foundation",
1102
+ "-o", "jsonpath={.spec.values.env.postgres.host}",
1103
+ ]));
1104
+ }
1105
+
1106
+ if (!pgHost) {
1107
+ console.log(ERR(" ✗ Could not find postgres host in foundation-backend HelmRelease"));
1108
+ return;
1109
+ }
1110
+
1111
+ // Read password from the postgres secret
1112
+ const { stdout: pwB64 } = await kubectl([
1113
+ "get", "secret", "postgres", "-n", "foundation",
1114
+ "-o", "jsonpath={.data.password}",
1115
+ ]);
1116
+ if (!pwB64) {
1117
+ console.log(ERR(" ✗ No postgres secret found"));
1118
+ return;
1119
+ }
1120
+ const pgPass = Buffer.from(pwB64, "base64").toString();
1121
+
1122
+ // SQL to grant Foundation Admin role (role_id=1) to user by email
1123
+ // Uses the role_member table which links users to roles via identifier
1124
+ const escapedEmail = email.replace(/'/g, "''");
1125
+ const sql = `INSERT INTO role_member (role_id, identifier) SELECT 1, u.identifier FROM "user" u WHERE u.email = '${escapedEmail}' AND NOT EXISTS (SELECT 1 FROM role_member rm WHERE rm.role_id = 1 AND rm.identifier = u.identifier);`;
1126
+ // Escape double quotes for shell - "user" becomes \\"user\\"
1127
+ const escapedSql = sql.replace(/"/g, '\\"');
1128
+ const script = `psql -c "${escapedSql}" && echo DONE`;
1129
+
1130
+ const jobManifest = JSON.stringify({
1131
+ apiVersion: "batch/v1", kind: "Job",
1132
+ metadata: { name: "fops-grant-admin", namespace: "foundation" },
1133
+ spec: {
1134
+ backoffLimit: 2, ttlSecondsAfterFinished: 60,
1135
+ template: {
1136
+ spec: {
1137
+ restartPolicy: "Never",
1138
+ containers: [{
1139
+ name: "psql",
1140
+ image: "postgres:16-alpine",
1141
+ env: [
1142
+ { name: "PGHOST", value: pgHost },
1143
+ { name: "PGUSER", value: "foundation" },
1144
+ { name: "PGDATABASE", value: "foundation" },
1145
+ { name: "PGPASSWORD", value: pgPass },
1146
+ { name: "PGSSLMODE", value: "require" },
1147
+ ],
1148
+ command: ["sh", "-c", script],
1149
+ }],
1150
+ },
1151
+ },
1152
+ },
1153
+ });
1154
+
1155
+ // Delete old job if it exists
1156
+ await kubectl(["delete", "job", "fops-grant-admin", "-n", "foundation", "--ignore-not-found"]);
1157
+ await new Promise(r => setTimeout(r, 2000));
1158
+
1159
+ const { exitCode, stderr } = await kubectl(["apply", "-f", "-"], { input: jobManifest });
1160
+ if (exitCode !== 0) {
1161
+ console.log(ERR(` ✗ grant-admin job failed: ${(stderr || "").split("\n")[0]}`));
1162
+ return;
1163
+ }
1164
+
1165
+ // Wait for job to complete
1166
+ const { exitCode: waitCode } = await execa("kubectl", [
1167
+ "--context", clusterName,
1168
+ "wait", "--for=condition=complete", "job/fops-grant-admin",
1169
+ "-n", "foundation", "--timeout=60s",
1170
+ ], { timeout: 70000, reject: false });
1171
+
1172
+ if (waitCode === 0) {
1173
+ console.log(OK(` ✓ Admin role granted to ${email}`));
1174
+ } else {
1175
+ const { stdout: logs } = await kubectl(["logs", "job/fops-grant-admin", "-n", "foundation", "--tail=10"]);
1176
+ if (logs?.includes("DONE")) {
1177
+ console.log(OK(` ✓ Admin role granted to ${email}`));
1178
+ } else {
1179
+ console.log(WARN(" ⚠ grant-admin job didn't complete — check: kubectl logs job/fops-grant-admin -n foundation"));
1180
+ console.log(DIM(logs || ""));
1181
+ }
1182
+ }
1183
+
1184
+ await kubectl(["delete", "job", "fops-grant-admin", "-n", "foundation", "--ignore-not-found"]);
1185
+ }