@meshxdata/fops 0.1.44 → 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 (36) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/package.json +1 -1
  3. package/src/commands/lifecycle.js +101 -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-ops.js +29 -0
  21. package/src/plugins/bundled/fops-plugin-azure/lib/azure-results.js +78 -0
  22. package/src/plugins/bundled/fops-plugin-azure/lib/azure.js +1 -1
  23. package/src/plugins/bundled/fops-plugin-azure/lib/commands/infra-cmds.js +758 -0
  24. package/src/plugins/bundled/fops-plugin-azure/lib/commands/registry-cmds.js +250 -0
  25. package/src/plugins/bundled/fops-plugin-azure/lib/commands/test-cmds.js +52 -1
  26. package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +10 -0
  27. package/src/plugins/bundled/fops-plugin-foundation/lib/apply.js +3 -2
  28. package/src/plugins/bundled/fops-plugin-foundation/lib/helpers.js +21 -0
  29. package/src/plugins/bundled/fops-plugin-foundation/lib/tools-read.js +3 -5
  30. package/src/ui/tui/App.js +13 -13
  31. package/src/web/dist/assets/index-NXC8Hvnp.css +1 -0
  32. package/src/web/dist/assets/index-QH1N4ejK.js +112 -0
  33. package/src/web/dist/index.html +2 -2
  34. package/src/web/server.js +4 -4
  35. package/src/web/dist/assets/index-BphVaAUd.css +0 -1
  36. package/src/web/dist/assets/index-CSckLzuG.js +0 -129
@@ -0,0 +1,393 @@
1
+ /**
2
+ * azure-aks-ingress.js - Ingress IP, DNS, and frontend auth
3
+ *
4
+ * Depends on: azure-aks-naming.js, azure-aks-state.js, cloudflare.js
5
+ */
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import {
11
+ OK, WARN, DIM,
12
+ hint, resolveUniqueDomain,
13
+ } from "./azure.js";
14
+ import { writeClusterState } from "./azure-aks-state.js";
15
+ import { syncDns, resolveCfToken } from "./cloudflare.js";
16
+
17
+ // ── Domain resolution ─────────────────────────────────────────────────────────
18
+
19
+ export function clusterDomain(clusterName) {
20
+ return resolveUniqueDomain(clusterName, "aks");
21
+ }
22
+
23
+ // ── Ingress VirtualServices ───────────────────────────────────────────────────
24
+
25
+ export const INGRESS_VIRTUALSERVICES = [
26
+ { name: "frontend", namespace: "foundation", gateway: "foundation-gateway", hostPrefix: "" },
27
+ { name: "foundation-api", namespace: "foundation", gateway: "foundation-gateway", hostPrefix: "api." },
28
+ ];
29
+
30
+ // ── CLI module resolver helper ────────────────────────────────────────────────
31
+
32
+ function resolveCliSrc(relPath) {
33
+ const thisDir = path.dirname(fileURLToPath(import.meta.url));
34
+ const fromSource = path.resolve(thisDir, "../../../..", relPath);
35
+ if (fs.existsSync(fromSource)) return new URL(`file://${fromSource}`).href;
36
+ const fopsBin = process.argv[1];
37
+ if (fopsBin) {
38
+ try {
39
+ const cliRoot = path.dirname(fs.realpathSync(fopsBin));
40
+ const fromCli = path.resolve(cliRoot, "src", relPath);
41
+ if (fs.existsSync(fromCli)) return new URL(`file://${fromCli}`).href;
42
+ } catch { /* fall through */ }
43
+ }
44
+ return "../../../../" + relPath;
45
+ }
46
+
47
+ // ── Reconcile Ingress IP ──────────────────────────────────────────────────────
48
+
49
+ export async function reconcileIngressIp(ctx) {
50
+ const { execa, clusterName } = ctx;
51
+ const kubectl = (args, opts = {}) =>
52
+ execa("kubectl", ["--context", clusterName, ...args], { timeout: 15000, reject: false, ...opts });
53
+
54
+ const domain = clusterDomain(clusterName);
55
+ writeClusterState(clusterName, { domain });
56
+
57
+ // ── 1. Ensure LB has an external IP ──
58
+
59
+ const { exitCode, stdout } = await kubectl([
60
+ "get", "svc", "istio-ingressgateway", "-n", "istio-system",
61
+ "-o", "json",
62
+ ]);
63
+ if (exitCode !== 0 || !stdout) return;
64
+
65
+ const svc = JSON.parse(stdout);
66
+ const annotations = svc.metadata?.annotations || {};
67
+ const pipName = annotations["service.beta.kubernetes.io/azure-pip-name"] || "";
68
+ let externalIp = svc.status?.loadBalancer?.ingress?.[0]?.ip;
69
+
70
+ // Remove stale annotations from other clouds / restrictive source ranges
71
+ const staleKeys = Object.keys(annotations).filter(k =>
72
+ k.startsWith("service.beta.kubernetes.io/aws-") ||
73
+ k === "service.beta.kubernetes.io/load-balancer-source-ranges" ||
74
+ k === "service.beta.kubernetes.io/azure-allowed-service-tags"
75
+ );
76
+ if (staleKeys.length > 0) {
77
+ hint(`Removing ${staleKeys.length} stale/restrictive annotation(s) from istio-ingressgateway…`);
78
+ await kubectl([
79
+ "annotate", "svc", "istio-ingressgateway", "-n", "istio-system",
80
+ ...staleKeys.map(k => `${k}-`),
81
+ ]);
82
+ console.log(OK(` ✓ Cleaned ${staleKeys.length} stale annotation(s)`));
83
+ }
84
+
85
+ if (!externalIp && pipName) {
86
+ hint(`Removing stale PIP annotation "${pipName}" from istio-ingressgateway…`);
87
+ await kubectl([
88
+ "annotate", "svc", "istio-ingressgateway", "-n", "istio-system",
89
+ "service.beta.kubernetes.io/azure-pip-name-",
90
+ "service.beta.kubernetes.io/azure-pip-name-IPv6-",
91
+ ]);
92
+ for (let i = 0; i < 6; i++) {
93
+ await new Promise(r => setTimeout(r, 10000));
94
+ const { stdout: refreshed } = await kubectl([
95
+ "get", "svc", "istio-ingressgateway", "-n", "istio-system",
96
+ "-o", "jsonpath={.status.loadBalancer.ingress[0].ip}",
97
+ ]);
98
+ if (refreshed?.trim()) { externalIp = refreshed.trim(); break; }
99
+ }
100
+ }
101
+
102
+ if (externalIp) {
103
+ console.log(OK(` ✓ Ingress gateway IP: ${externalIp}`));
104
+ } else {
105
+ console.log(WARN(" ⚠ Ingress gateway has no external IP yet"));
106
+ hint(` kubectl describe svc istio-ingressgateway -n istio-system --context ${clusterName}`);
107
+ }
108
+
109
+ // ── 2. Self-signed TLS cert for Istio ingress ──
110
+
111
+ const TLS_SECRET = "istio-ingressgateway-certs";
112
+ const { exitCode: tlsCheck } = await kubectl([
113
+ "get", "secret", TLS_SECRET, "-n", "istio-system",
114
+ ]);
115
+ if (tlsCheck !== 0) {
116
+ hint("Creating self-signed TLS certificate for ingress…");
117
+ const { exitCode: certCode } = await execa("openssl", [
118
+ "req", "-x509", "-newkey", "rsa:2048",
119
+ "-keyout", "/tmp/fops-aks-tls.key", "-out", "/tmp/fops-aks-tls.crt",
120
+ "-days", "3650", "-nodes",
121
+ "-subj", `/CN=*.meshx.app`,
122
+ "-addext", `subjectAltName=DNS:*.meshx.app,DNS:meshx.app,DNS:*.${domain}`,
123
+ ], { timeout: 15000, reject: false });
124
+ if (certCode === 0) {
125
+ await execa("kubectl", [
126
+ "--context", clusterName,
127
+ "create", "secret", "tls", TLS_SECRET, "-n", "istio-system",
128
+ "--cert=/tmp/fops-aks-tls.crt", "--key=/tmp/fops-aks-tls.key",
129
+ ], { timeout: 15000 });
130
+ console.log(OK(" ✓ Self-signed TLS secret created"));
131
+ } else {
132
+ console.log(WARN(" ⚠ Could not generate TLS cert — HTTPS on ingress may not work"));
133
+ }
134
+ }
135
+
136
+ // ── 3. Reconcile Istio Gateways ──
137
+
138
+ const allHosts = INGRESS_VIRTUALSERVICES.map(vs => `${vs.hostPrefix}${domain}`);
139
+ const uniqueHosts = [...new Set(allHosts)];
140
+
141
+ const gwYaml = `apiVersion: networking.istio.io/v1beta1
142
+ kind: Gateway
143
+ metadata:
144
+ name: foundation-gateway
145
+ namespace: foundation
146
+ spec:
147
+ selector:
148
+ istio: ingressgateway
149
+ servers:
150
+ - port:
151
+ number: 80
152
+ name: http
153
+ protocol: HTTP
154
+ hosts:
155
+ ${uniqueHosts.map(h => ` - "${h}"`).join("\n")}
156
+ - port:
157
+ number: 443
158
+ name: https
159
+ protocol: HTTPS
160
+ tls:
161
+ mode: SIMPLE
162
+ credentialName: ${TLS_SECRET}
163
+ hosts:
164
+ ${uniqueHosts.map(h => ` - "${h}"`).join("\n")}`;
165
+
166
+ await kubectl(["apply", "-f", "-"], { input: gwYaml });
167
+ await kubectl(["delete", "gateway", "foundation-gateway", "-n", "velora", "--ignore-not-found"]);
168
+ console.log(OK(` ✓ Istio Gateway → ${domain} (HTTP + HTTPS)`));
169
+
170
+ // ── 4. Reconcile VirtualService hosts + gateway refs ──
171
+
172
+ let patched = 0;
173
+ for (const vs of INGRESS_VIRTUALSERVICES) {
174
+ const correctHost = `${vs.hostPrefix}${domain}`;
175
+ const { stdout: vsJson } = await kubectl([
176
+ "get", "virtualservice", vs.name, "-n", vs.namespace, "-o", "json",
177
+ ]);
178
+ if (!vsJson) continue;
179
+
180
+ const vsObj = JSON.parse(vsJson);
181
+ const hosts = vsObj.spec?.hosts || [];
182
+ const gateways = vsObj.spec?.gateways || [];
183
+ const needsHostFix = hosts.length !== 1 || hosts[0] !== correctHost;
184
+ const needsGwFix = gateways.some(g => g.includes("velora/"));
185
+
186
+ if (!needsHostFix && !needsGwFix) continue;
187
+
188
+ const patches = [];
189
+ if (needsHostFix) patches.push({ op: "replace", path: "/spec/hosts", value: [correctHost] });
190
+ if (needsGwFix) patches.push({ op: "replace", path: "/spec/gateways", value: ["foundation-gateway"] });
191
+
192
+ await kubectl([
193
+ "patch", "virtualservice", vs.name, "-n", vs.namespace,
194
+ "--type", "json", "-p", JSON.stringify(patches),
195
+ ]);
196
+ patched++;
197
+ }
198
+
199
+ if (patched > 0) {
200
+ console.log(OK(` ✓ Patched ${patched} VirtualService(s) → *.${domain}`));
201
+ } else {
202
+ console.log(OK(` ✓ VirtualService hosts correct (*.${domain})`));
203
+ }
204
+
205
+ // ── 5. DNS records ──
206
+ if (externalIp) {
207
+ const cfToken = resolveCfToken();
208
+ if (cfToken) {
209
+ const dnsNames = [...new Set(INGRESS_VIRTUALSERVICES.map(vs => `${vs.hostPrefix}${domain}`))];
210
+ for (const host of dnsNames) {
211
+ await syncDns(cfToken, `https://${host}`, externalIp, { proxied: true });
212
+ }
213
+ } else {
214
+ hint(` No CLOUDFLARE_API_TOKEN — add DNS A records manually:`);
215
+ const dnsNames = [...new Set(INGRESS_VIRTUALSERVICES.map(vs => `${vs.hostPrefix}${domain}`))];
216
+ for (const host of dnsNames) {
217
+ hint(` ${host} → ${externalIp}`);
218
+ }
219
+ }
220
+ }
221
+
222
+ // Save the ingress info to state
223
+ if (externalIp) {
224
+ writeClusterState(clusterName, { ingressIp: externalIp, domain });
225
+ }
226
+ }
227
+
228
+ // ── Frontend Auth0 reconciliation ─────────────────────────────────────────────
229
+
230
+ export async function reconcileFrontendAuth(ctx) {
231
+ const { execa, clusterName } = ctx;
232
+ const kubectl = (args, opts = {}) =>
233
+ execa("kubectl", ["--context", clusterName, ...args], { timeout: 30000, reject: false, ...opts });
234
+
235
+ const domain = clusterDomain(clusterName);
236
+ const baseUrl = `https://${domain}`;
237
+
238
+ // Read Auth0 creds from env or project .env
239
+ let auth0 = {
240
+ clientId: process.env.AUTH0_CLIENT_ID,
241
+ clientSecret: process.env.AUTH0_CLIENT_SECRET,
242
+ domain: process.env.AUTH0_DOMAIN,
243
+ audience: process.env.AUTH0_AUDIENCE,
244
+ issuerBaseUrl: process.env.AUTH0_ISSUER_BASE_URL,
245
+ secret: process.env.AUTH0_SECRET,
246
+ };
247
+
248
+ if (!auth0.clientId) {
249
+ try {
250
+ const { rootDir } = await import(resolveCliSrc("project.js"));
251
+ const root = rootDir();
252
+ if (root) {
253
+ const envRaw = fs.readFileSync(path.join(root, ".env"), "utf8");
254
+ for (const line of envRaw.split("\n")) {
255
+ const m = line.match(/^([A-Z0-9_]+)=(.+)$/);
256
+ if (!m) continue;
257
+ const [, k, v] = m;
258
+ const val = v.replace(/^["']|["']$/g, "");
259
+ if (k === "AUTH0_CLIENT_ID") auth0.clientId = val;
260
+ if (k === "AUTH0_CLIENT_SECRET") auth0.clientSecret = val;
261
+ if (k === "AUTH0_DOMAIN") auth0.domain = val;
262
+ if (k === "AUTH0_AUDIENCE") auth0.audience = val;
263
+ if (k === "AUTH0_ISSUER_BASE_URL") auth0.issuerBaseUrl = val;
264
+ if (k === "AUTH0_SECRET") auth0.secret = val;
265
+ }
266
+ }
267
+ } catch { /* no project root or .env */ }
268
+ }
269
+
270
+ if (!auth0.clientId || !auth0.domain) {
271
+ console.log(WARN(" ⚠ No Auth0 credentials found — skipping frontend auth setup"));
272
+ hint(" Set AUTH0_CLIENT_ID / AUTH0_DOMAIN in .env or environment");
273
+ return;
274
+ }
275
+
276
+ // ── 1. Create/update the frontend auth0 K8s secret ──
277
+
278
+ const secretArgs = [
279
+ "create", "secret", "generic", "foundation-frontend-auth0", "-n", "foundation",
280
+ "--from-literal", `APP_BASE_URL=${baseUrl}`,
281
+ "--from-literal", `AUTH0_CLIENT_ID=${auth0.clientId}`,
282
+ "--from-literal", `AUTH0_CLIENT_SECRET=${auth0.clientSecret || ""}`,
283
+ "--from-literal", `AUTH0_DOMAIN=${auth0.domain}`,
284
+ "--from-literal", `AUTH0_AUDIENCE=${auth0.audience || ""}`,
285
+ "--from-literal", `AUTH0_ISSUER_BASE_URL=${auth0.issuerBaseUrl || `https://${auth0.domain}/`}`,
286
+ "--from-literal", `AUTH0_SECRET=${auth0.secret || auth0.clientSecret || ""}`,
287
+ "--from-literal", `NEXT_PUBLIC_AUTH0_CLIENT_ID=${auth0.clientId}`,
288
+ "--from-literal", `NEXT_PUBLIC_AUTH0_DOMAIN=${auth0.domain}`,
289
+ "--dry-run=client", "-o", "yaml",
290
+ ];
291
+ const { stdout: secretYaml } = await kubectl(secretArgs);
292
+ if (secretYaml) {
293
+ await kubectl(["apply", "-f", "-"], { input: secretYaml });
294
+ console.log(OK(` ✓ Frontend Auth0 secret → APP_BASE_URL=${baseUrl}`));
295
+ }
296
+
297
+ // ── 2. Patch frontend deployment API_URL to point at own ingress ──
298
+
299
+ const { exitCode: feExists } = await kubectl([
300
+ "get", "deploy", "foundation-frontend", "-n", "foundation",
301
+ ]);
302
+ if (feExists === 0) {
303
+ const apiUrl = `${baseUrl}/api/`;
304
+ const { exitCode: envExit } = await execa("kubectl", [
305
+ "--context", clusterName,
306
+ "set", "env", "deploy/foundation-frontend", "-n", "foundation",
307
+ `API_URL=${apiUrl}`,
308
+ ], { timeout: 15000, reject: false });
309
+ if (envExit === 0) {
310
+ console.log(OK(` ✓ Frontend API_URL → ${apiUrl}`));
311
+ }
312
+ }
313
+
314
+ // ── 3. Ensure Auth0 wildcard callback URLs exist ──
315
+
316
+ if (!auth0.clientSecret) {
317
+ hint(" No AUTH0_CLIENT_SECRET — cannot verify Auth0 wildcard callbacks");
318
+ return;
319
+ }
320
+
321
+ try {
322
+ const tokenResp = await fetch(`https://${auth0.domain}/oauth/token`, {
323
+ method: "POST",
324
+ headers: { "Content-Type": "application/json" },
325
+ body: JSON.stringify({
326
+ client_id: auth0.clientId,
327
+ client_secret: auth0.clientSecret,
328
+ audience: `https://${auth0.domain}/api/v2/`,
329
+ grant_type: "client_credentials",
330
+ }),
331
+ signal: AbortSignal.timeout(10_000),
332
+ });
333
+ if (!tokenResp.ok) { hint(" Could not get Auth0 management token"); return; }
334
+ const mgmtToken = (await tokenResp.json()).access_token;
335
+ if (!mgmtToken) return;
336
+
337
+ const appResp = await fetch(
338
+ `https://${auth0.domain}/api/v2/clients/${auth0.clientId}?fields=callbacks,allowed_logout_urls,web_origins,allowed_origins`,
339
+ { headers: { Authorization: `Bearer ${mgmtToken}` }, signal: AbortSignal.timeout(10_000) },
340
+ );
341
+ if (!appResp.ok) return;
342
+ const app = await appResp.json();
343
+
344
+ const wildcardCb1 = "https://*.meshx.app/auth/callback";
345
+ const wildcardCb2 = "https://*.meshx.app/api/auth/callback";
346
+ const wildcardOrigin = "https://*.meshx.app";
347
+
348
+ const callbacks = new Set(app.callbacks || []);
349
+ const logoutUrls = new Set(app.allowed_logout_urls || []);
350
+ const webOrigins = new Set(app.web_origins || []);
351
+ const allowedOrigins = new Set(app.allowed_origins || []);
352
+
353
+ callbacks.add(`${baseUrl}/auth/callback`);
354
+ callbacks.add(`${baseUrl}/api/auth/callback`);
355
+ logoutUrls.add(baseUrl);
356
+ webOrigins.add(baseUrl);
357
+ allowedOrigins.add(baseUrl);
358
+
359
+ const before = callbacks.size + logoutUrls.size + webOrigins.size + allowedOrigins.size;
360
+ callbacks.add(wildcardCb1);
361
+ callbacks.add(wildcardCb2);
362
+ logoutUrls.add(wildcardOrigin);
363
+ webOrigins.add(wildcardOrigin);
364
+ allowedOrigins.add(wildcardOrigin);
365
+ const after = callbacks.size + logoutUrls.size + webOrigins.size + allowedOrigins.size;
366
+
367
+ if (after > before) {
368
+ const patchResp = await fetch(
369
+ `https://${auth0.domain}/api/v2/clients/${auth0.clientId}`,
370
+ {
371
+ method: "PATCH",
372
+ headers: { Authorization: `Bearer ${mgmtToken}`, "Content-Type": "application/json" },
373
+ body: JSON.stringify({
374
+ callbacks: [...callbacks],
375
+ allowed_logout_urls: [...logoutUrls],
376
+ web_origins: [...webOrigins],
377
+ allowed_origins: [...allowedOrigins],
378
+ }),
379
+ signal: AbortSignal.timeout(10_000),
380
+ },
381
+ );
382
+ if (patchResp.ok) {
383
+ console.log(OK(" ✓ Auth0 wildcard callbacks ensured (*.meshx.app)"));
384
+ } else {
385
+ console.log(WARN(` ⚠ Auth0 callback update failed: HTTP ${patchResp.status}`));
386
+ }
387
+ } else {
388
+ console.log(OK(" ✓ Auth0 wildcard callbacks already configured"));
389
+ }
390
+ } catch (e) {
391
+ hint(` Auth0 callback check failed: ${e.message}`);
392
+ }
393
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * azure-aks-naming.js - Naming helpers, constants, and pure utility functions
3
+ *
4
+ * Foundation module with no internal dependencies.
5
+ */
6
+
7
+ import crypto from "node:crypto";
8
+ import { DEFAULTS } from "./azure.js";
9
+
10
+ // ── AKS Defaults ──────────────────────────────────────────────────────────────
11
+
12
+ export const AKS_DEFAULTS = {
13
+ clusterName: "foundation-aks",
14
+ resourceGroup: process.env.AZURE_AKS_RESOURCE_GROUP || "foundation-aks-rg",
15
+ location: DEFAULTS.location,
16
+ nodeCount: 1,
17
+ minCount: 1,
18
+ maxCount: 1,
19
+ nodeVmSize: "Standard_D8s_v3",
20
+ kubernetesVersion: "1.33",
21
+ tier: "standard", // "free" | "standard" | "premium"
22
+ networkPlugin: "azure", // "azure" (CNI) | "kubenet"
23
+ fluxOwner: "meshxdata",
24
+ fluxRepo: "platform-flux-template",
25
+ fluxBranch: "main",
26
+ fluxPath: "clusters/fops",
27
+ };
28
+
29
+ // Region pairs for Postgres geo-replication
30
+ export const PG_REPLICA_REGIONS = {
31
+ uaenorth: "westeurope", // UAE → EU
32
+ westeurope: "uaenorth", // EU → UAE
33
+ eastus: "westeurope", // US East → EU
34
+ westus2: "eastus", // US West → US East
35
+ };
36
+
37
+ // ── Postgres Defaults ─────────────────────────────────────────────────────────
38
+
39
+ export const PG_DEFAULTS = {
40
+ sku: "Standard_B2ms",
41
+ tier: "Burstable",
42
+ version: "15",
43
+ storageSizeGb: 32,
44
+ adminUser: "foundation",
45
+ };
46
+
47
+ // ── Event Hubs Defaults ───────────────────────────────────────────────────────
48
+
49
+ export const EH_DEFAULTS = {
50
+ sku: "Standard",
51
+ capacity: 1,
52
+ };
53
+
54
+ // ── Naming Functions ──────────────────────────────────────────────────────────
55
+
56
+ export function pgServerName(clusterName) {
57
+ return `fops-${clusterName}-psql`;
58
+ }
59
+
60
+ export function kvName(clusterName) {
61
+ const base = `fops-${clusterName}-kv`.replace(/[^a-zA-Z0-9-]/g, "").slice(0, 24);
62
+ return base;
63
+ }
64
+
65
+ export function ehNamespaceName(clusterName) {
66
+ return `fops-${clusterName}-eh`;
67
+ }
68
+
69
+ // Note: clusterDomain, stackDomain, and pgDatabaseName are in azure-aks-ingress.js
70
+ // and azure-aks-stacks.js respectively
71
+
72
+ // ── Utility Functions ─────────────────────────────────────────────────────────
73
+
74
+ export function generatePassword(len = 32) {
75
+ const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
76
+ const bytes = crypto.randomBytes(len);
77
+ return Array.from(bytes, b => chars[b % chars.length]).join("");
78
+ }
79
+
80
+ export function timeSince(isoStr) {
81
+ const ms = Date.now() - new Date(isoStr).getTime();
82
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
83
+ if (ms < 3600_000) return `${Math.round(ms / 60_000)}m`;
84
+ if (ms < 86400_000) return `${Math.round(ms / 3600_000)}h`;
85
+ return `${Math.round(ms / 86400_000)}d`;
86
+ }
87
+
88
+ // ── CIDR Parsing and Overlap Detection ────────────────────────────────────────
89
+
90
+ export function parseCidr(cidr) {
91
+ const [ip, bits] = cidr.split("/");
92
+ const octets = ip.split(".").map(Number);
93
+ const addr = ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0;
94
+ const mask = bits === "0" ? 0 : (0xFFFFFFFF << (32 - Number(bits))) >>> 0;
95
+ return { start: (addr & mask) >>> 0, end: ((addr & mask) | ~mask) >>> 0 };
96
+ }
97
+
98
+ export function cidrOverlaps(cidr, existingCidrs) {
99
+ const a = parseCidr(cidr);
100
+ return existingCidrs.some(ec => {
101
+ const b = parseCidr(ec);
102
+ return a.start <= b.end && a.end >= b.start;
103
+ });
104
+ }