@meshxdata/fops 0.1.45 → 0.1.46

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 +16 -18
  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,768 @@
1
+ /**
2
+ * azure-aks-postgres.js - PostgreSQL Flexible Server and replicas
3
+ *
4
+ * Depends on: azure-aks-naming.js, azure-aks-state.js, azure-aks-network.js
5
+ */
6
+
7
+ import chalk from "chalk";
8
+ import { DEFAULTS, OK, WARN, ERR, DIM, hint, banner, kvLine, subArgs } from "./azure.js";
9
+ import { pgServerName, generatePassword, PG_DEFAULTS, PG_REPLICA_REGIONS, EH_DEFAULTS, ehNamespaceName } from "./azure-aks-naming.js";
10
+ import { readClusterState, writeClusterState, requireCluster } from "./azure-aks-state.js";
11
+ import { findAvailableSubnetCidr, findAksVnet } from "./azure-aks-network.js";
12
+
13
+ // ── Service databases ─────────────────────────────────────────────────────────
14
+
15
+ export const PG_SERVICE_DBS = ["foundation", "processor", "scheduler", "watcher", "mlflow"];
16
+
17
+ // ── Postgres reconciliation ───────────────────────────────────────────────────
18
+
19
+ export async function reconcilePostgres(ctx) {
20
+ const { execa, clusterName, rg, sub, cluster } = ctx;
21
+ const location = cluster.location || DEFAULTS.location;
22
+ const serverName = pgServerName(clusterName);
23
+
24
+ // 1. Check if server already exists
25
+ const { exitCode: pgExists, stdout: pgJson } = await execa("az", [
26
+ "postgres", "flexible-server", "show",
27
+ "--name", serverName, "--resource-group", rg,
28
+ "--output", "json", ...subArgs(sub),
29
+ ], { reject: false, timeout: 30000 });
30
+
31
+ let fqdn;
32
+ let adminPassword;
33
+
34
+ if (pgExists === 0 && pgJson?.trim()) {
35
+ const pg = JSON.parse(pgJson);
36
+ fqdn = pg.fullyQualifiedDomainName;
37
+ console.log(OK(` ✓ Postgres Flexible Server "${serverName}" exists (${fqdn})`));
38
+
39
+ // Reconcile storage auto-grow
40
+ const autoGrow = pg.storage?.autoGrow;
41
+ if (autoGrow !== "Enabled") {
42
+ hint("Enabling storage auto-grow…");
43
+ await execa("az", [
44
+ "postgres", "flexible-server", "update",
45
+ "--name", serverName, "--resource-group", rg,
46
+ "--storage-auto-grow", "Enabled",
47
+ "--output", "none", ...subArgs(sub),
48
+ ], { reject: false, timeout: 120000 });
49
+ console.log(OK(" ✓ Storage auto-grow enabled"));
50
+ }
51
+
52
+ adminPassword = readClusterState(clusterName)?.postgres?.adminPassword;
53
+ if (!adminPassword) {
54
+ console.log(WARN(" ⚠ Postgres admin password not in local state — secret sync skipped"));
55
+ hint(` Password was set at creation time. If lost, reset with:`);
56
+ hint(` az postgres flexible-server update -g ${rg} -n ${serverName} --admin-password <new>`);
57
+ return;
58
+ }
59
+ } else {
60
+ // 2. Resolve the AKS VNet + create a delegated subnet for Postgres
61
+ const nodeRg = cluster.nodeResourceGroup;
62
+ if (!nodeRg) {
63
+ console.log(WARN(" ⚠ Could not determine node resource group — skipping Postgres"));
64
+ return;
65
+ }
66
+
67
+ hint(`Resolving AKS VNet in ${nodeRg}…`);
68
+ const { stdout: vnetListJson, exitCode: vnetCode } = await execa("az", [
69
+ "network", "vnet", "list", "-g", nodeRg, "--output", "json",
70
+ ...subArgs(sub),
71
+ ], { reject: false, timeout: 15000 });
72
+
73
+ if (vnetCode !== 0 || !vnetListJson?.trim()) {
74
+ console.log(WARN(" ⚠ No VNet found in node resource group — skipping Postgres"));
75
+ return;
76
+ }
77
+
78
+ const vnets = JSON.parse(vnetListJson);
79
+ const vnet = vnets[0];
80
+ if (!vnet) {
81
+ console.log(WARN(" ⚠ No VNet found — skipping Postgres"));
82
+ return;
83
+ }
84
+
85
+ const vnetName = vnet.name;
86
+ const pgSubnetName = "postgres-subnet";
87
+
88
+ // Check if the postgres subnet already exists
89
+ const { exitCode: subnetExists } = await execa("az", [
90
+ "network", "vnet", "subnet", "show",
91
+ "-g", nodeRg, "--vnet-name", vnetName, "-n", pgSubnetName,
92
+ "--output", "none", ...subArgs(sub),
93
+ ], { reject: false, timeout: 15000 });
94
+
95
+ if (subnetExists !== 0) {
96
+ const vnetPrefix = vnet.addressSpace?.addressPrefixes?.[0] || "10.224.0.0/12";
97
+ const pgCidr = await findAvailableSubnetCidr(execa, nodeRg, vnetName, vnetPrefix, sub);
98
+
99
+ hint(`Creating subnet "${pgSubnetName}" (${pgCidr}) in ${vnetName}…`);
100
+ const { exitCode: createCode, stderr: createErr } = await execa("az", [
101
+ "network", "vnet", "subnet", "create",
102
+ "-g", nodeRg, "--vnet-name", vnetName, "-n", pgSubnetName,
103
+ "--address-prefixes", pgCidr,
104
+ "--delegations", "Microsoft.DBforPostgreSQL/flexibleServers",
105
+ "--output", "none", ...subArgs(sub),
106
+ ], { reject: false, timeout: 60000 });
107
+ if (createCode !== 0) {
108
+ const detail = (createErr || "").split("\n").filter(l => l.trim()).slice(-2).join(" ");
109
+ throw new Error(`Subnet creation failed (${pgCidr}): ${detail || "exit code " + createCode}`);
110
+ }
111
+ console.log(OK(` ✓ Subnet "${pgSubnetName}" created`));
112
+ }
113
+
114
+ // Get subnet ID
115
+ const { stdout: subnetJson } = await execa("az", [
116
+ "network", "vnet", "subnet", "show",
117
+ "-g", nodeRg, "--vnet-name", vnetName, "-n", pgSubnetName,
118
+ "--output", "json", ...subArgs(sub),
119
+ ], { timeout: 15000 });
120
+ const subnetId = JSON.parse(subnetJson).id;
121
+
122
+ // Create a private DNS zone for Postgres in the VNet
123
+ const dnsZone = `${serverName}.private.postgres.database.azure.com`;
124
+ const { exitCode: dnsExists } = await execa("az", [
125
+ "network", "private-dns", "zone", "show",
126
+ "-g", rg, "-n", dnsZone, "--output", "none",
127
+ ...subArgs(sub),
128
+ ], { reject: false, timeout: 15000 });
129
+
130
+ if (dnsExists !== 0) {
131
+ hint(`Creating private DNS zone ${dnsZone}…`);
132
+ await execa("az", [
133
+ "network", "private-dns", "zone", "create",
134
+ "-g", rg, "-n", dnsZone, "--output", "none",
135
+ ...subArgs(sub),
136
+ ], { timeout: 60000 });
137
+ }
138
+
139
+ // 3. Create the Flexible Server
140
+ adminPassword = generatePassword();
141
+ console.log(chalk.yellow(` ↻ Creating Postgres Flexible Server "${serverName}"…`));
142
+ hint("This takes 3–5 minutes…");
143
+
144
+ await execa("az", [
145
+ "postgres", "flexible-server", "create",
146
+ "--name", serverName,
147
+ "--resource-group", rg,
148
+ "--location", location,
149
+ "--admin-user", PG_DEFAULTS.adminUser,
150
+ "--admin-password", adminPassword,
151
+ "--sku-name", PG_DEFAULTS.sku,
152
+ "--tier", PG_DEFAULTS.tier,
153
+ "--version", PG_DEFAULTS.version,
154
+ "--storage-size", String(PG_DEFAULTS.storageSizeGb),
155
+ "--storage-auto-grow", "Enabled",
156
+ "--subnet", subnetId,
157
+ "--private-dns-zone", dnsZone,
158
+ "--yes",
159
+ "--output", "json", ...subArgs(sub),
160
+ ], { timeout: 600000 });
161
+
162
+ // Read the created server to get the FQDN
163
+ const { stdout: createdJson } = await execa("az", [
164
+ "postgres", "flexible-server", "show",
165
+ "--name", serverName, "--resource-group", rg,
166
+ "--output", "json", ...subArgs(sub),
167
+ ], { timeout: 15000 });
168
+ fqdn = JSON.parse(createdJson).fullyQualifiedDomainName;
169
+
170
+ console.log(OK(` ✓ Postgres Flexible Server created (${fqdn})`));
171
+
172
+ // Save password + FQDN to local state
173
+ writeClusterState(clusterName, {
174
+ postgres: { serverName, fqdn, adminUser: PG_DEFAULTS.adminUser, adminPassword },
175
+ });
176
+ }
177
+
178
+ // 4. Allowlist pg_trgm extension (required by backend search migration)
179
+ const { stdout: extVal } = await execa("az", [
180
+ "postgres", "flexible-server", "parameter", "show",
181
+ "--resource-group", rg, "--server-name", serverName,
182
+ "--name", "azure.extensions", "--query", "value", "-o", "tsv",
183
+ ...subArgs(sub),
184
+ ], { reject: false, timeout: 15000 });
185
+ const currentExts = (extVal || "").trim().split(",").map(e => e.trim()).filter(Boolean);
186
+ if (!currentExts.includes("PG_TRGM") && !currentExts.includes("pg_trgm")) {
187
+ const newExts = [...currentExts, "pg_trgm"].join(",");
188
+ hint("Allowlisting pg_trgm extension…");
189
+ await execa("az", [
190
+ "postgres", "flexible-server", "parameter", "set",
191
+ "--resource-group", rg, "--server-name", serverName,
192
+ "--name", "azure.extensions", "--value", newExts,
193
+ "--output", "none", ...subArgs(sub),
194
+ ], { reject: false, timeout: 60000 });
195
+ console.log(OK(" ✓ pg_trgm extension allowlisted"));
196
+ }
197
+
198
+ // 5. Sync the K8s "postgres" secret into the foundation namespace
199
+ if (fqdn && adminPassword) {
200
+ await syncPostgresSecret(execa, { clusterName, fqdn, adminUser: PG_DEFAULTS.adminUser, adminPassword });
201
+ }
202
+ }
203
+
204
+ // ── Sync Postgres secret to K8s ───────────────────────────────────────────────
205
+
206
+ export async function syncPostgresSecret(execa, { clusterName, fqdn, adminUser, adminPassword }) {
207
+ const namespaces = ["foundation"];
208
+ for (const ns of namespaces) {
209
+ // Ensure namespace exists
210
+ await execa("kubectl", [
211
+ "--context", clusterName,
212
+ "create", "namespace", ns, "--dry-run=client", "-o", "yaml",
213
+ ], { reject: false, timeout: 10000 }).then(({ stdout }) =>
214
+ execa("kubectl", ["--context", clusterName, "apply", "-f", "-"], { input: stdout, timeout: 10000, reject: false })
215
+ );
216
+
217
+ // Create or update the postgres secret
218
+ const { exitCode } = await execa("kubectl", [
219
+ "--context", clusterName, "-n", ns,
220
+ "create", "secret", "generic", "postgres",
221
+ "--from-literal=host=" + fqdn,
222
+ "--from-literal=superUserPassword=" + adminPassword,
223
+ "--from-literal=user=" + adminUser,
224
+ "--from-literal=password=" + adminPassword,
225
+ "--dry-run=client", "-o", "yaml",
226
+ ], { timeout: 10000 }).then(({ stdout }) =>
227
+ execa("kubectl", [
228
+ "--context", clusterName, "-n", ns, "apply", "-f", "-",
229
+ ], { input: stdout, timeout: 10000, reject: false })
230
+ );
231
+
232
+ if (exitCode === 0) {
233
+ console.log(OK(` ✓ Secret "postgres" synced to ${ns} namespace`));
234
+ } else {
235
+ console.log(WARN(` ⚠ Could not sync postgres secret to ${ns}`));
236
+ }
237
+ }
238
+ }
239
+
240
+ // ── Postgres databases reconciliation ─────────────────────────────────────────
241
+
242
+ export async function reconcilePgDatabases(ctx) {
243
+ const { execa, clusterName } = ctx;
244
+ const kubectl = (args, opts = {}) =>
245
+ execa("kubectl", ["--context", clusterName, ...args], { timeout: 60000, reject: false, ...opts });
246
+
247
+ const pgServer = pgServerName(clusterName);
248
+ const pgHost = `${pgServer}.postgres.database.azure.com`;
249
+
250
+ // Read password from the postgres secret
251
+ const { stdout: pwB64 } = await kubectl([
252
+ "get", "secret", "postgres", "-n", "foundation",
253
+ "-o", "jsonpath={.data.password}",
254
+ ]);
255
+ if (!pwB64) {
256
+ console.log(WARN(" ⚠ No postgres secret found — skipping DB setup"));
257
+ return;
258
+ }
259
+ const pgPass = Buffer.from(pwB64, "base64").toString();
260
+
261
+ // Run a psql job to create all databases and roles
262
+ const sqlLines = PG_SERVICE_DBS.map(db => [
263
+ `SELECT 'CREATE DATABASE ${db}' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${db}');`,
264
+ `DO \\$\\$ BEGIN IF NOT EXISTS (SELECT FROM pg_database WHERE datname = '${db}') THEN EXECUTE 'CREATE DATABASE ${db}'; END IF; IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${db}') THEN CREATE ROLE ${db} LOGIN PASSWORD '${pgPass}'; END IF; END \\$\\$;`,
265
+ `GRANT ALL ON DATABASE ${db} TO ${db};`,
266
+ ]).flat();
267
+
268
+ const script = sqlLines.map(s => `psql -c "${s}"`).join(" && ") + " && echo DONE";
269
+
270
+ const jobManifest = JSON.stringify({
271
+ apiVersion: "batch/v1", kind: "Job",
272
+ metadata: { name: "fops-pg-setup", namespace: "foundation" },
273
+ spec: {
274
+ backoffLimit: 2, ttlSecondsAfterFinished: 60,
275
+ template: {
276
+ spec: {
277
+ restartPolicy: "Never",
278
+ containers: [{
279
+ name: "psql",
280
+ image: "postgres:16-alpine",
281
+ env: [
282
+ { name: "PGHOST", value: pgHost },
283
+ { name: "PGUSER", value: "foundation" },
284
+ { name: "PGDATABASE", value: "postgres" },
285
+ { name: "PGPASSWORD", value: pgPass },
286
+ { name: "PGSSLMODE", value: "require" },
287
+ ],
288
+ command: ["sh", "-c", script],
289
+ }],
290
+ },
291
+ },
292
+ },
293
+ });
294
+
295
+ // Delete old job if it exists
296
+ await kubectl(["delete", "job", "fops-pg-setup", "-n", "foundation", "--ignore-not-found"]);
297
+ await new Promise(r => setTimeout(r, 2000));
298
+
299
+ const { exitCode, stderr } = await kubectl(["apply", "-f", "-"], { input: jobManifest });
300
+ if (exitCode !== 0) {
301
+ console.log(WARN(` ⚠ pg-setup job failed: ${(stderr || "").split("\n")[0]}`));
302
+ return;
303
+ }
304
+
305
+ // Wait for job to complete (max 60s)
306
+ const { exitCode: waitCode } = await execa("kubectl", [
307
+ "--context", clusterName,
308
+ "wait", "--for=condition=complete", "job/fops-pg-setup",
309
+ "-n", "foundation", "--timeout=60s",
310
+ ], { timeout: 70000, reject: false });
311
+
312
+ if (waitCode === 0) {
313
+ console.log(OK(` ✓ Postgres databases ready (${PG_SERVICE_DBS.join(", ")})`));
314
+ } else {
315
+ const { stdout: logs } = await kubectl([
316
+ "logs", "job/fops-pg-setup", "-n", "foundation", "--tail=5",
317
+ ]);
318
+ if (logs?.includes("DONE")) {
319
+ console.log(OK(` ✓ Postgres databases ready (${PG_SERVICE_DBS.join(", ")})`));
320
+ } else {
321
+ console.log(WARN(" ⚠ pg-setup job didn't complete — check: kubectl logs job/fops-pg-setup -n foundation"));
322
+ }
323
+ }
324
+
325
+ await kubectl(["delete", "job", "fops-pg-setup", "-n", "foundation", "--ignore-not-found"]);
326
+ }
327
+
328
+ // ── Postgres read replicas ────────────────────────────────────────────────────
329
+
330
+ export async function aksPostgresReplicaCreate(opts = {}) {
331
+ const { lazyExeca, ensureAzCli, ensureAzAuth } = await import("./azure.js");
332
+ const execa = await lazyExeca();
333
+ const sub = opts.profile;
334
+ await ensureAzCli(execa);
335
+ await ensureAzAuth(execa, { subscription: sub });
336
+
337
+ const { clusterName, resourceGroup: rg } = requireCluster(opts.clusterName);
338
+ const sourceServer = pgServerName(clusterName);
339
+ const targetRegion = opts.region || PG_REPLICA_REGIONS[opts.sourceRegion] || "westeurope";
340
+ const replicaName = opts.replicaName || `${sourceServer}-replica-${targetRegion.replace(/[^a-z]/g, "")}`;
341
+
342
+ banner("Postgres Read Replica");
343
+ kvLine("Source", sourceServer);
344
+ kvLine("Replica", replicaName);
345
+ kvLine("Region", targetRegion);
346
+
347
+ // Get source server resource ID
348
+ const { stdout: sourceJson, exitCode: sourceExists } = await execa("az", [
349
+ "postgres", "flexible-server", "show",
350
+ "--name", sourceServer, "--resource-group", rg,
351
+ "--output", "json", ...subArgs(sub),
352
+ ], { reject: false, timeout: 30000 });
353
+
354
+ if (sourceExists !== 0) {
355
+ console.error(ERR(`\n ✗ Source server "${sourceServer}" not found`));
356
+ process.exit(1);
357
+ }
358
+
359
+ const source = JSON.parse(sourceJson);
360
+ const sourceId = source.id;
361
+
362
+ // Check if replica already exists
363
+ const { exitCode: replicaExists } = await execa("az", [
364
+ "postgres", "flexible-server", "show",
365
+ "--name", replicaName, "--resource-group", rg,
366
+ "--output", "none", ...subArgs(sub),
367
+ ], { reject: false, timeout: 30000 });
368
+
369
+ if (replicaExists === 0) {
370
+ console.log(OK(`\n ✓ Replica "${replicaName}" already exists`));
371
+ return;
372
+ }
373
+
374
+ hint("Creating read replica (this takes 10-15 minutes)…\n");
375
+
376
+ const { exitCode, stderr } = await execa("az", [
377
+ "postgres", "flexible-server", "replica", "create",
378
+ "--replica-name", replicaName,
379
+ "--resource-group", rg,
380
+ "--source-server", sourceId,
381
+ "--location", targetRegion,
382
+ "--output", "json", ...subArgs(sub),
383
+ ], { timeout: 900000, reject: false });
384
+
385
+ if (exitCode !== 0) {
386
+ const errMsg = (stderr || "").split("\n").find(l => l.includes("ERROR")) || stderr;
387
+ console.error(ERR(`\n ✗ Replica creation failed: ${errMsg}`));
388
+ process.exit(1);
389
+ }
390
+
391
+ // Get replica FQDN
392
+ const { stdout: replicaJson } = await execa("az", [
393
+ "postgres", "flexible-server", "show",
394
+ "--name", replicaName, "--resource-group", rg,
395
+ "--output", "json", ...subArgs(sub),
396
+ ], { timeout: 30000 });
397
+ const replica = JSON.parse(replicaJson);
398
+
399
+ console.log(OK(`\n ✓ Read replica created`));
400
+ kvLine("FQDN", replica.fullyQualifiedDomainName);
401
+ kvLine("Region", replica.location);
402
+ kvLine("State", replica.state);
403
+
404
+ // Save replica info to state
405
+ const cl = readClusterState(clusterName);
406
+ const replicas = cl.postgres?.replicas || [];
407
+ replicas.push({
408
+ name: replicaName,
409
+ fqdn: replica.fullyQualifiedDomainName,
410
+ region: targetRegion,
411
+ createdAt: new Date().toISOString(),
412
+ });
413
+ writeClusterState(clusterName, {
414
+ postgres: { ...cl.postgres, replicas },
415
+ });
416
+
417
+ hint(`\n Connect: psql "host=${replica.fullyQualifiedDomainName} user=foundation sslmode=require"`);
418
+ hint(" Note: Replica is read-only. Use for read scaling or DR.\n");
419
+ }
420
+
421
+ export async function aksPostgresReplicaList(opts = {}) {
422
+ const { lazyExeca, ensureAzCli, ensureAzAuth, LABEL } = await import("./azure.js");
423
+ const execa = await lazyExeca();
424
+ const sub = opts.profile;
425
+ await ensureAzCli(execa);
426
+ await ensureAzAuth(execa, { subscription: sub });
427
+
428
+ const { clusterName, resourceGroup: rg } = requireCluster(opts.clusterName);
429
+ const sourceServer = pgServerName(clusterName);
430
+
431
+ banner(`Postgres Replicas: ${sourceServer}`);
432
+
433
+ const { stdout: replicasJson } = await execa("az", [
434
+ "postgres", "flexible-server", "replica", "list",
435
+ "--name", sourceServer, "--resource-group", rg,
436
+ "--output", "json", ...subArgs(sub),
437
+ ], { timeout: 60000 });
438
+
439
+ const replicas = JSON.parse(replicasJson || "[]");
440
+
441
+ if (replicas.length === 0) {
442
+ console.log(DIM("\n No replicas found.\n"));
443
+ hint(`Create one: fops azure aks postgres replica create --region westeurope`);
444
+ return;
445
+ }
446
+
447
+ console.log("");
448
+ for (const r of replicas) {
449
+ const state = r.state === "Ready" ? OK(r.state) : WARN(r.state);
450
+ console.log(` ${LABEL(r.name)}`);
451
+ kvLine(" FQDN", DIM(r.fullyQualifiedDomainName), { pad: 10 });
452
+ kvLine(" Region", DIM(r.location), { pad: 10 });
453
+ kvLine(" State", state, { pad: 10 });
454
+ kvLine(" Role", DIM(r.replicationRole || "Replica"), { pad: 10 });
455
+ console.log("");
456
+ }
457
+ }
458
+
459
+ export async function aksPostgresReplicaPromote(opts = {}) {
460
+ const { lazyExeca, ensureAzCli, ensureAzAuth } = await import("./azure.js");
461
+ const execa = await lazyExeca();
462
+ const sub = opts.profile;
463
+ await ensureAzCli(execa);
464
+ await ensureAzAuth(execa, { subscription: sub });
465
+
466
+ const { resourceGroup: rg } = requireCluster(opts.clusterName);
467
+ const replicaName = opts.replicaName;
468
+
469
+ if (!replicaName) {
470
+ console.error(ERR("\n --replica-name is required"));
471
+ process.exit(1);
472
+ }
473
+
474
+ banner("Promote Replica");
475
+ kvLine("Replica", replicaName);
476
+
477
+ console.log(WARN("\n ⚠ WARNING: Promoting a replica breaks replication permanently."));
478
+ console.log(WARN(" The replica becomes a standalone server."));
479
+ console.log(WARN(" This is typically used for disaster recovery failover.\n"));
480
+
481
+ if (!opts.yes) {
482
+ const readline = await import("node:readline");
483
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
484
+ const answer = await new Promise(resolve => {
485
+ rl.question(` Promote "${replicaName}" to standalone? [y/N] `, resolve);
486
+ });
487
+ rl.close();
488
+ if (answer.toLowerCase() !== "y") {
489
+ console.log(DIM(" Cancelled.\n"));
490
+ return;
491
+ }
492
+ }
493
+
494
+ hint("Promoting replica (this takes a few minutes)…\n");
495
+
496
+ const { exitCode, stderr } = await execa("az", [
497
+ "postgres", "flexible-server", "replica", "stop-replication",
498
+ "--name", replicaName, "--resource-group", rg,
499
+ "--yes", "--output", "none", ...subArgs(sub),
500
+ ], { timeout: 600000, reject: false });
501
+
502
+ if (exitCode !== 0) {
503
+ console.error(ERR(`\n ✗ Promotion failed: ${stderr}`));
504
+ process.exit(1);
505
+ }
506
+
507
+ console.log(OK(`\n ✓ Replica "${replicaName}" promoted to standalone server`));
508
+ hint(" Update your application connection strings to point to the new primary.\n");
509
+ }
510
+
511
+ export async function aksPostgresReplicaDelete(opts = {}) {
512
+ const { lazyExeca, ensureAzCli, ensureAzAuth } = await import("./azure.js");
513
+ const execa = await lazyExeca();
514
+ const sub = opts.profile;
515
+ await ensureAzCli(execa);
516
+ await ensureAzAuth(execa, { subscription: sub });
517
+
518
+ const { clusterName, resourceGroup: rg } = requireCluster(opts.clusterName);
519
+ const replicaName = opts.replicaName;
520
+
521
+ if (!replicaName) {
522
+ console.error(ERR("\n --replica-name is required"));
523
+ process.exit(1);
524
+ }
525
+
526
+ banner("Delete Replica");
527
+ kvLine("Replica", replicaName);
528
+
529
+ if (!opts.yes) {
530
+ const readline = await import("node:readline");
531
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
532
+ const answer = await new Promise(resolve => {
533
+ rl.question(` Delete replica "${replicaName}"? [y/N] `, resolve);
534
+ });
535
+ rl.close();
536
+ if (answer.toLowerCase() !== "y") {
537
+ console.log(DIM(" Cancelled.\n"));
538
+ return;
539
+ }
540
+ }
541
+
542
+ hint("Deleting replica…\n");
543
+
544
+ const { exitCode, stderr } = await execa("az", [
545
+ "postgres", "flexible-server", "delete",
546
+ "--name", replicaName, "--resource-group", rg,
547
+ "--yes", "--output", "none", ...subArgs(sub),
548
+ ], { timeout: 300000, reject: false });
549
+
550
+ if (exitCode !== 0) {
551
+ console.error(ERR(`\n ✗ Delete failed: ${stderr}`));
552
+ process.exit(1);
553
+ }
554
+
555
+ // Remove from state
556
+ const cl = readClusterState(clusterName);
557
+ if (cl.postgres?.replicas) {
558
+ cl.postgres.replicas = cl.postgres.replicas.filter(r => r.name !== replicaName);
559
+ writeClusterState(clusterName, { postgres: cl.postgres });
560
+ }
561
+
562
+ console.log(OK(`\n ✓ Replica "${replicaName}" deleted\n`));
563
+ }
564
+
565
+ // ── Event Hubs (managed Kafka) ─────────────────────────────────────────────────
566
+
567
+ export async function reconcileEventHubs(ctx) {
568
+ if (!ctx.opts?.managedKafka) return;
569
+
570
+ const { execa, clusterName, rg, sub, cluster } = ctx;
571
+ const location = cluster.location || DEFAULTS.location;
572
+ const nsName = ehNamespaceName(clusterName);
573
+
574
+ // 1. Check if namespace already exists
575
+ const { exitCode: ehExists, stdout: ehJson } = await execa("az", [
576
+ "eventhubs", "namespace", "show",
577
+ "--name", nsName, "--resource-group", rg,
578
+ "--output", "json", ...subArgs(sub),
579
+ ], { reject: false, timeout: 30000 });
580
+
581
+ let endpoint = "";
582
+ if (ehExists === 0 && ehJson) {
583
+ try {
584
+ const ns = JSON.parse(ehJson);
585
+ endpoint = ns.serviceBusEndpoint || "";
586
+ console.log(OK(` ✓ Event Hubs namespace "${nsName}" exists`));
587
+ } catch {}
588
+ } else {
589
+ // Create namespace
590
+ console.log(chalk.yellow(` ↻ Creating Event Hubs namespace "${nsName}"…`));
591
+ try {
592
+ const { stdout: createdJson } = await execa("az", [
593
+ "eventhubs", "namespace", "create",
594
+ "--name", nsName,
595
+ "--resource-group", rg,
596
+ "--location", location,
597
+ "--sku", EH_DEFAULTS.sku,
598
+ "--capacity", String(EH_DEFAULTS.capacity),
599
+ "--enable-kafka", "true",
600
+ "--output", "json", ...subArgs(sub),
601
+ ], { timeout: 300000 });
602
+ const ns = JSON.parse(createdJson);
603
+ endpoint = ns.serviceBusEndpoint || "";
604
+ console.log(OK(` ✓ Event Hubs namespace created`));
605
+ } catch (err) {
606
+ const msg = (err.stderr || err.message || "").split("\n")[0];
607
+ console.log(WARN(` ⚠ Event Hubs creation failed: ${msg}`));
608
+ return;
609
+ }
610
+ }
611
+
612
+ // 2. Disable public network access
613
+ hint("Disabling public access…");
614
+ await execa("az", [
615
+ "eventhubs", "namespace", "update",
616
+ "--name", nsName, "--resource-group", rg,
617
+ "--public-network-access", "Disabled",
618
+ "--output", "none", ...subArgs(sub),
619
+ ], { reject: false, timeout: 120000 });
620
+
621
+ // 3. Create private endpoint if not exists
622
+ const nodeRg = cluster.nodeResourceGroup;
623
+ const vnetName = await findAksVnet(execa, { nodeRg, sub });
624
+ if (!vnetName) {
625
+ console.log(WARN(" ⚠ Could not find AKS VNet — skipping private endpoint"));
626
+ return;
627
+ }
628
+
629
+ const peName = `${nsName}-pe`;
630
+ const { exitCode: peExists } = await execa("az", [
631
+ "network", "private-endpoint", "show",
632
+ "--name", peName, "--resource-group", rg,
633
+ "--output", "none", ...subArgs(sub),
634
+ ], { reject: false, timeout: 30000 });
635
+
636
+ if (peExists !== 0) {
637
+ hint("Creating private endpoint…");
638
+
639
+ // Get Event Hubs resource ID
640
+ const { stdout: ehIdJson } = await execa("az", [
641
+ "eventhubs", "namespace", "show",
642
+ "--name", nsName, "--resource-group", rg,
643
+ "--query", "id", "-o", "tsv", ...subArgs(sub),
644
+ ], { timeout: 15000 });
645
+ const ehId = ehIdJson.trim();
646
+
647
+ // Create subnet for private endpoints if not exists
648
+ const peSubnetName = "private-endpoints";
649
+ const { exitCode: subnetExists } = await execa("az", [
650
+ "network", "vnet", "subnet", "show",
651
+ "--resource-group", nodeRg, "--vnet-name", vnetName, "--name", peSubnetName,
652
+ "--output", "none", ...subArgs(sub),
653
+ ], { reject: false, timeout: 15000 });
654
+
655
+ if (subnetExists !== 0) {
656
+ await execa("az", [
657
+ "network", "vnet", "subnet", "create",
658
+ "--resource-group", nodeRg, "--vnet-name", vnetName, "--name", peSubnetName,
659
+ "--address-prefixes", "10.225.0.0/24",
660
+ "--disable-private-endpoint-network-policies", "true",
661
+ "--output", "none", ...subArgs(sub),
662
+ ], { timeout: 60000 });
663
+ }
664
+
665
+ // Create private endpoint
666
+ await execa("az", [
667
+ "network", "private-endpoint", "create",
668
+ "--name", peName,
669
+ "--resource-group", rg,
670
+ "--vnet-name", vnetName,
671
+ "--subnet", peSubnetName,
672
+ "--private-connection-resource-id", ehId,
673
+ "--group-id", "namespace",
674
+ "--connection-name", `${nsName}-connection`,
675
+ "--output", "none", ...subArgs(sub),
676
+ ], { timeout: 120000 });
677
+ console.log(OK(" ✓ Private endpoint created"));
678
+
679
+ // 4. Create private DNS zone and link
680
+ const dnsZoneName = "privatelink.servicebus.windows.net";
681
+ const { exitCode: zoneExists } = await execa("az", [
682
+ "network", "private-dns", "zone", "show",
683
+ "--resource-group", rg, "--name", dnsZoneName,
684
+ "--output", "none", ...subArgs(sub),
685
+ ], { reject: false, timeout: 15000 });
686
+
687
+ if (zoneExists !== 0) {
688
+ await execa("az", [
689
+ "network", "private-dns", "zone", "create",
690
+ "--resource-group", rg, "--name", dnsZoneName,
691
+ "--output", "none", ...subArgs(sub),
692
+ ], { timeout: 60000 });
693
+ }
694
+
695
+ // Link DNS zone to VNet
696
+ const linkName = `${clusterName}-eh-link`;
697
+ const { exitCode: linkExists } = await execa("az", [
698
+ "network", "private-dns", "link", "vnet", "show",
699
+ "--resource-group", rg, "--zone-name", dnsZoneName, "--name", linkName,
700
+ "--output", "none", ...subArgs(sub),
701
+ ], { reject: false, timeout: 15000 });
702
+
703
+ if (linkExists !== 0) {
704
+ const { stdout: vnetIdJson } = await execa("az", [
705
+ "network", "vnet", "show",
706
+ "--resource-group", nodeRg, "--name", vnetName,
707
+ "--query", "id", "-o", "tsv", ...subArgs(sub),
708
+ ], { timeout: 15000 });
709
+ await execa("az", [
710
+ "network", "private-dns", "link", "vnet", "create",
711
+ "--resource-group", rg, "--zone-name", dnsZoneName, "--name", linkName,
712
+ "--virtual-network", vnetIdJson.trim(),
713
+ "--registration-enabled", "false",
714
+ "--output", "none", ...subArgs(sub),
715
+ ], { timeout: 60000 });
716
+ }
717
+
718
+ // Create DNS record for the private endpoint
719
+ const { stdout: peIpJson } = await execa("az", [
720
+ "network", "private-endpoint", "show",
721
+ "--name", peName, "--resource-group", rg,
722
+ "--query", "customDnsConfigs[0].ipAddresses[0]", "-o", "tsv", ...subArgs(sub),
723
+ ], { reject: false, timeout: 15000 });
724
+ const peIp = (peIpJson || "").trim();
725
+
726
+ if (peIp) {
727
+ await execa("az", [
728
+ "network", "private-dns", "record-set", "a", "add-record",
729
+ "--resource-group", rg, "--zone-name", dnsZoneName,
730
+ "--record-set-name", nsName, "--ipv4-address", peIp,
731
+ "--output", "none", ...subArgs(sub),
732
+ ], { reject: false, timeout: 30000 });
733
+ console.log(OK(" ✓ Private DNS configured"));
734
+ }
735
+ } else {
736
+ console.log(OK(" ✓ Private endpoint already exists"));
737
+ }
738
+
739
+ // 5. Get connection string and create K8s secret
740
+ const { stdout: connStrJson } = await execa("az", [
741
+ "eventhubs", "namespace", "authorization-rule", "keys", "list",
742
+ "--resource-group", rg, "--namespace-name", nsName, "--name", "RootManageSharedAccessKey",
743
+ "--query", "primaryConnectionString", "-o", "tsv", ...subArgs(sub),
744
+ ], { reject: false, timeout: 15000 });
745
+ const connStr = (connStrJson || "").trim();
746
+
747
+ if (connStr) {
748
+ const kafkaBootstrap = `${nsName}.servicebus.windows.net:9093`;
749
+ const kubectl = (args, opts = {}) =>
750
+ execa("kubectl", ["--context", clusterName, ...args], { timeout: 30000, reject: false, ...opts });
751
+
752
+ await kubectl([
753
+ "-n", "foundation", "create", "secret", "generic", "kafka-eventhubs",
754
+ "--from-literal=bootstrap-servers=" + kafkaBootstrap,
755
+ "--from-literal=connection-string=" + connStr,
756
+ "--from-literal=sasl-username=$ConnectionString",
757
+ "--from-literal=sasl-password=" + connStr,
758
+ "--dry-run=client", "-o", "yaml",
759
+ ]).then(({ stdout }) =>
760
+ kubectl(["-n", "foundation", "apply", "-f", "-"], { input: stdout })
761
+ );
762
+ console.log(OK(` ✓ Kafka connection secret created (${kafkaBootstrap})`));
763
+
764
+ writeClusterState(clusterName, {
765
+ eventHubs: { namespace: nsName, bootstrap: kafkaBootstrap },
766
+ });
767
+ }
768
+ }