@meshxdata/fops 0.1.36 → 0.1.38

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 (59) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/fops.mjs +37 -14
  3. package/package.json +1 -1
  4. package/src/agent/llm.js +2 -0
  5. package/src/auth/azure.js +92 -0
  6. package/src/auth/cloudflare.js +125 -0
  7. package/src/auth/index.js +2 -0
  8. package/src/commands/index.js +8 -4
  9. package/src/commands/lifecycle.js +31 -10
  10. package/src/plugins/bundled/fops-plugin-azure/index.js +44 -2896
  11. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks.js +130 -2
  12. package/src/plugins/bundled/fops-plugin-azure/lib/azure-auth.js +497 -0
  13. package/src/plugins/bundled/fops-plugin-azure/lib/azure-helpers.js +51 -13
  14. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops.js +206 -52
  15. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision.js +128 -34
  16. package/src/plugins/bundled/fops-plugin-azure/lib/azure-shared-cache.js +1 -1
  17. package/src/plugins/bundled/fops-plugin-azure/lib/azure-sync.js +4 -4
  18. package/src/plugins/bundled/fops-plugin-azure/lib/azure.js +2 -2
  19. package/src/plugins/bundled/fops-plugin-azure/lib/commands/fleet-cmds.js +254 -0
  20. package/src/plugins/bundled/fops-plugin-azure/lib/commands/infra-cmds.js +894 -0
  21. package/src/plugins/bundled/fops-plugin-azure/lib/commands/test-cmds.js +314 -0
  22. package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +893 -0
  23. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/dai-backend.yaml +13 -0
  24. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/dai-frontend.yaml +13 -0
  25. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-backend.yaml +13 -0
  26. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-frontend.yaml +13 -0
  27. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-hive.yaml +13 -0
  28. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-kafka.yaml +13 -0
  29. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-meltano.yaml +13 -0
  30. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-mlflow.yaml +13 -0
  31. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-opa.yaml +13 -0
  32. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-processor.yaml +13 -0
  33. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-scheduler.yaml +13 -0
  34. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-storage-engine.yaml +13 -0
  35. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-trino.yaml +13 -0
  36. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/apps/foundation-watcher.yaml +13 -0
  37. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/config/repository.yaml +66 -0
  38. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/kustomization.yaml +30 -0
  39. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/acr-webhook-controller.yaml +63 -0
  40. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/externalsecrets.yaml +15 -0
  41. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/istio.yaml +42 -0
  42. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/kafka.yaml +15 -0
  43. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/kube-reflector.yaml +33 -0
  44. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/kubecost.yaml +12 -0
  45. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/nats-server.yaml +15 -0
  46. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/prometheus-agent.yaml +34 -0
  47. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/reloader.yaml +12 -0
  48. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/spark.yaml +112 -0
  49. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/tailscale.yaml +67 -0
  50. package/src/plugins/bundled/fops-plugin-azure/templates/cluster/operator/vertical-pod-autoscaler.yaml +15 -0
  51. package/src/plugins/bundled/fops-plugin-file/demo/orders_renamed.aligned.csv +1 -1
  52. package/src/plugins/bundled/fops-plugin-file/index.js +81 -12
  53. package/src/plugins/bundled/fops-plugin-file/lib/match.js +133 -15
  54. package/src/plugins/bundled/fops-plugin-file/lib/report.js +3 -0
  55. package/src/plugins/bundled/fops-plugin-foundation/index.js +26 -6
  56. package/src/plugins/bundled/fops-plugin-foundation/lib/client.js +9 -5
  57. package/src/plugins/bundled/fops-plugin-foundation-graphql/lib/graphql/resolvers/data-product.js +32 -0
  58. package/src/plugins/bundled/fops-plugin-foundation-graphql/lib/graphql/schema.js +20 -1
  59. package/src/plugins/loader.js +23 -6
@@ -1,2879 +1,45 @@
1
- import crypto from "node:crypto";
2
- import { spawnSync } from "node:child_process";
3
- import fs from "node:fs";
4
- import os from "node:os";
5
- import pathMod from "node:path";
6
- import { pathToFileURL } from "node:url";
7
- import chalk from "chalk";
8
-
9
- function hashContent(text) {
10
- return crypto.createHash("sha256").update(text).digest("hex").slice(0, 16);
11
- }
12
-
13
- const AZURE_UP_LOCK_PATH = pathMod.join(os.tmpdir(), "fops-azure-up.lock");
14
-
15
- function acquireAzureUpLock() {
16
- const tryAcquire = () => {
17
- try {
18
- const fd = fs.openSync(AZURE_UP_LOCK_PATH, "wx");
19
- fs.writeFileSync(fd, `${process.pid}\n${Date.now()}`, "utf8");
20
- fs.closeSync(fd);
21
- return true;
22
- } catch (e) {
23
- if (e.code !== "EEXIST") throw e;
24
- return false;
25
- }
26
- };
27
- if (tryAcquire()) return;
28
- try {
29
- const content = fs.readFileSync(AZURE_UP_LOCK_PATH, "utf8");
30
- const [pidLine] = content.split("\n");
31
- const pid = parseInt(pidLine, 10);
32
- if (pid && process.kill(pid, 0)) {
33
- console.error(chalk.red(`\n Another fops azure up is running (PID ${pid}). Wait for it to finish.`));
34
- console.error(chalk.dim(` Or remove the lock if that process died: rm ${AZURE_UP_LOCK_PATH}\n`));
35
- process.exit(1);
36
- }
37
- } catch { /* pid check failed or file gone */ }
38
- fs.unlinkSync(AZURE_UP_LOCK_PATH);
39
- if (!tryAcquire()) {
40
- console.error(chalk.red("\n Could not acquire fops azure up lock. Remove if stale: " + AZURE_UP_LOCK_PATH + "\n"));
41
- process.exit(1);
42
- }
43
- }
44
-
45
- function releaseAzureUpLock() {
46
- try { fs.unlinkSync(AZURE_UP_LOCK_PATH); } catch { /* ignore */ }
47
- }
48
-
49
- /**
50
- * Resolve Foundation credentials from env → .env → ~/.fops.json.
51
- * Returns { bearerToken } or { user, password } or null.
52
- */
53
- function resolveFoundationCreds() {
54
- if (process.env.BEARER_TOKEN?.trim()) return { bearerToken: process.env.BEARER_TOKEN.trim() };
55
- if (process.env.QA_USERNAME?.trim() && process.env.QA_PASSWORD)
56
- return { user: process.env.QA_USERNAME.trim(), password: process.env.QA_PASSWORD };
57
- try {
58
- const raw = JSON.parse(fs.readFileSync(pathMod.join(os.homedir(), ".fops.json"), "utf8"));
59
- const cfg = raw?.plugins?.entries?.["fops-plugin-foundation"]?.config || {};
60
- if (cfg.bearerToken?.trim()) return { bearerToken: cfg.bearerToken.trim() };
61
- if (cfg.user?.trim() && cfg.password) return { user: cfg.user.trim(), password: cfg.password };
62
- } catch { /* no fops.json */ }
63
- return null;
64
- }
65
-
66
- import { parsePytestSummary, parsePytestDurations } from "./lib/pytest-parse.js";
67
-
68
- // VMs use Traefik with self-signed certs — skip TLS verification for VM API calls.
69
- // Suppress the NODE_TLS_REJECT_UNAUTHORIZED warning once.
70
- let _tlsWarningSuppressed = false;
71
- function suppressTlsWarning() {
72
- if (_tlsWarningSuppressed) return;
73
- _tlsWarningSuppressed = true;
74
- const origEmit = process.emitWarning;
75
- process.emitWarning = (warning, ...args) => {
76
- if (typeof warning === "string" && warning.includes("NODE_TLS_REJECT_UNAUTHORIZED")) return;
77
- return origEmit.call(process, warning, ...args);
78
- };
79
- }
80
-
81
- async function vmFetch(url, opts = {}) {
82
- suppressTlsWarning();
83
- const prev = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
84
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
85
- try {
86
- return await fetch(url, { signal: AbortSignal.timeout(10_000), ...opts });
87
- } finally {
88
- if (prev === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
89
- else process.env.NODE_TLS_REJECT_UNAUTHORIZED = prev;
90
- }
91
- }
92
-
93
- /**
94
- * Resolve Auth0 config from ~/.fops.json → .env files.
95
- * Returns { domain, clientId, clientSecret, audience } or null.
96
- */
97
- function resolveAuth0Config() {
98
- try {
99
- const raw = JSON.parse(fs.readFileSync(pathMod.join(os.homedir(), ".fops.json"), "utf8"));
100
- const a0 = raw?.plugins?.entries?.["fops-plugin-foundation"]?.config?.auth0;
101
- if (a0?.domain && a0?.clientId) return a0;
102
-
103
- const projectRoot = raw?.projectRoot || "";
104
- const envCandidates = [
105
- ...(projectRoot ? [pathMod.join(projectRoot, ".env")] : []),
106
- pathMod.resolve(".env"),
107
- pathMod.resolve("..", ".env"),
108
- ];
109
- for (const ep of envCandidates) {
110
- try {
111
- const lines = fs.readFileSync(ep, "utf8").split("\n");
112
- const get = (k) => {
113
- const ln = lines.find((l) => l.startsWith(`${k}=`));
114
- return ln ? ln.slice(k.length + 1).trim().replace(/^["']|["']$/g, "") : "";
115
- };
116
- const domain = get("MX_AUTH0_DOMAIN") || get("AUTH0_DOMAIN");
117
- const clientId = get("MX_AUTH0_CLIENT_ID") || get("AUTH0_CLIENT_ID");
118
- const clientSecret = get("MX_AUTH0_CLIENT_SECRET") || get("AUTH0_CLIENT_SECRET");
119
- const audience = get("MX_AUTH0_AUDIENCE") || get("AUTH0_AUDIENCE");
120
- if (domain && clientId) return { domain, clientId, clientSecret, audience };
121
- } catch { /* try next */ }
122
- }
123
- } catch { /* no fops.json */ }
124
- return null;
125
- }
126
-
127
- /**
128
- * Authenticate against a remote VM's Foundation API and return a bearer token.
129
- * Tries the backend /iam/login first, then falls back to Auth0 ROPC.
130
- */
131
- async function authenticateVm(vmUrl, ip, creds) {
132
- if (creds.bearerToken) return creds.bearerToken;
133
-
134
- const hasDomain = vmUrl && !vmUrl.match(/^https?:\/\/\d+\.\d+\.\d+\.\d+/);
135
- const apiUrls = hasDomain
136
- ? [`${vmUrl}/api`, ...(ip ? [`http://${ip}:9001/api`] : [])]
137
- : [`${vmUrl}/api`, `https://${ip}:3002/api`, `http://${ip}:9001/api`];
138
-
139
- for (const apiBase of apiUrls) {
140
- try {
141
- const resp = await vmFetch(`${apiBase}/iam/login`, {
142
- method: "POST",
143
- headers: { "Content-Type": "application/json" },
144
- body: JSON.stringify({ user: creds.user, password: creds.password }),
145
- });
146
- if (resp.ok) {
147
- const data = await resp.json();
148
- const token = data.access_token || data.token;
149
- if (token) return token;
150
- }
151
- } catch { /* try next URL */ }
152
- }
153
-
154
- // Auth0 ROPC fallback for VMs that delegate auth to Auth0
155
- const auth0 = resolveAuth0Config();
156
- if (auth0 && creds.user && creds.password) {
157
- try {
158
- const body = {
159
- grant_type: "password",
160
- client_id: auth0.clientId,
161
- username: creds.user,
162
- password: creds.password,
163
- scope: "openid",
164
- };
165
- if (auth0.clientSecret) body.client_secret = auth0.clientSecret;
166
- if (auth0.audience) body.audience = auth0.audience;
167
-
168
- const resp = await fetch(`https://${auth0.domain}/oauth/token`, {
169
- method: "POST",
170
- headers: { "Content-Type": "application/json" },
171
- body: JSON.stringify(body),
172
- signal: AbortSignal.timeout(10_000),
173
- });
174
- if (resp.ok) {
175
- const data = await resp.json();
176
- if (data.access_token) return data.access_token;
177
- }
178
- } catch { /* auth0 fallback failed */ }
179
- }
180
-
181
- return null;
182
- }
183
-
184
- function isJwt(token) {
185
- return token && token.split(".").length === 3;
186
- }
187
-
188
- /**
189
- * Resolve a valid JWT bearer token for a remote VM/cluster.
190
- * Auth chain: local bearer → pre-auth /iam/login → Auth0 ROPC → SSH fetch from VM.
191
- * Returns { bearerToken, user, password, useTokenMode } or throws.
192
- */
193
- async function resolveRemoteAuth(opts = {}) {
194
- const {
195
- apiUrl, ip, vmState, execaFn: execa, sshCmd: ssh,
196
- knockForVm: knock, suppressTlsWarning: suppressTls,
197
- } = opts;
198
- const log = opts.log || console.log;
199
-
200
- const creds = resolveFoundationCreds();
201
- let qaUser = creds?.user || process.env.QA_USERNAME || process.env.FOUNDATION_USERNAME || "operator@local";
202
- let qaPass = creds?.password || process.env.QA_PASSWORD || "";
203
- let bearerToken = creds?.bearerToken || "";
204
- let useTokenMode = false;
205
-
206
- // 1) Use local bearer if it's a valid JWT
207
- if (bearerToken && isJwt(bearerToken)) {
208
- return { bearerToken, qaUser, qaPass, useTokenMode: true };
209
- }
210
- bearerToken = "";
211
-
212
- // 2) Pre-auth against the backend /iam/login
213
- if (qaUser && qaPass && apiUrl) {
214
- try {
215
- if (suppressTls) suppressTls();
216
- const prev = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
217
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
218
- try {
219
- const resp = await fetch(`${apiUrl}/iam/login`, {
220
- method: "POST",
221
- headers: { "Content-Type": "application/json" },
222
- body: JSON.stringify({ username: qaUser, password: qaPass }),
223
- signal: AbortSignal.timeout(10_000),
224
- });
225
- if (resp.ok) {
226
- const data = await resp.json();
227
- bearerToken = data.access_token || data.token || "";
228
- if (bearerToken) {
229
- log(chalk.green(` ✓ Authenticated as ${qaUser}`));
230
- return { bearerToken, qaUser, qaPass, useTokenMode: true };
231
- }
232
- } else {
233
- log(chalk.dim(` Local creds rejected: HTTP ${resp.status}`));
234
- }
235
- } finally {
236
- if (prev === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
237
- else process.env.NODE_TLS_REJECT_UNAUTHORIZED = prev;
238
- }
239
- } catch (e) {
240
- log(chalk.dim(` Pre-auth failed: ${e.cause?.code || e.message}`));
241
- }
242
- }
243
-
244
- // 3) Auth0 ROPC with local config
245
- if (qaUser && qaPass) {
246
- const auth0Cfg = resolveAuth0Config();
247
- if (auth0Cfg) {
248
- try {
249
- log(chalk.dim(` Trying Auth0 ROPC (${auth0Cfg.domain})…`));
250
- const body = {
251
- grant_type: "password", client_id: auth0Cfg.clientId,
252
- username: qaUser, password: qaPass, scope: "openid",
253
- };
254
- if (auth0Cfg.clientSecret) body.client_secret = auth0Cfg.clientSecret;
255
- if (auth0Cfg.audience) body.audience = auth0Cfg.audience;
256
- const resp = await fetch(`https://${auth0Cfg.domain}/oauth/token`, {
257
- method: "POST",
258
- headers: { "Content-Type": "application/json" },
259
- body: JSON.stringify(body),
260
- signal: AbortSignal.timeout(10_000),
261
- });
262
- if (resp.ok) {
263
- const data = await resp.json();
264
- if (data.access_token) {
265
- log(chalk.green(` ✓ Authenticated as ${qaUser} via Auth0`));
266
- return { bearerToken: data.access_token, qaUser, qaPass, useTokenMode: true };
267
- }
268
- } else {
269
- log(chalk.dim(` Auth0 rejected: HTTP ${resp.status}`));
270
- }
271
- } catch (e) {
272
- log(chalk.dim(` Auth0 failed: ${e.message}`));
273
- }
274
- }
275
- }
276
-
277
- // 4) SSH fallback — fetch credentials from VM and authenticate
278
- if (ip && ssh && execa) {
279
- if (knock && vmState) await knock(vmState);
280
- log(chalk.dim(" Fetching credentials from remote VM…"));
281
- try {
282
- const sshUser = vmState?.adminUser || "azureuser";
283
- const { stdout } = await ssh(
284
- execa, ip, sshUser,
285
- "grep -E '^(BEARER_TOKEN|QA_USERNAME|QA_PASSWORD|MX_AUTH0_DOMAIN|MX_AUTH0_CLIENT_ID|MX_AUTH0_CLIENT_SECRET|MX_AUTH0_AUDIENCE)=' /opt/foundation-compose/.env | head -20",
286
- 15_000,
287
- );
288
- const remoteEnv = {};
289
- for (const line of (stdout || "").split("\n")) {
290
- const m = line.match(/^([A-Z_]+)=(.*)$/);
291
- if (m) remoteEnv[m[1]] = m[2].trim().replace(/^["']|["']$/g, "");
292
- }
293
-
294
- const remoteToken = remoteEnv.BEARER_TOKEN;
295
- if (remoteToken && isJwt(remoteToken)) {
296
- log(chalk.green(" ✓ Got JWT bearer token from VM"));
297
- return { bearerToken: remoteToken, qaUser, qaPass, useTokenMode: true };
298
- }
299
-
300
- if (remoteEnv.MX_AUTH0_DOMAIN && remoteEnv.MX_AUTH0_CLIENT_ID) {
301
- const user = remoteEnv.QA_USERNAME || qaUser;
302
- const pass = remoteEnv.QA_PASSWORD || qaPass;
303
- if (user && pass) {
304
- log(chalk.dim(` Authenticating as ${user} via VM's Auth0…`));
305
- const body = {
306
- grant_type: "password", client_id: remoteEnv.MX_AUTH0_CLIENT_ID,
307
- username: user, password: pass, scope: "openid",
308
- };
309
- if (remoteEnv.MX_AUTH0_CLIENT_SECRET) body.client_secret = remoteEnv.MX_AUTH0_CLIENT_SECRET;
310
- if (remoteEnv.MX_AUTH0_AUDIENCE) body.audience = remoteEnv.MX_AUTH0_AUDIENCE;
311
- try {
312
- const resp = await fetch(`https://${remoteEnv.MX_AUTH0_DOMAIN}/oauth/token`, {
313
- method: "POST",
314
- headers: { "Content-Type": "application/json" },
315
- body: JSON.stringify(body),
316
- signal: AbortSignal.timeout(10_000),
317
- });
318
- if (resp.ok) {
319
- const data = await resp.json();
320
- if (data.access_token) {
321
- log(chalk.green(` ✓ Authenticated via VM's Auth0`));
322
- return { bearerToken: data.access_token, qaUser: user, qaPass: pass, useTokenMode: true };
323
- }
324
- }
325
- } catch (e) {
326
- log(chalk.dim(` VM Auth0 login failed: ${e.message}`));
327
- }
328
- }
329
- }
330
-
331
- if (remoteToken) {
332
- log(chalk.yellow(" ⚠ Using non-JWT token from VM (may cause 401)"));
333
- return { bearerToken: remoteToken, qaUser, qaPass, useTokenMode: true };
334
- }
335
- } catch (e) {
336
- log(chalk.dim(` SSH credential fetch failed: ${e.message}`));
337
- }
338
- }
339
-
340
- return { bearerToken: "", qaUser, qaPass, useTokenMode: false };
341
- }
342
-
343
- /**
344
- * Fetch landscape entities (meshes, products, sources, systems) from a remote
345
- * VM's Foundation API. Returns embeddable chunks tagged with the VM name.
346
- */
347
- async function fetchRemoteLandscape(vmName, vmUrl, ip, token, log) {
348
- const hasDomain = vmUrl && !vmUrl.match(/^https?:\/\/\d+\.\d+\.\d+\.\d+/);
349
- const apiBase = hasDomain ? `${vmUrl}/api` : `http://${ip}:9001/api`;
350
- const headers = { Authorization: `Bearer ${token}`, "x-org": "root" };
351
-
352
- let authRejected = 0;
353
- const fetchJson = async (path) => {
354
- const resp = await vmFetch(`${apiBase}${path}`, {
355
- headers,
356
- signal: AbortSignal.timeout(15_000),
357
- });
358
- if (resp.status === 401 || resp.status === 403) { authRejected++; return null; }
359
- if (!resp.ok) return null;
360
- return resp.json();
361
- };
362
-
363
- const [meshRes, dpRes, dsRes, dSysRes] = await Promise.all([
364
- fetchJson("/data/mesh/list?per_page=100").catch(() => null),
365
- fetchJson("/data/data_product/list?per_page=200").catch(() => null),
366
- fetchJson("/data/data_source/list?per_page=200").catch(() => null),
367
- fetchJson("/data/data_system/list?per_page=100").catch(() => null),
368
- ]);
369
-
370
- // If all 4 endpoints rejected the token, signal the caller to try SSH
371
- if (authRejected >= 4) {
372
- const err = new Error("token rejected by all endpoints");
373
- err.code = "AUTH_REJECTED";
374
- throw err;
375
- }
376
-
377
- const { parseListResponse } = await import("../fops-plugin-foundation/lib/api-spec.js");
378
-
379
- const chunks = [];
380
-
381
- for (const m of parseListResponse(meshRes)) {
382
- const text = [`Mesh: ${m.name} (VM: ${vmName})`, m.label ? `Label: ${m.label}` : null, m.description ? `Description: ${m.description}` : null, m.purpose ? `Purpose: ${m.purpose}` : null, `Identifier: ${m.identifier}`, `VM: ${vmName}`].filter(Boolean).join("\n");
383
- chunks.push({ title: `vm/${vmName}/mesh/${m.name}`, content: text, metadata: { resourceType: "remote-landscape", entityType: "mesh", vm: vmName, identifier: m.identifier, fileHash: hashContent(text) } });
384
- }
385
-
386
- for (const p of parseListResponse(dpRes)) {
387
- const text = [`Data Product: ${p.name} (VM: ${vmName})`, p.label ? `Label: ${p.label}` : null, p.description ? `Description: ${p.description}` : null, p.data_product_type ? `Type: ${p.data_product_type}` : null, `Identifier: ${p.identifier}`, p.host_mesh_identifier ? `Mesh: ${p.host_mesh_identifier}` : null, p.state?.code ? `State: ${p.state.code} (healthy: ${p.state.healthy})` : null, `VM: ${vmName}`].filter(Boolean).join("\n");
388
- chunks.push({ title: `vm/${vmName}/product/${p.name}`, content: text, metadata: { resourceType: "remote-landscape", entityType: "data_product", vm: vmName, identifier: p.identifier, fileHash: hashContent(text) } });
389
- }
390
-
391
- for (const s of parseListResponse(dsRes)) {
392
- const text = [`Data Source: ${s.name} (VM: ${vmName})`, s.label ? `Label: ${s.label}` : null, s.description ? `Description: ${s.description}` : null, `Identifier: ${s.identifier}`, s.state?.code ? `State: ${s.state.code} (healthy: ${s.state.healthy})` : null, `VM: ${vmName}`].filter(Boolean).join("\n");
393
- chunks.push({ title: `vm/${vmName}/source/${s.name}`, content: text, metadata: { resourceType: "remote-landscape", entityType: "data_source", vm: vmName, identifier: s.identifier, fileHash: hashContent(text) } });
394
- }
395
-
396
- for (const sys of parseListResponse(dSysRes)) {
397
- const text = [`Data System: ${sys.name} (VM: ${vmName})`, sys.label ? `Label: ${sys.label}` : null, sys.description ? `Description: ${sys.description}` : null, `Identifier: ${sys.identifier}`, `VM: ${vmName}`].filter(Boolean).join("\n");
398
- chunks.push({ title: `vm/${vmName}/system/${sys.name}`, content: text, metadata: { resourceType: "remote-landscape", entityType: "data_system", vm: vmName, identifier: sys.identifier, fileHash: hashContent(text) } });
399
- }
400
-
401
- const meshCount = entities(meshRes).length;
402
- const prodCount = parseListResponse(dpRes).length;
403
- const srcCount = parseListResponse(dsRes).length;
404
- const sysCount = parseListResponse(dSysRes).length;
405
- log(` azure/${vmName}: ${meshCount} meshes, ${prodCount} products, ${srcCount} sources, ${sysCount} systems`);
406
-
407
- return chunks;
408
- }
409
-
410
1
  /**
411
- * Fetch remote landscape data by SSHing into the VM and curling localhost:9001
412
- * directly, bypassing traefik/OAuth. Falls back to this when authenticateVm
413
- * returns null because the HTTPS endpoint rejects credentials.
2
+ * Azure plugin orchestrator.
3
+ * Delegates command registration to domain-focused modules in lib/commands/.
414
4
  */
415
- async function fetchLandscapeViaSsh(vmName, ip, creds, log) {
416
- const { lazyExeca, sshCmd, knockForVm, DEFAULTS } = await import("./lib/azure.js");
417
- const execa = await lazyExeca();
418
- const user = DEFAULTS.adminUser;
419
-
420
- try {
421
- await knockForVm({ publicIp: ip, vmName });
422
- } catch {}
423
-
424
- const ssh = (cmd, timeout = 15000) => sshCmd(execa, ip, user, cmd, timeout);
425
-
426
- // Authenticate via localhost:9001 on the VM
427
- const loginPayload = JSON.stringify({ user: creds.user, password: creds.password });
428
- const { stdout: loginOut, exitCode: loginExit } = await ssh(
429
- `curl -sf -X POST http://localhost:9001/api/iam/login -H 'Content-Type: application/json' -d '${loginPayload.replace(/'/g, "'\\''")}'`,
430
- 15000,
431
- );
432
-
433
- if (loginExit !== 0 || !loginOut?.trim()) {
434
- log(` azure/${vmName}: SSH auth failed (exit ${loginExit})`);
435
- return [];
436
- }
437
-
438
- let token;
439
- try {
440
- const data = JSON.parse(loginOut.trim());
441
- token = data.access_token || data.token;
442
- } catch {
443
- log(` azure/${vmName}: SSH auth returned invalid JSON`);
444
- return [];
445
- }
446
- if (!token) {
447
- log(` azure/${vmName}: SSH auth returned no token`);
448
- return [];
449
- }
450
-
451
- // Fetch all landscape entities in one SSH call to minimize round-trips
452
- const authHeader = `Authorization: Bearer ${token}`;
453
- const { stdout: dataOut, exitCode: dataExit } = await ssh(
454
- [
455
- `curl -sf -H '${authHeader}' -H 'x-org: root' 'http://localhost:9001/api/data/mesh/list?per_page=100'`,
456
- `echo '___SEP___'`,
457
- `curl -sf -H '${authHeader}' -H 'x-org: root' 'http://localhost:9001/api/data/data_product/list?per_page=200'`,
458
- `echo '___SEP___'`,
459
- `curl -sf -H '${authHeader}' -H 'x-org: root' 'http://localhost:9001/api/data/data_source/list?per_page=200'`,
460
- `echo '___SEP___'`,
461
- `curl -sf -H '${authHeader}' -H 'x-org: root' 'http://localhost:9001/api/data/data_system/list?per_page=100'`,
462
- ].join(" && "),
463
- 30000,
464
- );
465
-
466
- if (dataExit !== 0 || !dataOut?.trim()) {
467
- log(` azure/${vmName}: SSH landscape fetch failed (exit ${dataExit})`);
468
- return [];
469
- }
470
-
471
- const parts = dataOut.split("___SEP___");
472
- const parseJson = (s) => { try { return JSON.parse(s.trim()); } catch { return null; } };
473
- const meshRes = parseJson(parts[0] || "");
474
- const dpRes = parseJson(parts[1] || "");
475
- const dsRes = parseJson(parts[2] || "");
476
- const dSysRes = parseJson(parts[3] || "");
477
- const { parseListResponse } = await import("../fops-plugin-foundation/lib/api-spec.js");
478
-
479
- const chunks = [];
480
-
481
- for (const m of parseListResponse(meshRes)) {
482
- const text = [`Mesh: ${m.name} (VM: ${vmName})`, m.label ? `Label: ${m.label}` : null, m.description ? `Description: ${m.description}` : null, m.purpose ? `Purpose: ${m.purpose}` : null, `Identifier: ${m.identifier}`, `VM: ${vmName}`].filter(Boolean).join("\n");
483
- chunks.push({ title: `vm/${vmName}/mesh/${m.name}`, content: text, metadata: { resourceType: "remote-landscape", entityType: "mesh", vm: vmName, identifier: m.identifier, fileHash: hashContent(text) } });
484
- }
485
-
486
- for (const p of parseListResponse(dpRes)) {
487
- const text = [`Data Product: ${p.name} (VM: ${vmName})`, p.label ? `Label: ${p.label}` : null, p.description ? `Description: ${p.description}` : null, p.data_product_type ? `Type: ${p.data_product_type}` : null, `Identifier: ${p.identifier}`, p.host_mesh_identifier ? `Mesh: ${p.host_mesh_identifier}` : null, p.state?.code ? `State: ${p.state.code} (healthy: ${p.state.healthy})` : null, `VM: ${vmName}`].filter(Boolean).join("\n");
488
- chunks.push({ title: `vm/${vmName}/product/${p.name}`, content: text, metadata: { resourceType: "remote-landscape", entityType: "data_product", vm: vmName, identifier: p.identifier, fileHash: hashContent(text) } });
489
- }
490
-
491
- for (const s of parseListResponse(dsRes)) {
492
- const text = [`Data Source: ${s.name} (VM: ${vmName})`, s.label ? `Label: ${s.label}` : null, s.description ? `Description: ${s.description}` : null, `Identifier: ${s.identifier}`, s.state?.code ? `State: ${s.state.code} (healthy: ${s.state.healthy})` : null, `VM: ${vmName}`].filter(Boolean).join("\n");
493
- chunks.push({ title: `vm/${vmName}/source/${s.name}`, content: text, metadata: { resourceType: "remote-landscape", entityType: "data_source", vm: vmName, identifier: s.identifier, fileHash: hashContent(text) } });
494
- }
495
-
496
- for (const sys of parseListResponse(dSysRes)) {
497
- const text = [`Data System: ${sys.name} (VM: ${vmName})`, sys.label ? `Label: ${sys.label}` : null, sys.description ? `Description: ${sys.description}` : null, `Identifier: ${sys.identifier}`, `VM: ${vmName}`].filter(Boolean).join("\n");
498
- chunks.push({ title: `vm/${vmName}/system/${sys.name}`, content: text, metadata: { resourceType: "remote-landscape", entityType: "data_system", vm: vmName, identifier: sys.identifier, fileHash: hashContent(text) } });
499
- }
500
-
501
- const meshCount = entities(meshRes).length;
502
- const prodCount = parseListResponse(dpRes).length;
503
- const srcCount = parseListResponse(dsRes).length;
504
- const sysCount = parseListResponse(dSysRes).length;
505
- log(` azure/${vmName}: ${meshCount} meshes, ${prodCount} products, ${srcCount} sources, ${sysCount} systems (via SSH)`);
5
+ import chalk from "chalk";
506
6
 
507
- return chunks;
508
- }
7
+ import {
8
+ hashContent,
9
+ resolveFoundationCreds,
10
+ resolveAuth0Config,
11
+ authenticateVm,
12
+ vmFetch,
13
+ fetchRemoteLandscape,
14
+ fetchLandscapeViaSsh,
15
+ } from "./lib/azure-auth.js";
16
+
17
+ import { registerVmCommands } from "./lib/commands/vm-cmds.js";
18
+ import { registerFleetCommands } from "./lib/commands/fleet-cmds.js";
19
+ import { registerTestCommands } from "./lib/commands/test-cmds.js";
20
+ import { registerInfraCommands } from "./lib/commands/infra-cmds.js";
509
21
 
510
22
  export { resolveFoundationCreds, resolveAuth0Config, authenticateVm, vmFetch };
511
23
 
512
24
  export async function register(api) {
513
- // ── Command: fops azure ──────────────────────────────
25
+ // ── Commands ──────────────────────────────────────────────────────────
26
+
514
27
  api.registerCommand((program, registry) => {
515
28
  const azure = program
516
29
  .command("azure")
517
30
  .description("Manage Foundation on Azure (VMs & AKS clusters)");
518
31
 
519
- azure
520
- .command("doctor [name]")
521
- .description("Run doctor locally, or on VM if name given (e.g. fops azure doctor my-vm)")
522
- .option("--fix", "Apply suggested fixes where possible", false)
523
- .action(async (name, opts) => {
524
- if (name) {
525
- const {
526
- lazyExeca, ensureAzCli, ensureAzAuth,
527
- requireVmState, knockForVm, DEFAULTS, resolvePublicIp,
528
- } = await import("./lib/azure.js");
529
- const { closeKnock } = await import("./lib/port-knock.js");
530
- const { sshCmd } = await import("./lib/azure.js");
531
- const vmName = opts.vmName || name;
532
- const execa = await lazyExeca();
533
- await ensureAzCli(execa);
534
- await ensureAzAuth(execa);
535
- const state = requireVmState(vmName);
536
- const ip = await resolvePublicIp(execa, state.resourceGroup, state.vmName, state.publicIp);
537
- const user = DEFAULTS.adminUser;
538
- if (!ip) {
539
- console.error(chalk.red("\n No IP address. Is the VM running? Try: fops azure start\n"));
540
- process.exit(1);
541
- }
542
- await knockForVm(state);
543
- const ssh = (cmd, timeout) => sshCmd(execa, ip, user, cmd, timeout);
544
- const fixFlag = opts.fix ? " --fix" : "";
545
- console.log(chalk.cyan(`\n Running fops doctor on "${state.vmName}" (${ip})…\n`));
546
- const { MUX_OPTS } = await import("./lib/azure.js");
547
- const { exitCode } = await execa("ssh", [
548
- ...MUX_OPTS(ip, user),
549
- `${user}@${ip}`,
550
- `cd /opt/foundation-compose && fops doctor${fixFlag}`,
551
- ], {
552
- timeout: 120000,
553
- reject: false,
554
- stdio: "inherit",
555
- });
556
- await closeKnock(ssh, { quiet: true });
557
- if (exitCode !== 0) process.exitCode = 1;
558
- return;
559
- }
560
- const doctorPath = api.getCliPath?.("src", "doctor.js");
561
- const mod = doctorPath
562
- ? await import(pathToFileURL(doctorPath).href)
563
- : await import("../../../doctor.js");
564
- await mod.runDoctor({ fix: opts.fix }, registry ?? null);
565
- });
566
-
567
- azure
568
- .command("packer")
569
- .description("Build the Foundation platform image with Packer")
570
- .option("--profile <subscription>", "Azure subscription name or ID")
571
- .option("--github-token <token>", "GitHub PAT for cloning private repos (default: $GITHUB_TOKEN)")
572
- .option("--branch <branch>", "Git branch to bake into the image (default: main)")
573
- .option("--fops-version <version>", "fops npm version to install (default: latest)")
574
- .option("--location <region>", "Azure region for the build VM (default: from pkrvars)")
575
- .option("--vm-size <size>", "Build VM size (default: from pkrvars)")
576
- .option("--image-name <name>", "Output image name (default: foundation-platform)")
577
- .option("--force", "Replace existing image if it already exists")
578
- .action(async (opts) => {
579
- const { azureBuild } = await import("./lib/azure.js");
580
- await azureBuild({
581
- githubToken: opts.githubToken,
582
- branch: opts.branch,
583
- fopsVersion: opts.fopsVersion,
584
- location: opts.location,
585
- vmSize: opts.vmSize,
586
- imageName: opts.imageName,
587
- force: opts.force,
588
- });
589
- });
590
-
591
- const marketplace = azure
592
- .command("marketplace")
593
- .description("Prepare Azure VM image artifacts for Marketplace publishing");
594
-
595
- marketplace
596
- .command("image <vmName>")
597
- .description("Create a Marketplace-ready managed image from a safe clone of a source VM (source VM unchanged)")
598
- .option("--profile <subscription>", "Azure subscription name or ID")
599
- .option("--source-rg <name>", "Resource group containing the source VM")
600
- .option("--resource-group <name>", "Working RG for snapshot/clone/image (default: source RG)")
601
- .option("--clone-vm-name <name>", "Temporary clone VM name")
602
- .option("--clone-vm-size <size>", "Temporary clone VM size (default: source VM size)")
603
- .option("--admin-user <name>", "Admin username for temporary clone VM (default: azureuser)")
604
- .option("--snapshot-name <name>", "Snapshot name override")
605
- .option("--disk-name <name>", "Managed disk name override")
606
- .option("--image-name <name>", "Output managed image artifact name")
607
- .option("--keep-staging", "Keep temporary clone resources (VM/disk/snapshot)")
608
- .option("--gallery-name <name>", "Optional: publish to Azure Compute Gallery")
609
- .option("--image-definition <name>", "Gallery image definition name (default: <offer>-<sku>)")
610
- .option("--publisher <name>", "Required with --gallery-name")
611
- .option("--offer <name>", "Required with --gallery-name")
612
- .option("--sku <name>", "Required with --gallery-name")
613
- .option("--version <version>", "Required with --gallery-name (e.g. 1.0.0)")
614
- .option("--target-region <region>", "Target region for gallery image version (default: source VM region)")
615
- .action(async (vmName, opts) => {
616
- const { marketplaceImageFromVm } = await import("./lib/azure.js");
617
- await marketplaceImageFromVm({
618
- vmName,
619
- profile: opts.profile,
620
- sourceRg: opts.sourceRg,
621
- resourceGroup: opts.resourceGroup,
622
- cloneVmName: opts.cloneVmName,
623
- cloneVmSize: opts.cloneVmSize,
624
- adminUser: opts.adminUser,
625
- snapshotName: opts.snapshotName,
626
- diskName: opts.diskName,
627
- imageName: opts.imageName,
628
- keepStaging: opts.keepStaging,
629
- galleryName: opts.galleryName,
630
- imageDefinition: opts.imageDefinition,
631
- publisher: opts.publisher,
632
- offer: opts.offer,
633
- sku: opts.sku,
634
- version: opts.version,
635
- targetRegion: opts.targetRegion,
636
- });
637
- });
638
-
639
- azure
640
- .command("up [name] [branch]")
641
- .description("Provision / reconcile Azure VMs, or run 'fops up <component> <branch>' on remote (e.g. fops azure up backend FOU-2072)")
642
- .option("--profile <subscription>", "Azure subscription name or ID")
643
- .option("--vm-name <name>", "VM name (default: foundation-test-vm) or target VM for remote up")
644
- .option("--vm-size <size>", "VM size (default: Standard_D8s_v3)")
645
- .option("--location <region>", "Azure region (default: uaenorth)")
646
- .option("--image <urn>", "Custom image URN or managed image ID (default: Ubuntu 24.04 LTS)")
647
- .option("--branch <branch>", "Git branch to clone (default: main)")
648
- .option("--fops-version <version>", "fops npm version to install (default: latest)")
649
- .option("--url <url>", "Public URL override (default: https://<name>.meshx.app)")
650
- .option("--github-token <token>", "GitHub PAT for .netrc + GHCR auth (default: $GITHUB_TOKEN)")
651
- .option("--cf-token <token>", "Cloudflare API token for DNS (default: $CLOUDFLARE_API_TOKEN)")
652
- .option("--wait-mode <mode>", "URL readiness mode: http-any (default) or http-2xx", "http-any")
653
- .option("--no-knock", "Skip port-knock setup — SSH stays open to all")
654
- .option("--k3s", "Include k3s Kubernetes services")
655
- .option("--traefik", "Include traefik reverse proxy (auto-enabled for DNS URLs)")
656
- .option("--dai", "Also start DAI services (implies --k3s)")
657
- .option("--resource-group <name>", "Resource group for the VM (default: FOUNDATION-VM-RG or AZURE_RESOURCE_GROUP)")
658
- .option("--update", "Run fops update (git pull, npm install, image pull) before starting services")
659
- .action(async (name, branch, opts) => {
660
- if (opts.dai) opts.k3s = true;
661
- // Two args: run "fops up <component> <branch>" on remote VM (same as local fops up backend FOU-2072)
662
- if (name && branch) {
663
- const { azureRunUp } = await import("./lib/azure.js");
664
- await azureRunUp({
665
- vmName: opts.vmName,
666
- component: name,
667
- branch,
668
- url: opts.url,
669
- k3s: opts.k3s,
670
- traefik: opts.traefik,
671
- dai: opts.dai,
672
- update: opts.update,
673
- });
674
- return;
675
- }
676
- acquireAzureUpLock();
677
- try {
678
- const vmName = opts.vmName || name;
679
- const sharedOpts = {
680
- vmSize: opts.vmSize, location: opts.location,
681
- image: opts.image, branch: opts.branch, fopsVersion: opts.fopsVersion,
682
- url: opts.url, githubToken: opts.githubToken, cfToken: opts.cfToken,
683
- profile: opts.profile, knock: opts.knock, k3s: opts.k3s, traefik: opts.traefik, dai: opts.dai,
684
- resourceGroup: opts.resourceGroup, waitMode: opts.waitMode,
685
- };
686
- if (vmName) {
687
- const { azureUp } = await import("./lib/azure.js");
688
- await azureUp({ ...sharedOpts, vmName });
689
- } else {
690
- const { azureUpAll } = await import("./lib/azure.js");
691
- await azureUpAll(sharedOpts);
692
- }
693
- } finally {
694
- releaseAzureUpLock();
695
- }
696
- });
697
-
698
- azure
699
- .command("list")
700
- .description("List all tracked Azure VMs and AKS clusters (cached)")
701
- .option("--live", "Force live probe (skip cache)")
702
- .option("--verbose", "Show sync progress")
703
- .option("--cost", "Show estimated cost per resource (queries Azure Cost Management)")
704
- .option("--days <days>", "Days to look back for cost (default: 30)", "30")
705
- .option("--versions", "Show service image version matrix")
706
- .action(async (opts) => {
707
- const { azureList } = await import("./lib/azure.js");
708
- await azureList({ live: opts.live, verbose: opts.verbose, cost: opts.cost, days: parseInt(opts.days), versions: opts.versions });
709
- });
710
-
711
- azure
712
- .command("index")
713
- .description("Index Azure VMs and AKS clusters into local cache")
714
- .action(async () => {
715
- const { azureSync } = await import("./lib/azure-sync.js");
716
- await azureSync();
717
- });
718
-
719
- const azureEmbed = azure
720
- .command("embed")
721
- .description("Manage Azure semantic indexing/search in local embeddings");
722
-
723
- azureEmbed
724
- .command("index [name]")
725
- .description("Index Azure inventory and embeddings (equivalent to: azure index + embed index --source azure)")
726
- .option("--force", "Force re-embedding even when chunks are unchanged")
727
- .action(async (name, opts) => {
728
- const { azureSync } = await import("./lib/azure-sync.js");
729
- await azureSync();
730
- const cliPath = process.argv[1];
731
- if (!cliPath) {
732
- console.error(chalk.red("Could not resolve CLI entrypoint to run embeddings index."));
733
- process.exitCode = 1;
734
- return;
735
- }
736
- const args = [cliPath, "embed", "index", "--source", "azure"];
737
- if (opts.force) args.push("--force");
738
- const env = { ...process.env };
739
- if (name) env.__FOPS_AZURE_EMBED_VM = name;
740
- const child = spawnSync(process.execPath, args, { stdio: "inherit", env });
741
- if (child.error) throw child.error;
742
- if (typeof child.status === "number" && child.status !== 0) process.exitCode = child.status;
743
- if (child.signal) process.exitCode = 1;
744
- });
745
-
746
- azureEmbed
747
- .command("search <query>")
748
- .description("Semantic search over Azure VMs and AKS in the local embeddings index")
749
- .option("-k, --top-k <k>", "Number of results", "5")
750
- .action(async (query, opts) => {
751
- const searchSvc = typeof api.getService === "function" ? api.getService("embeddings:search") : null;
752
- if (!searchSvc?.search) {
753
- console.error(chalk.red("\n Embeddings plugin not available. Run: fops embed index (and fops azure index).\n"));
754
- process.exitCode = 1;
755
- return;
756
- }
757
- try {
758
- const topK = parseInt(opts.topK, 10) || 5;
759
- const results = await searchSvc.search(query, { source: "azure", topK });
760
- if (results.length === 0) {
761
- console.log("No Azure results. Index first: fops azure embed index");
762
- return;
763
- }
764
- for (const r of results) {
765
- console.log(`\n${chalk.cyan(`[${r.source}]`)} ${chalk.bold(r.title)} ${chalk.dim(`(score: ${Number(r.score).toFixed(3)})`)}`);
766
- console.log(chalk.dim((r.content || "").slice(0, 200) + ((r.content || "").length > 200 ? "..." : "")));
767
- }
768
- } catch (err) {
769
- console.error(chalk.red(err?.message || String(err)));
770
- process.exitCode = 1;
771
- }
772
- });
773
-
774
- azure
775
- .command("select [name]")
776
- .description("Set the active VM (interactive picker when no name given)")
777
- .action(async (name) => {
778
- const { listVms, readState, saveState, migrateAzureState } = await import("./lib/azure-state.js");
779
- const { activeVm, vms } = listVms();
780
- const vmNames = Object.keys(vms);
781
- if (vmNames.length === 0) {
782
- console.error(chalk.red("\n No tracked VMs. Provision one first: fops azure up --vm-name <name>\n"));
783
- process.exit(1);
784
- }
785
- let selected = name;
786
- if (!selected) {
787
- if (vmNames.length === 1) {
788
- selected = vmNames[0];
789
- } else {
790
- const { default: inquirer } = await import("inquirer");
791
- const answer = await inquirer.prompt([{
792
- type: "list",
793
- name: "vm",
794
- message: "Select active VM:",
795
- choices: vmNames.map(n => ({
796
- name: n === activeVm ? `${n} ${chalk.dim("(current)")}` : n,
797
- value: n,
798
- })),
799
- default: activeVm,
800
- }]);
801
- selected = answer.vm;
802
- }
803
- }
804
- if (!vms[selected]) {
805
- console.error(chalk.red(`\n VM "${selected}" not found. Tracked VMs: ${vmNames.join(", ")}\n`));
806
- process.exit(1);
807
- }
808
- if (selected === activeVm) {
809
- console.log(chalk.dim(`\n "${selected}" is already the active VM.\n`));
810
- return;
811
- }
812
- const state = readState();
813
- const az = migrateAzureState(state.azure);
814
- az.activeVm = selected;
815
- state.azure = az;
816
- saveState(state);
817
- const vm = vms[selected];
818
- const url = vm.publicIp ? `https://${vm.domain || selected + ".meshx.app"}` : "";
819
- console.log(chalk.green(`\n ✓ Active VM → ${chalk.bold(selected)}`));
820
- if (url) console.log(chalk.dim(` ${url}`));
821
- console.log();
822
- });
823
-
824
- azure
825
- .command("down [name]")
826
- .description("Destroy the Azure VM and all associated resources")
827
- .option("--profile <subscription>", "Azure subscription name or ID")
828
- .option("--vm-name <name>", "Target VM (default: active VM)")
829
- .option("--cf-token <token>", "Cloudflare API token for DNS cleanup (default: $CLOUDFLARE_API_TOKEN)")
830
- .action(async (name, opts) => {
831
- const { azureDown } = await import("./lib/azure.js");
832
- await azureDown({ vmName: opts.vmName || name, profile: opts.profile, cfToken: opts.cfToken });
833
- });
834
-
835
- azure
836
- .command("stop [name]")
837
- .description("Stop (deallocate) the VM — no compute charges, disk preserved")
838
- .option("--profile <subscription>", "Azure subscription name or ID")
839
- .option("--vm-name <name>", "Target VM (default: active VM)")
840
- .action(async (name, opts) => {
841
- const { azureStop } = await import("./lib/azure.js");
842
- await azureStop({ vmName: opts.vmName || name, profile: opts.profile });
843
- });
844
-
845
- azure
846
- .command("resize [name]")
847
- .description("Scale the VM up (or down) to the next size in its family")
848
- .option("--profile <subscription>", "Azure subscription name or ID")
849
- .option("--vm-name <name>", "Target VM (default: active VM)")
850
- .option("--size <size>", "Explicit target size (e.g. Standard_D8s_v3) — skips auto-detection")
851
- .option("--down", "Scale down instead of up")
852
- .option("--github-token <token>", "GitHub PAT for .netrc + GHCR auth (default: $GITHUB_TOKEN)")
853
- .option("--k3s", "Include k3s Kubernetes services")
854
- .option("--traefik", "Include traefik reverse proxy (auto-enabled for DNS URLs)")
855
- .option("--dai", "Also start DAI services (implies --k3s)")
856
- .action(async (name, opts) => {
857
- if (opts.dai) opts.k3s = true;
858
- const { azureResize } = await import("./lib/azure.js");
859
- await azureResize({ vmName: opts.vmName || name, size: opts.size, down: opts.down, githubToken: opts.githubToken, profile: opts.profile, k3s: opts.k3s, traefik: opts.traefik, dai: opts.dai });
860
- });
861
-
862
- azure
863
- .command("start [name]")
864
- .description("Start a stopped VM and reconfigure Foundation URL")
865
- .option("--profile <subscription>", "Azure subscription name or ID")
866
- .option("--vm-name <name>", "Target VM (default: active VM)")
867
- .option("--url <url>", "Public URL override")
868
- .option("--github-token <token>", "GitHub PAT for .netrc + GHCR auth (default: $GITHUB_TOKEN)")
869
- .option("--cf-token <token>", "Cloudflare API token for DNS (default: $CLOUDFLARE_API_TOKEN)")
870
- .option("--k3s", "Include k3s Kubernetes services")
871
- .option("--traefik", "Include traefik reverse proxy (auto-enabled for DNS URLs)")
872
- .option("--dai", "Also start DAI services (implies --k3s)")
873
- .action(async (name, opts) => {
874
- if (opts.dai) opts.k3s = true;
875
- const { azureStart } = await import("./lib/azure.js");
876
- await azureStart({ vmName: opts.vmName || name, url: opts.url, githubToken: opts.githubToken, cfToken: opts.cfToken, profile: opts.profile, k3s: opts.k3s, traefik: opts.traefik, dai: opts.dai });
877
- });
878
-
879
- azure
880
- .command("status [name]")
881
- .description("Show VM state and Foundation service health")
882
- .option("--profile <subscription>", "Azure subscription name or ID")
883
- .option("--vm-name <name>", "Target VM (default: active VM)")
884
- .action(async (name, opts) => {
885
- const { azureStatus } = await import("./lib/azure.js");
886
- await azureStatus({ vmName: opts.vmName || name, profile: opts.profile });
887
- });
888
-
889
- azure
890
- .command("trino-status [name]")
891
- .description("Check Trino container and engine status on the VM (for bootstrap debugging)")
892
- .option("--profile <subscription>", "Azure subscription name or ID")
893
- .option("--vm-name <name>", "Target VM (default: active VM)")
894
- .action(async (name, opts) => {
895
- const { azureTrinoStatus } = await import("./lib/azure.js");
896
- await azureTrinoStatus({ vmName: opts.vmName || name, profile: opts.profile });
897
- });
898
-
899
- azure
900
- .command("terraform [name]")
901
- .description("Generate Terraform HCL for the VM and its resources (VNet, NSG, IP, disk encryption)")
902
- .option("--profile <subscription>", "Azure subscription name or ID")
903
- .option("--vm-name <name>", "Target VM (default: active VM)")
904
- .option("--output <file>", "Write HCL to a file instead of stdout")
905
- .action(async (name, opts) => {
906
- const { vmTerraform } = await import("./lib/azure.js");
907
- await vmTerraform({ vmName: opts.vmName || name, profile: opts.profile, output: opts.output });
908
- });
909
-
910
- const ssh = azure
911
- .command("ssh")
912
- .description("SSH access and admin key management");
913
-
914
- ssh
915
- .command("connect [name]", { isDefault: true })
916
- .description("Open an interactive SSH session to the VM")
917
- .option("--vm-name <name>", "Target VM (default: active VM)")
918
- .action(async (name, opts) => {
919
- const { azureSsh } = await import("./lib/azure.js");
920
- await azureSsh({ vmName: opts.vmName || name });
921
- });
922
-
923
- const sshAdmin = ssh
924
- .command("admin")
925
- .description("Manage admin SSH keys across all VMs");
926
-
927
- sshAdmin
928
- .command("add <pubKey>")
929
- .description("Add a public SSH key to all tracked VMs")
930
- .action(async (pubKey) => {
931
- const { azureSshAdminAdd } = await import("./lib/azure.js");
932
- await azureSshAdminAdd({ pubKey });
933
- });
934
-
935
- azure
936
- .command("port <remotePort>")
937
- .description("Forward a remote VM port to localhost (SSH tunnel)")
938
- .option("--vm-name <name>", "Target VM (default: active VM)")
939
- .option("--local-port <port>", "Local port (default: same as remote port)")
940
- .action(async (remotePort, opts) => {
941
- const { azurePortForward } = await import("./lib/azure.js");
942
- await azurePortForward({ vmName: opts.vmName, remotePort: Number(remotePort), localPort: opts.localPort ? Number(opts.localPort) : undefined });
943
- });
944
-
945
- azure
946
- .command("agent [vmName]")
947
- .description("Run fops agent TUI on the remote VM via SSH (vmName = VM name; use --agent for agent name)")
948
- .option("--vm-name <name>", "Target VM (default: active VM)")
949
- .option("--agent <name>", "Agent to run (default: foundation)", "foundation")
950
- .option("-m, --message <text>", "Single-turn message instead of TUI")
951
- .option("--classic", "Use classic terminal REPL instead of TUI")
952
- .option("--model <id>", "Model override (e.g. claude-sonnet-4-20250514)")
953
- .action(async (vmName, opts) => {
954
- const { azureAgent } = await import("./lib/azure.js");
955
- await azureAgent({ vmName: opts.vmName || vmName, agent: opts.agent, message: opts.message, classic: opts.classic, model: opts.model });
956
- });
957
-
958
- const knock = azure
959
- .command("knock")
960
- .description("Perform port-knock sequence to temporarily open SSH");
961
-
962
- knock
963
- .command("open [name]", { isDefault: true })
964
- .description("Send the knock sequence — opens SSH for ~5 min")
965
- .option("--vm-name <name>", "Target VM (default: active VM)")
966
- .action(async (name, opts) => {
967
- const { azureKnock } = await import("./lib/azure.js");
968
- await azureKnock({ vmName: opts.vmName || name });
969
- });
970
-
971
- knock
972
- .command("close [name]")
973
- .description("Re-lock ports — revoke current knock, require a fresh one")
974
- .option("--vm-name <name>", "Target VM (default: active VM)")
975
- .action(async (name, opts) => {
976
- const { azureKnockClose } = await import("./lib/azure.js");
977
- await azureKnockClose({ vmName: opts.vmName || name });
978
- });
979
-
980
- knock
981
- .command("disable [name]")
982
- .description("Remove port knocking — restore open SSH access")
983
- .option("--vm-name <name>", "Target VM (default: active VM)")
984
- .action(async (name, opts) => {
985
- const { azureKnockDisable } = await import("./lib/azure.js");
986
- await azureKnockDisable({ vmName: opts.vmName || name });
987
- });
988
-
989
- knock
990
- .command("verify [name]")
991
- .description("Diagnose port-knock setup: check knockd, iptables rules, sequence match")
992
- .option("--vm-name <name>", "Target VM (default: all VMs)")
993
- .action(async (name, opts) => {
994
- const { azureKnockVerify } = await import("./lib/azure.js");
995
- await azureKnockVerify({ vmName: opts.vmName || name });
996
- });
997
-
998
- knock
999
- .command("fix [name]")
1000
- .description("Re-setup port knocking with correct iptables rules on all (or one) VM")
1001
- .option("--vm-name <name>", "Target VM (default: all VMs)")
1002
- .action(async (name, opts) => {
1003
- const { azureKnockFix } = await import("./lib/azure.js");
1004
- await azureKnockFix({ vmName: opts.vmName || name });
1005
- });
1006
-
1007
- const deploy = azure
1008
- .command("deploy")
1009
- .description("Deploy code, images, or specific component versions");
1010
-
1011
- deploy
1012
- .command("stack [name]", { isDefault: true })
1013
- .description("Pull latest code, images, and restart Foundation on the VM")
1014
- .option("--vm-name <name>", "Target VM (default: active VM)")
1015
- .option("--branch <branch>", "Git branch to deploy (default: main)")
1016
- .option("--url <url>", "Public URL override")
1017
- .option("--github-token <token>", "GitHub PAT for .netrc + GHCR auth (default: $GITHUB_TOKEN)")
1018
- .option("--k3s", "Include k3s Kubernetes services")
1019
- .option("--traefik", "Include traefik reverse proxy (auto-enabled for DNS URLs)")
1020
- .option("--dai", "Also start DAI services (implies --k3s)")
1021
- .action(async (name, opts) => {
1022
- if (opts.dai) opts.k3s = true;
1023
- const { azureDeploy } = await import("./lib/azure.js");
1024
- await azureDeploy({ vmName: opts.vmName || name, branch: opts.branch, url: opts.url, githubToken: opts.githubToken, k3s: opts.k3s, traefik: opts.traefik, dai: opts.dai });
1025
- });
1026
-
1027
- deploy
1028
- .command("version <component> <tag>")
1029
- .description("Set a component to a specific image tag, pull, and restart")
1030
- .option("--vm-name <name>", "Target a specific VM")
1031
- .option("--all", "Deploy to all tracked VMs")
1032
- .option("--aks", "Also deploy to AKS clusters")
1033
- .option("--aks-cluster <name>", "Target a specific AKS cluster")
1034
- .option("--no-restart", "Pull image but don't restart the service")
1035
- .action(async (component, tag, opts) => {
1036
- const { azureDeployVersion } = await import("./lib/azure.js");
1037
- await azureDeployVersion({
1038
- component, tag,
1039
- vmName: opts.vmName,
1040
- all: opts.all,
1041
- aks: opts.aks,
1042
- aksCluster: opts.aksCluster,
1043
- noPull: !opts.restart,
1044
- });
1045
- });
1046
-
1047
- const config = azure
1048
- .command("config")
1049
- .description("Manage feature flags and component versions on the remote VM");
1050
-
1051
- config
1052
- .command("flags [name]", { isDefault: true })
1053
- .description("Toggle MX_FF_* feature flags")
1054
- .option("--vm-name <name>", "Target VM (default: active VM)")
1055
- .action(async (name, opts) => {
1056
- const { azureConfig } = await import("./lib/azure.js");
1057
- await azureConfig({ vmName: opts.vmName || name });
1058
- });
1059
-
1060
- config
1061
- .command("versions [name]")
1062
- .description("Set image tags per Foundation component (backend, frontend, watcher, scheduler, storage)")
1063
- .option("--vm-name <name>", "Target VM (default: active VM)")
1064
- .action(async (name, opts) => {
1065
- const { azureConfigVersions } = await import("./lib/azure.js");
1066
- await azureConfigVersions({ vmName: opts.vmName || name });
1067
- });
1068
-
1069
- azure
1070
- .command("update [names...]")
1071
- .description("Run fops update on tracked VMs in parallel (no args = all VMs, or list VM names)")
1072
- .option("--vm-name <name>", "Target a specific VM (alternative to positional names)")
1073
- .option("--github-token <token>", "GitHub PAT for git pull on VM when repo is private (default: $GITHUB_TOKEN)")
1074
- .option("--up", "After update, run fops up to restart services")
1075
- .action(async (names, opts) => {
1076
- const { azureUpdate } = await import("./lib/azure.js");
1077
- const list = Array.isArray(names) ? names.filter(Boolean) : (names ? [names] : []);
1078
- await azureUpdate({
1079
- vmName: opts.vmName,
1080
- vmNames: list.length ? list : undefined,
1081
- githubToken: opts.githubToken,
1082
- up: opts.up,
1083
- });
1084
- });
1085
-
1086
- azure
1087
- .command("apply <file>")
1088
- .description("Apply a landscape file (FCL/HCL/YAML) to the remote VM's Foundation")
1089
- .option("--vm-name <name>", "Target VM (default: active VM)")
1090
- .option("--dry-run", "Preview changes without creating entities")
1091
- .action(async (file, opts) => {
1092
- const { azureApply } = await import("./lib/azure.js");
1093
- await azureApply(file, { vmName: opts.vmName, dryRun: opts.dryRun });
1094
- });
1095
-
1096
- azure
1097
- .command("download [name]")
1098
- .description("Pull all container images on a VM (docker compose pull)")
1099
- .option("--vm-name <name>", "Target VM (default: active VM)")
1100
- .action(async (name, opts) => {
1101
- const { lazyExeca, requireVmState, sshCmd, knockForVm } = await import("./lib/azure.js");
1102
- const execa = await lazyExeca();
1103
- const state = requireVmState(opts.vmName || name);
1104
- const ip = state.ip || state.publicIp;
1105
- const user = state.adminUser || "azureuser";
1106
- if (!ip) {
1107
- console.log(chalk.red(` ✗ No IP found for VM "${state.vmName}". Run: fops azure audit ${state.vmName}`));
1108
- return;
1109
- }
1110
- await knockForVm(state);
1111
- console.log(chalk.cyan(` Pulling images on ${state.vmName || name} (${ip})…`));
1112
- const { stdout, stderr, exitCode } = await sshCmd(
1113
- execa, ip, user,
1114
- "cd /opt/foundation-compose && make download",
1115
- 600000,
1116
- );
1117
- if (stdout) console.log(stdout);
1118
- if (exitCode !== 0) {
1119
- console.log(chalk.yellow(` ⚠ download exited with code ${exitCode}`));
1120
- if (stderr) console.log(chalk.dim(stderr.split("\n").slice(-5).join("\n")));
1121
- } else {
1122
- console.log(chalk.green(` ✓ Images pulled on ${state.vmName || name}`));
1123
- }
1124
- });
1125
-
1126
- azure
1127
- .command("pull [name]")
1128
- .description("Pull latest git code on a VM (no restart)")
1129
- .option("--vm-name <name>", "Target VM (default: active VM)")
1130
- .option("--branch <branch>", "Git branch to pull (default: current branch)")
1131
- .option("--github-token <token>", "GitHub PAT for private repos (default: $GITHUB_TOKEN)")
1132
- .action(async (name, opts) => {
1133
- const { azurePull } = await import("./lib/azure.js");
1134
- await azurePull({
1135
- vmName: opts.vmName || name,
1136
- branch: opts.branch,
1137
- githubToken: opts.githubToken,
1138
- });
1139
- });
1140
-
1141
- azure
1142
- .command("vm check [name]")
1143
- .description("Diagnose VM: show config versions and run make download with full output (for image-pull failures)")
1144
- .option("--vm-name <name>", "Target VM (default: active VM)")
1145
- .action(async (name, opts) => {
1146
- const { azureVmCheck } = await import("./lib/azure.js");
1147
- await azureVmCheck({ vmName: opts.vmName || name });
1148
- });
1149
-
1150
- // ── Fleet management ─────────────────────────────────────────────────
1151
-
1152
- azure
1153
- .command("exec <command>")
1154
- .description("Run a command on all tracked VMs in parallel")
1155
- .option("--vm-name <name>", "Target a specific VM instead of all")
1156
- .option("--timeout <seconds>", "Per-VM command timeout (default: 120)", "120")
1157
- .option("--quiet", "Show only summary, not command output")
1158
- .action(async (command, opts) => {
1159
- const { fleetExec } = await import("./lib/azure-fleet.js");
1160
- await fleetExec(command, { vmName: opts.vmName, timeout: Number(opts.timeout), quiet: opts.quiet });
1161
- });
1162
-
1163
- azure
1164
- .command("diff")
1165
- .description("Compare configuration across all VMs — detect drift")
1166
- .option("--vm-name <name>", "Target a specific VM instead of all")
1167
- .action(async (opts) => {
1168
- const { fleetDiff } = await import("./lib/azure-fleet.js");
1169
- await fleetDiff({ vmName: opts.vmName });
1170
- });
1171
-
1172
- azure
1173
- .command("rollout")
1174
- .description("Rolling deploy: pull, restart, health-check across VMs in batches")
1175
- .option("--vm-name <name>", "Target a specific VM instead of all")
1176
- .option("--batch <size>", "Number of VMs to deploy in parallel per batch (default: 1)", "1")
1177
- .option("--branch <branch>", "Git branch to deploy (default: main)")
1178
- .option("--health-timeout <seconds>", "Seconds to wait for healthy after restart (default: 120)", "120")
1179
- .option("--force", "Continue rolling out even if a batch fails")
1180
- .action(async (opts) => {
1181
- const { fleetRollout } = await import("./lib/azure-fleet.js");
1182
- await fleetRollout({
1183
- vmName: opts.vmName, batch: Number(opts.batch), branch: opts.branch,
1184
- healthTimeout: Number(opts.healthTimeout), force: opts.force,
1185
- });
1186
- });
1187
-
1188
- azure
1189
- .command("sync")
1190
- .description("Push local config files to all VMs")
1191
- .option("--vm-name <name>", "Target a specific VM instead of all")
1192
- .option("--files <files>", "Comma-separated list of files to sync (default: docker-compose.yaml,.env)", "docker-compose.yaml,.env")
1193
- .option("--restart", "Run fops up after syncing files")
1194
- .action(async (opts) => {
1195
- const { fleetSync } = await import("./lib/azure-fleet.js");
1196
- await fleetSync({ vmName: opts.vmName, files: opts.files.split(","), restart: opts.restart });
1197
- });
1198
-
1199
- azure
1200
- .command("health")
1201
- .description("Deep health report across all VMs — containers, disk, memory, load")
1202
- .option("--vm-name <name>", "Target a specific VM instead of all")
1203
- .action(async (opts) => {
1204
- const { fleetHealth } = await import("./lib/azure-fleet.js");
1205
- await fleetHealth({ vmName: opts.vmName });
1206
- });
1207
-
1208
- azure
1209
- .command("bootstrap [name]")
1210
- .description("Run `fops bootstrap` on a VM (create demo data mesh)")
1211
- .option("--vm-name <name>", "Target VM (default: active)")
1212
- .action(async (name, opts) => {
1213
- const vmName = opts.vmName || name;
1214
- const {
1215
- lazyExeca, ensureAzCli, ensureAzAuth,
1216
- requireVmState, sshCmd, knockForVm, DEFAULTS,
1217
- } = await import("./lib/azure.js");
1218
- const { closeKnock } = await import("./lib/port-knock.js");
1219
- const fs = await import("node:fs");
1220
- const os = await import("node:os");
1221
- const pathMod = await import("node:path");
1222
- const execa = await lazyExeca();
1223
- await ensureAzCli(execa);
1224
- await ensureAzAuth(execa);
1225
- const state = requireVmState(vmName);
1226
- const ip = state.publicIp;
1227
- const user = DEFAULTS.adminUser;
1228
-
1229
- if (!ip) {
1230
- console.error(chalk.red("\n No IP address. Is the VM running? Try: fops azure start\n"));
1231
- process.exit(1);
1232
- }
1233
-
1234
- // Load project root .env so credentials are available (same as fops bootstrap)
1235
- let projectRoot = null;
1236
- try {
1237
- const fopsPath = pathMod.join(os.homedir(), ".fops.json");
1238
- const raw = JSON.parse(fs.readFileSync(fopsPath, "utf8"));
1239
- if (raw?.projectRoot && fs.existsSync(pathMod.join(raw.projectRoot, ".env"))) projectRoot = raw.projectRoot;
1240
- } catch {}
1241
- if (!projectRoot) {
1242
- let dir = pathMod.resolve(process.cwd());
1243
- for (;;) {
1244
- if (fs.existsSync(pathMod.join(dir, "docker-compose.yaml")) && fs.existsSync(pathMod.join(dir, "Makefile"))) {
1245
- projectRoot = dir;
1246
- break;
1247
- }
1248
- const parent = pathMod.dirname(dir);
1249
- if (parent === dir) break;
1250
- dir = parent;
1251
- }
1252
- }
1253
- if (projectRoot) {
1254
- const { loadEnvFromFile } = await import("./lib/azure-helpers.js");
1255
- const loaded = loadEnvFromFile(pathMod.join(projectRoot, ".env"));
1256
- for (const [k, v] of Object.entries(loaded)) {
1257
- if (process.env[k] === undefined) process.env[k] = v;
1258
- }
1259
- }
1260
-
1261
- // Resolve local credentials: env → .env (project root) → ~/.fops.json
1262
- let qaUser = "", qaPass = "", bearerToken = "";
1263
- if (process.env.BEARER_TOKEN?.trim()) {
1264
- bearerToken = process.env.BEARER_TOKEN.trim();
1265
- } else if (process.env.QA_USERNAME?.trim() && process.env.QA_PASSWORD) {
1266
- qaUser = process.env.QA_USERNAME.trim();
1267
- qaPass = process.env.QA_PASSWORD;
1268
- }
1269
- if (!bearerToken && !qaUser) {
1270
- try {
1271
- const fopsPath = pathMod.join(os.homedir(), ".fops.json");
1272
- const raw = JSON.parse(fs.readFileSync(fopsPath, "utf8"));
1273
- const cfg = raw?.plugins?.entries?.["fops-plugin-foundation"]?.config || {};
1274
- if (cfg.bearerToken?.trim()) {
1275
- bearerToken = cfg.bearerToken.trim();
1276
- } else if (cfg.user?.trim() && cfg.password) {
1277
- qaUser = cfg.user.trim();
1278
- qaPass = cfg.password;
1279
- }
1280
- } catch { /* no fops.json */ }
1281
- }
1282
-
1283
- if (!bearerToken && !qaUser) {
1284
- console.error(chalk.red("\n No Foundation credentials found locally."));
1285
- console.error(chalk.dim(" Set BEARER_TOKEN or QA_USERNAME/QA_PASSWORD in env, .env, or ~/.fops.json\n"));
1286
- process.exit(1);
1287
- }
1288
-
1289
- // Pre-authenticate against the VM's API to get a bearer token
1290
- const vmUrl = state.publicUrl || `https://${ip}`;
1291
- let credsRejected = false; // true when the remote API explicitly returned 401
1292
- if (!bearerToken && qaUser) {
1293
- console.log(chalk.dim(` Pre-authenticating against ${vmUrl}…`));
1294
-
1295
- // When we have a domain-based public URL, only try that — direct
1296
- // IP:port fallbacks are firewalled on remote VMs and just waste time.
1297
- const hasDomain = state.publicUrl && !state.publicUrl.match(/^https?:\/\/\d+\.\d+\.\d+\.\d+/);
1298
- const apiUrls = hasDomain
1299
- ? [`${vmUrl}/api`]
1300
- : [`${vmUrl}/api`, `https://${ip}:3002/api`, `http://${ip}:9001/api`];
1301
-
1302
- for (const apiBase of apiUrls) {
1303
- try {
1304
- const resp = await fetch(`${apiBase}/iam/login`, {
1305
- method: "POST",
1306
- headers: { "Content-Type": "application/json" },
1307
- body: JSON.stringify({ username: qaUser, password: qaPass }),
1308
- signal: AbortSignal.timeout(10000),
1309
- });
1310
- if (resp.ok) {
1311
- const data = await resp.json();
1312
- bearerToken = data.access_token || data.token || "";
1313
- if (bearerToken) {
1314
- console.log(chalk.green(` ✓ Authenticated as ${qaUser} via ${apiBase}`));
1315
- break;
1316
- }
1317
- } else {
1318
- console.log(chalk.dim(` ${apiBase}: HTTP ${resp.status}`));
1319
- if (resp.status === 401) credsRejected = true;
1320
- }
1321
- } catch (e) {
1322
- console.log(chalk.dim(` ${apiBase}: ${e.cause?.code || e.message || "unreachable"}`));
1323
- }
1324
- }
1325
-
1326
- // Auth0 ROPC fallback — works when the backend's /iam/login rejects
1327
- // local credentials but Auth0 accepts them directly.
1328
- if (!bearerToken) {
1329
- const auth0Cfg = resolveAuth0Config();
1330
- if (auth0Cfg) {
1331
- try {
1332
- console.log(chalk.dim(` Trying Auth0 ROPC fallback (${auth0Cfg.domain})…`));
1333
- const body = {
1334
- grant_type: "password",
1335
- client_id: auth0Cfg.clientId,
1336
- username: qaUser,
1337
- password: qaPass,
1338
- scope: "openid",
1339
- };
1340
- if (auth0Cfg.clientSecret) body.client_secret = auth0Cfg.clientSecret;
1341
- if (auth0Cfg.audience) body.audience = auth0Cfg.audience;
1342
-
1343
- const resp = await fetch(`https://${auth0Cfg.domain}/oauth/token`, {
1344
- method: "POST",
1345
- headers: { "Content-Type": "application/json" },
1346
- body: JSON.stringify(body),
1347
- signal: AbortSignal.timeout(10000),
1348
- });
1349
- if (resp.ok) {
1350
- const data = await resp.json();
1351
- bearerToken = data.access_token || "";
1352
- if (bearerToken) {
1353
- credsRejected = false;
1354
- console.log(chalk.green(` ✓ Authenticated as ${qaUser} via Auth0`));
1355
- }
1356
- } else {
1357
- console.log(chalk.dim(` Auth0: HTTP ${resp.status}`));
1358
- }
1359
- } catch (e) {
1360
- console.log(chalk.dim(` Auth0 fallback: ${e.message || "failed"}`));
1361
- }
1362
- }
1363
- }
1364
- }
1365
-
1366
- // If the remote API explicitly rejected credentials (401), don't forward
1367
- // the same wrong user/pass to the VM — they'll fail there too.
1368
- if (credsRejected && !bearerToken) {
1369
- console.error(chalk.red("\n Credentials rejected by the remote API (HTTP 401)."));
1370
- console.error(chalk.dim(" The password for " + qaUser + " does not match this environment's Keycloak."));
1371
- console.error(chalk.dim(" Fix: set the correct password in ~/.fops.json or pass BEARER_TOKEN directly.\n"));
1372
- console.error(chalk.dim(" Hint: ssh into the VM and check the Keycloak password:"));
1373
- console.error(chalk.dim(" fops azure ssh " + (state.vmName || "") + '\n'));
1374
- process.exit(1);
1375
- }
1376
-
1377
- // Build env exports — prefer bearer token over user/pass
1378
- const creds = {};
1379
- if (bearerToken) {
1380
- creds.BEARER_TOKEN = bearerToken;
1381
- } else if (qaUser) {
1382
- creds.QA_USERNAME = qaUser;
1383
- creds.QA_PASSWORD = qaPass;
1384
- }
1385
-
1386
- if (!creds.BEARER_TOKEN && !creds.QA_USERNAME) {
1387
- console.error(chalk.red("\n Could not authenticate against the VM's API."));
1388
- console.error(chalk.dim(" Check that the VM is running (fops azure status) and credentials are valid.\n"));
1389
- process.exit(1);
1390
- }
1391
-
1392
- // Vars to persist to VM .env (bootstrap Python script loads .env and needs these)
1393
- const vmEnv = { ...creds };
1394
- if (process.env.AUTH0_EMAIL?.trim()) {
1395
- vmEnv.AUTH0_EMAIL = process.env.AUTH0_EMAIL.trim();
1396
- }
1397
-
1398
- await knockForVm(state);
1399
- const ssh = (cmd, timeout, sshEnv) => sshCmd(execa, ip, user, cmd, timeout, { env: sshEnv });
1400
-
1401
- // Persist credentials and AUTH0_EMAIL to the VM's .env so the Python script picks them up
1402
- const sedParts = ["sed -i '/^BEARER_TOKEN=/d;/^QA_USERNAME=/d;/^QA_PASSWORD=/d;/^AUTH0_EMAIL=/d' .env 2>/dev/null || true"];
1403
- for (const [k, v] of Object.entries(vmEnv)) {
1404
- const escaped = String(v).replace(/'/g, "'\\''");
1405
- sedParts.push(`echo '${k}=${escaped}' >> .env`);
1406
- }
1407
- await ssh(`cd /opt/foundation-compose && ${sedParts.join(" && ")}`, 15000, vmEnv);
1408
-
1409
- console.log(chalk.cyan(`\n Running fops bootstrap on "${state.vmName}"…\n`));
1410
- // Stream output in real-time. Use login shell so PATH has fops; fallback to common paths if not found.
1411
- const { MUX_OPTS } = await import("./lib/azure.js");
1412
- const bootstrapEnv = vmEnv ? { ...process.env, ...vmEnv } : undefined;
1413
- const remoteCmd = "cd /opt/foundation-compose && (fops bootstrap --yes || /usr/local/bin/fops bootstrap --yes || /usr/bin/fops bootstrap --yes)";
1414
- const { exitCode } = await execa("ssh", [
1415
- ...MUX_OPTS(ip, user),
1416
- `${user}@${ip}`,
1417
- remoteCmd,
1418
- ], {
1419
- timeout: 600000,
1420
- reject: false,
1421
- stdout: ["inherit"],
1422
- stderr: ["inherit"],
1423
- env: bootstrapEnv,
1424
- });
1425
-
1426
- if (state.knockSequence?.length) await closeKnock(ssh, { quiet: true });
1427
-
1428
- if (exitCode === 0) {
1429
- console.log(chalk.green("\n ✓ Bootstrap complete on VM\n"));
1430
- } else {
1431
- const normalized = exitCode === 255 || exitCode === -1 ? 1 : exitCode;
1432
- console.error(chalk.red(`\n Bootstrap failed (exit ${normalized}).\n`));
1433
- process.exitCode = 1;
1434
- }
1435
- });
1436
-
1437
- const test = azure
1438
- .command("test")
1439
- .description("Run and manage QA test results");
1440
-
1441
- test
1442
- .command("run [name]", { isDefault: true })
1443
- .description("Run QA automation tests locally against a remote VM")
1444
- .option("--vm-name <name>", "Target VM (default: active)")
1445
- .action(async (name, opts) => {
1446
- const { requireVmState, knockForVm } = await import("./lib/azure.js");
1447
- const { resolveCliSrc } = await import("./lib/azure-helpers.js");
1448
- const { rootDir } = await import(resolveCliSrc("project.js"));
1449
- const fsp = await import("node:fs/promises");
1450
- const path = await import("node:path");
1451
-
1452
- const state = requireVmState(opts.vmName || name);
1453
- const ip = state.publicIp;
1454
- if (!ip) {
1455
- console.error(chalk.red("\n No IP address. Is the VM running? Try: fops azure start\n"));
1456
- process.exit(1);
1457
- }
1458
-
1459
- const root = rootDir();
1460
- if (!root) {
1461
- console.error(chalk.red("\n Foundation project root not found. Run from the compose repo or set FOUNDATION_ROOT.\n"));
1462
- process.exit(1);
1463
- }
1464
-
1465
- const qaDir = path.join(root, "foundation-qa-automation");
1466
- try {
1467
- await fsp.access(qaDir);
1468
- } catch {
1469
- console.error(chalk.red("\n foundation-qa-automation/ not found in project root."));
1470
- console.error(chalk.dim(" Run: git submodule update --init\n"));
1471
- process.exit(1);
1472
- }
1473
-
1474
- const vmUrl = state.publicUrl || `https://${ip}`;
1475
- const apiUrl = `${vmUrl}/api`;
1476
- const { execa: execaFn } = await import("execa");
1477
- const { sshCmd, MUX_OPTS } = await import("./lib/azure.js");
1478
-
1479
- console.log(chalk.dim(` Authenticating against ${vmUrl}…`));
1480
- const auth = await resolveRemoteAuth({
1481
- apiUrl, ip, vmState: state,
1482
- execaFn, sshCmd, knockForVm, suppressTlsWarning,
1483
- });
1484
- let { bearerToken, qaUser, qaPass, useTokenMode } = auth;
1485
-
1486
- if (!bearerToken && !qaUser) {
1487
- console.error(chalk.red("\n No credentials found (local or remote)."));
1488
- console.error(chalk.dim(" Set BEARER_TOKEN or QA_USERNAME/QA_PASSWORD, or ensure the VM has Auth0 configured in .env\n"));
1489
- process.exit(1);
1490
- }
1491
-
1492
- // Write .env pointed at the remote VM
1493
- const envPath = path.join(qaDir, ".env");
1494
- const examplePath = path.join(qaDir, ".env.example");
1495
- let envContent;
1496
- try {
1497
- envContent = await fsp.readFile(examplePath, "utf8");
1498
- } catch {
1499
- envContent = await fsp.readFile(envPath, "utf8").catch(() => "");
1500
- }
1501
-
1502
- const setVar = (content, key, value) => {
1503
- const re = new RegExp(`^${key}=.*`, "m");
1504
- return re.test(content)
1505
- ? content.replace(re, `${key}=${value}`)
1506
- : content + `\n${key}=${value}`;
1507
- };
1508
-
1509
- envContent = setVar(envContent, "API_URL", apiUrl);
1510
- envContent = setVar(envContent, "DEV_API_URL", apiUrl);
1511
- envContent = setVar(envContent, "LIVE_API_URL", apiUrl);
1512
- envContent = setVar(envContent, "QA_USERNAME", qaUser);
1513
- envContent = setVar(envContent, "QA_PASSWORD", qaPass);
1514
- envContent = setVar(envContent, "QA_X_ACCOUNT", "root");
1515
- envContent = setVar(envContent, "ADMIN_USERNAME", qaUser);
1516
- envContent = setVar(envContent, "ADMIN_PASSWORD", qaPass);
1517
- envContent = setVar(envContent, "ADMIN_X_ACCOUNT", "root");
1518
- envContent = setVar(envContent, "OWNER_EMAIL", qaUser);
1519
- envContent = setVar(envContent, "OWNER_NAME", "Foundation Operator");
1520
- if (bearerToken) {
1521
- envContent = setVar(envContent, "BEARER_TOKEN", bearerToken);
1522
- envContent = setVar(envContent, "TOKEN_AUTH0", bearerToken);
1523
- }
1524
-
1525
- await fsp.writeFile(envPath, envContent);
1526
- console.log(chalk.green(` ✓ Configured QA .env → ${apiUrl}`));
1527
-
1528
- // Ensure venv + deps
1529
- try {
1530
- await fsp.access(path.join(qaDir, "venv"));
1531
- } catch {
1532
- console.log(chalk.cyan(" Setting up QA automation environment…"));
1533
- await execaFn("python3", ["-m", "venv", "venv"], { cwd: qaDir, stdio: "inherit" });
1534
- await execaFn("bash", ["-c", "source venv/bin/activate && pip install -r requirements.txt && playwright install"], { cwd: qaDir, stdio: "inherit" });
1535
- }
1536
-
1537
- // Knock to ensure VM is reachable
1538
- await knockForVm(state);
1539
-
1540
- const authMode = useTokenMode ? "bearer token (--use-token)" : `user/pass (${qaUser})`;
1541
- console.log(chalk.cyan(`\n Running QA tests against ${state.vmName} (${vmUrl}) [${authMode}]…\n`));
1542
- const pytestArgsList = [
1543
- "tests/",
1544
- "--env", "staging",
1545
- "--role", "FOUNDATION_ADMIN",
1546
- "--numprocesses=0",
1547
- "-v",
1548
- "--durations=0",
1549
- "--html=./playwright-report/report.html",
1550
- "--self-contained-html",
1551
- "--ignore=tests/e2e/landscape",
1552
- "--ignore=tests/e2e/procedures",
1553
- "--ignore=tests/e2e/upload_file_s3",
1554
- "--ignore=tests/roles",
1555
- "--ignore=tests/unit/data_product/test_data_product_consumers_v2.py",
1556
- "--ignore=tests/unit/data_product/test_data_product_query.py",
1557
- "--ignore=tests/unit/data_product/test_data_product_quality_v2.py",
1558
- "--ignore=tests/unit/data_product/test_data_product_schema_v2.py",
1559
- ];
1560
- if (useTokenMode) pytestArgsList.push("--use-token");
1561
- const pytestArgs = pytestArgsList.join(" ");
1562
-
1563
- const testEnv = {
1564
- ...process.env,
1565
- API_URL: apiUrl,
1566
- DEV_API_URL: apiUrl,
1567
- LIVE_API_URL: apiUrl,
1568
- ROLE_NAME: "FOUNDATION_ADMIN",
1569
- QA_USERNAME: qaUser,
1570
- QA_PASSWORD: qaPass,
1571
- CDO_USERNAME: qaUser,
1572
- CDO_PASSWORD: qaPass,
1573
- ADMIN_USERNAME: qaUser,
1574
- ADMIN_PASSWORD: qaPass,
1575
- QA_X_ACCOUNT: "root",
1576
- ADMIN_X_ACCOUNT: "root",
1577
- CDO_X_ACCOUNT: "root",
1578
- OWNER_EMAIL: qaUser,
1579
- OWNER_NAME: "Foundation Operator",
1580
- };
1581
- if (bearerToken) {
1582
- testEnv.BEARER_TOKEN = bearerToken;
1583
- testEnv.TOKEN_AUTH0 = bearerToken;
1584
- }
1585
-
1586
- const startMs = Date.now();
1587
- const proc = execaFn(
1588
- "bash",
1589
- ["-c", `source venv/bin/activate && pytest ${pytestArgs}`],
1590
- { cwd: qaDir, timeout: 600_000, reject: false, env: testEnv },
1591
- );
1592
- let captured = "";
1593
- proc.stdout?.on("data", (d) => { const s = d.toString(); captured += s; process.stdout.write(s); });
1594
- proc.stderr?.on("data", (d) => { const s = d.toString(); captured += s; process.stderr.write(s); });
1595
- const { exitCode } = await proc;
1596
- const wallSec = Math.round((Date.now() - startMs) / 1000);
1597
-
1598
- const counts = parsePytestSummary(captured);
1599
- const timing = parsePytestDurations(captured);
1600
- const { writeVmState } = await import("./lib/azure-state.js");
1601
- const qaResult = {
1602
- passed: exitCode === 0,
1603
- exitCode,
1604
- at: new Date().toISOString(),
1605
- ...(counts.passed != null && { numPassed: counts.passed }),
1606
- ...(counts.failed != null && { numFailed: counts.failed }),
1607
- ...(counts.error != null && { numErrors: counts.error }),
1608
- ...(counts.errors != null && { numErrors: counts.errors }),
1609
- ...(counts.skipped != null && { numSkipped: counts.skipped }),
1610
- durationSec: counts.durationSec || wallSec,
1611
- ...(timing && { timing }),
1612
- };
1613
- writeVmState(state.vmName, { qa: qaResult });
1614
-
1615
- if (exitCode === 0) {
1616
- console.log(chalk.green("\n ✓ QA tests passed\n"));
1617
- } else {
1618
- console.error(chalk.red(`\n QA tests failed (exit ${exitCode}).`));
1619
- console.error(chalk.dim(` Report: ${path.join(qaDir, "playwright-report", "report.html")}\n`));
1620
- process.exitCode = 1;
1621
- }
1622
-
1623
- try {
1624
- const { resultsPush } = await import("./lib/azure-results.js");
1625
- const enriched = { ...qaResult };
1626
- try {
1627
- const gitExeca = (await import("execa")).execa;
1628
- const { stdout: branch } = await gitExeca("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: qaDir, reject: false, timeout: 5000 });
1629
- const { stdout: sha } = await gitExeca("git", ["rev-parse", "--short", "HEAD"], { cwd: qaDir, reject: false, timeout: 5000 });
1630
- if (branch?.trim()) enriched.branch = branch.trim();
1631
- if (sha?.trim()) enriched.sha = sha.trim();
1632
- } catch {}
1633
- await resultsPush(state.vmName, enriched, { quiet: false });
1634
- } catch (pushErr) {
1635
- console.log(chalk.dim(` (Result push skipped: ${pushErr.message})`));
1636
- }
1637
- });
1638
-
1639
- test
1640
- .command("setup")
1641
- .description("Configure the Azure Blob Storage account for test results")
1642
- .option("--account <name>", "Storage account name")
1643
- .action(async (opts) => {
1644
- const { resultsSetup } = await import("./lib/azure-results.js");
1645
- await resultsSetup({ account: opts.account });
1646
- });
1647
-
1648
- test
1649
- .command("list [target]")
1650
- .description("List stored test results across VMs (optionally filter by name)")
1651
- .option("--last <n>", "Number of results to show", "20")
1652
- .action(async (target, opts) => {
1653
- const { resultsList } = await import("./lib/azure-results.js");
1654
- await resultsList({ target, last: parseInt(opts.last) });
1655
- });
1656
-
1657
- test
1658
- .command("show <target>")
1659
- .description("Show details of the latest test result for a VM/cluster")
1660
- .option("--run <id>", "Show a specific run by timestamp")
1661
- .action(async (target, opts) => {
1662
- const { resultsShow } = await import("./lib/azure-results.js");
1663
- await resultsShow({ target, run: opts.run });
1664
- });
1665
-
1666
- test
1667
- .command("compare [target]")
1668
- .description("Compare test runs across time — detect regressions and improvements")
1669
- .option("--last <n>", "Number of runs to compare", "10")
1670
- .action(async (target, opts) => {
1671
- const { resultsCompare } = await import("./lib/azure-results.js");
1672
- await resultsCompare({ target, last: parseInt(opts.last) });
1673
- });
1674
-
1675
- test
1676
- .command("push [target]")
1677
- .description("Push local QA results to blob storage (default: all VMs with results)")
1678
- .action(async (target) => {
1679
- const { readVmState, listVms } = await import("./lib/azure-state.js");
1680
- const { resultsPush } = await import("./lib/azure-results.js");
1681
-
1682
- const targets = [];
1683
- if (target) {
1684
- targets.push(target);
1685
- } else {
1686
- const { activeVm, vms } = listVms();
1687
- const names = Object.keys(vms);
1688
- if (names.length === 0) {
1689
- console.log(chalk.dim("\n No VMs tracked.\n"));
1690
- return;
1691
- }
1692
- for (const name of names) {
1693
- const st = readVmState(name);
1694
- if (st?.qa) targets.push(name);
1695
- }
1696
- if (targets.length === 0) {
1697
- console.log(chalk.dim("\n No local QA results found. Run tests first: fops azure test <vm>\n"));
1698
- return;
1699
- }
1700
- }
1701
-
1702
- let pushed = 0;
1703
- for (const t of targets) {
1704
- const state = readVmState(t);
1705
- if (!state?.qa) {
1706
- console.log(chalk.dim(` ${t}: no local QA result — skipped`));
1707
- continue;
1708
- }
1709
- await resultsPush(t, state.qa);
1710
- pushed++;
1711
- }
1712
- if (pushed === 0) {
1713
- console.log(chalk.dim("\n No results to push.\n"));
1714
- }
1715
- });
1716
-
1717
- azure
1718
- .command("scan [name]")
1719
- .description("Scan stack container images for CVEs using Trivy (same as fops scan)")
1720
- .option("--severity <levels>", "Comma-separated severity filter (default: CRITICAL,HIGH)", "CRITICAL,HIGH")
1721
- .option("--json", "Output raw JSON instead of table")
1722
- .option("--fix", "Show only vulnerabilities with a fix available")
1723
- .option("--service <name>", "Scan a specific compose service's image")
1724
- .action(async (name, opts) => {
1725
- const { resolveCliSrc } = await import("./lib/azure-helpers.js");
1726
- const { rootDir } = await import(resolveCliSrc("project.js"));
1727
- const root = rootDir();
1728
- if (!root) {
1729
- console.error(chalk.red("\n Foundation project root not found. Run from the compose repo or set FOUNDATION_ROOT.\n"));
1730
- process.exit(1);
1731
- }
1732
- const { runScan } = await import(resolveCliSrc("commands/lifecycle.js"));
1733
- await runScan(root, {
1734
- severity: opts.severity,
1735
- json: opts.json,
1736
- fix: opts.fix,
1737
- service: opts.service,
1738
- });
1739
- });
1740
-
1741
- azure
1742
- .command("snapshot [name]")
1743
- .description("Create Azure disk snapshots of all (or one) tracked VMs")
1744
- .option("--vm-name <name>", "Target a specific VM instead of all")
1745
- .option("--tag <tag>", "Snapshot tag/label (default: ISO timestamp)")
1746
- .option("--profile <subscription>", "Azure subscription name or ID")
1747
- .action(async (name, opts) => {
1748
- const { fleetSnapshot } = await import("./lib/azure-fleet.js");
1749
- await fleetSnapshot({ vmName: opts.vmName || name, tag: opts.tag, profile: opts.profile });
1750
- });
1751
-
1752
- const audit = azure
1753
- .command("audit")
1754
- .description("Security & compliance audit across Azure resources");
1755
-
1756
- audit
1757
- .command("all", { isDefault: true })
1758
- .description("Run all audit checks (VMs, AKS, storage encryption)")
1759
- .option("--profile <subscription>", "Azure subscription name or ID")
1760
- .option("--vm-name <name>", "Limit VM audit to a specific VM")
1761
- .option("--cluster <name>", "Limit AKS audit to a specific cluster")
1762
- .option("--account <name>", "Limit storage audit to a specific account")
1763
- .option("--verbose", "Show info-level suggestions alongside warnings")
1764
- .action(async (opts) => {
1765
- const { auditAll } = await import("./lib/azure-audit.js");
1766
- await auditAll({ profile: opts.profile, vmName: opts.vmName, clusterName: opts.cluster, account: opts.account, verbose: opts.verbose });
1767
- });
1768
-
1769
- audit
1770
- .command("vm [vmName]")
1771
- .description("Audit VM security — disk encryption, NSG rules, managed identity, patching")
1772
- .option("--profile <subscription>", "Azure subscription name or ID")
1773
- .option("--vm-name <name>", "Audit a specific VM (default: all tracked)")
1774
- .option("--verbose", "Show info-level suggestions alongside warnings")
1775
- .action(async (vmName, opts) => {
1776
- const { auditVms } = await import("./lib/azure-audit.js");
1777
- await auditVms({ profile: opts.profile, vmName: opts.vmName || vmName, verbose: opts.verbose });
1778
- });
1779
-
1780
- audit
1781
- .command("aks [clusterName]")
1782
- .description("Audit AKS cluster security — RBAC, network policy, auto-upgrade, Defender")
1783
- .option("--profile <subscription>", "Azure subscription name or ID")
1784
- .option("--verbose", "Show info-level suggestions alongside warnings")
1785
- .action(async (clusterName, opts) => {
1786
- const { auditAks } = await import("./lib/azure-audit.js");
1787
- await auditAks({ profile: opts.profile, clusterName, verbose: opts.verbose });
1788
- });
1789
-
1790
- audit
1791
- .command("storage")
1792
- .description("Audit storage account encryption & security posture")
1793
- .option("--profile <subscription>", "Azure subscription name or ID")
1794
- .option("--account <name>", "Audit a specific storage account (default: all)")
1795
- .option("--verbose", "Show info-level suggestions alongside warnings")
1796
- .action(async (opts) => {
1797
- const { auditStorage } = await import("./lib/azure-audit.js");
1798
- await auditStorage({ profile: opts.profile, account: opts.account, verbose: opts.verbose });
1799
- });
1800
-
1801
- audit
1802
- .command("sessions [vmName]")
1803
- .description("View SSH session recordings across the fleet")
1804
- .option("--session <id>", "Read a specific session by ID")
1805
- .option("--live", "Show only active (live) sessions")
1806
- .option("--cloud", "Read from blob storage instead of SSH")
1807
- .option("--push", "Push collected sessions to blob storage")
1808
- .option("--last <n>", "Show only the last N sessions", "50")
1809
- .option("--tail <n>", "When reading a session, show only the last N lines")
1810
- .action(async (vmName, opts) => {
1811
- const { fleetAudit } = await import("./lib/azure-fleet.js");
1812
- await fleetAudit({
1813
- vmName,
1814
- session: opts.session,
1815
- live: opts.live,
1816
- cloud: opts.cloud,
1817
- push: opts.push,
1818
- last: Number(opts.last),
1819
- tail: opts.tail ? Number(opts.tail) : undefined,
1820
- });
1821
- });
1822
-
1823
- audit
1824
- .command("zap [vmName]")
1825
- .description("Run an authenticated OWASP ZAP DAST scan against a VM's frontend")
1826
- .option("--vm-name <name>", "Target VM (default: active)")
1827
- .option("--target <url>", "Override target URL (default: https://<vm>.meshx.app)")
1828
- .option("--output <dir>", "Directory for JSON report (default: cwd)")
1829
- .option("--spider-minutes <n>", "Spider duration in minutes (default: 3)", "3")
1830
- .option("--ajax-minutes <n>", "Ajax spider duration in minutes (default: 3)", "3")
1831
- .option("--active-scan-minutes <n>", "Active scan rule timeout in minutes (default: 5)", "5")
1832
- .option("--max-minutes <n>", "Overall scan timeout in minutes (default: 20)", "20")
1833
- .option("--verbose", "Show fix suggestions for each finding")
1834
- .option("--aggressive", "Pentest mode: Penetration Tester policy, longer crawl/scan")
1835
- .action(async (vmName, opts) => {
1836
- const { auditZap } = await import("./lib/azure-audit.js");
1837
- await auditZap({
1838
- vmName: opts.vmName || vmName,
1839
- target: opts.target,
1840
- output: opts.output,
1841
- spiderMinutes: opts.aggressive ? undefined : (opts.spiderMinutes ? Number(opts.spiderMinutes) : undefined),
1842
- ajaxMinutes: opts.aggressive ? undefined : (opts.ajaxMinutes ? Number(opts.ajaxMinutes) : undefined),
1843
- activeScanMinutes: opts.aggressive ? undefined : (opts.activeScanMinutes ? Number(opts.activeScanMinutes) : undefined),
1844
- maxMinutes: opts.aggressive ? undefined : (opts.maxMinutes ? Number(opts.maxMinutes) : undefined),
1845
- verbose: opts.verbose,
1846
- aggressive: opts.aggressive,
1847
- });
1848
- });
1849
-
1850
- azure
1851
- .command("restore <name>")
1852
- .description("Restore a VM from an Azure disk snapshot")
1853
- .option("--snapshot <name>", "Snapshot name to restore from (omit to list available)")
1854
- .option("--profile <subscription>", "Azure subscription name or ID")
1855
- .option("--yes", "Skip confirmation prompt")
1856
- .action(async (name, opts) => {
1857
- const { fleetRestore } = await import("./lib/azure-fleet.js");
1858
- await fleetRestore({ vmName: name, snapshot: opts.snapshot, profile: opts.profile, yes: opts.yes });
1859
- });
1860
-
1861
- // ── Swarm subcommands ───────────────────────────────────────────
1862
-
1863
- const swarm = azure
1864
- .command("swarm")
1865
- .description("Docker Swarm cluster management");
1866
-
1867
- swarm
1868
- .command("init <vmName>")
1869
- .description("Initialize Docker Swarm on a VM (single-node manager)")
1870
- .option("--stack", "Also deploy the compose stack as swarm services")
1871
- .action(async (vmName, opts) => {
1872
- const { swarmInit } = await import("./lib/azure-fleet.js");
1873
- await swarmInit({ vmName, stack: opts.stack });
1874
- });
1875
-
1876
- swarm
1877
- .command("join <vmName>")
1878
- .description("Join a VM as a worker to an existing swarm (auto-creates the VM if it doesn't exist)")
1879
- .requiredOption("--manager <name>", "Manager VM to join")
1880
- .option("--as-manager", "Join as a manager instead of worker")
1881
- .option("--vm-size <size>", "VM size when auto-creating (default: inherited from manager)")
1882
- .option("--location <region>", "Azure region when auto-creating (default: inherited from manager)")
1883
- .option("--image <urn>", "Custom image URN when auto-creating")
1884
- .option("--url <url>", "Public URL override when auto-creating")
1885
- .option("--profile <subscription>", "Azure subscription when auto-creating")
1886
- .action(async (vmName, opts) => {
1887
- const { swarmJoin } = await import("./lib/azure-fleet.js");
1888
- await swarmJoin({
1889
- vmName, manager: opts.manager, asManager: opts.asManager,
1890
- vmSize: opts.vmSize, location: opts.location,
1891
- image: opts.image, url: opts.url, profile: opts.profile,
1892
- });
1893
- });
1894
-
1895
- swarm
1896
- .command("status [vmName]")
1897
- .description("Show swarm node and service status")
1898
- .action(async (vmName) => {
1899
- const { swarmStatus } = await import("./lib/azure-fleet.js");
1900
- await swarmStatus({ vmName });
1901
- });
1902
-
1903
- swarm
1904
- .command("promote <vmName>")
1905
- .description("Promote a swarm worker to manager")
1906
- .action(async (vmName) => {
1907
- const { swarmPromote } = await import("./lib/azure-fleet.js");
1908
- await swarmPromote({ vmName });
1909
- });
1910
-
1911
- swarm
1912
- .command("deploy [vmName]")
1913
- .description("Deploy the compose stack as swarm services (or update existing)")
1914
- .action(async (vmName) => {
1915
- const { swarmDeploy } = await import("./lib/azure-fleet.js");
1916
- await swarmDeploy({ vmName });
1917
- });
1918
-
1919
- swarm
1920
- .command("leave <vmName>")
1921
- .description("Remove a VM from the swarm")
1922
- .option("--force", "Force leave (required for managers)")
1923
- .action(async (vmName, opts) => {
1924
- const { swarmLeave } = await import("./lib/azure-fleet.js");
1925
- await swarmLeave({ vmName, force: opts.force });
1926
- });
1927
-
1928
- // ── Other VM commands ─────────────────────────────────────────────
1929
-
1930
- azure
1931
- .command("logs [name] [service]")
1932
- .description("Tail Foundation logs from the VM (service 'up' = tail /tmp/fops-up.log)")
1933
- .option("--vm-name <name>", "Target VM (default: active VM)")
1934
- .action(async (name, service, opts) => {
1935
- const { azureLogs } = await import("./lib/azure.js");
1936
- await azureLogs(service, { vmName: opts.vmName || name });
1937
- });
1938
-
1939
- azure
1940
- .command("context [name]")
1941
- .description("Set local Docker CLI to talk to the remote VM via SSH")
1942
- .option("--vm-name <name>", "Target VM (default: active VM)")
1943
- .option("--reset", "Switch back to the default Docker context")
1944
- .action(async (name, opts) => {
1945
- const { azureContext } = await import("./lib/azure.js");
1946
- await azureContext({ vmName: opts.vmName || name, reset: opts.reset });
1947
- });
1948
-
1949
- azure
1950
- .command("kubectl <name>")
1951
- .description("Generate / merge kubeconfig for an AKS cluster")
1952
- .option("--profile <subscription>", "Azure subscription name or ID")
1953
- .option("--admin", "Get admin credentials (cluster-admin role)")
1954
- .action(async (name, opts) => {
1955
- const { aksKubeconfig } = await import("./lib/azure-aks.js");
1956
- await aksKubeconfig({ clusterName: name, profile: opts.profile, admin: opts.admin });
1957
- });
1958
-
1959
- const grant = azure
1960
- .command("grant")
1961
- .description("Grant roles to users on the VM");
1962
-
1963
- grant
1964
- .command("admin [name]")
1965
- .description("Grant Foundation Admin role to users on the VM")
1966
- .option("--vm-name <name>", "Target VM (default: active VM)")
1967
- .option("--username <email>", "Grant to a specific user by email")
1968
- .option("--auth0-sub <sub>", "Grant to a specific Auth0 subject")
1969
- .action(async (name, opts) => {
1970
- const { azureGrantAdmin } = await import("./lib/azure.js");
1971
- await azureGrantAdmin({ vmName: opts.vmName || name, username: opts.username, auth0Sub: opts.auth0Sub });
1972
- });
1973
-
1974
- azure
1975
- .command("cost")
1976
- .description("Show current Azure costs by service")
1977
- .option("--profile <subscription>", "Azure subscription name or ID")
1978
- .option("--days <days>", "Days to look back (default: 30)", "30")
1979
- .action(async (opts) => {
1980
- const { azureCost } = await import("./lib/azure.js");
1981
- await azureCost({ profile: opts.profile, days: opts.days });
1982
- });
1983
-
1984
- azure
1985
- .command("subscriptions")
1986
- .description("List available Azure subscriptions")
1987
- .action(async () => {
1988
- const { azureSubscriptions } = await import("./lib/azure.js");
1989
- await azureSubscriptions();
1990
- });
1991
-
1992
- // ── Storage (blob management) ────────────────────────────────────────
1993
- const storage = azure
1994
- .command("storage")
1995
- .description("Manage Azure Blob Storage accounts, containers, and blobs");
1996
-
1997
- storage
1998
- .command("accounts")
1999
- .description("List storage accounts")
2000
- .option("--profile <subscription>", "Azure subscription name or ID")
2001
- .action(async (opts) => {
2002
- const { storageList } = await import("./lib/azure-storage.js");
2003
- await storageList({ profile: opts.profile });
2004
- });
2005
-
2006
- storage
2007
- .command("create <name>")
2008
- .description("Create a new storage account")
2009
- .option("--resource-group <name>", "Resource group (lists available if omitted)")
2010
- .requiredOption("--location <region>", "Azure region (e.g. eastus, westeurope, uaenorth)")
2011
- .option("--sku <sku>", "Replication SKU (default: Standard_LRS)", "Standard_LRS")
2012
- .option("--kind <kind>", "Account kind (default: StorageV2)", "StorageV2")
2013
- .option("--infra-encryption", "Enable infrastructure (double) encryption")
2014
- .option("--tags <tags>", "Space-separated tags: key1=val1 key2=val2")
2015
- .option("--profile <subscription>", "Azure subscription name or ID")
2016
- .action(async (name, opts) => {
2017
- const { storageCreate } = await import("./lib/azure-storage.js");
2018
- await storageCreate({
2019
- name, resourceGroup: opts.resourceGroup, location: opts.location,
2020
- sku: opts.sku, kind: opts.kind, infraEncryption: opts.infraEncryption,
2021
- tags: opts.tags, profile: opts.profile,
2022
- });
2023
- });
2024
-
2025
- const containers = storage
2026
- .command("container")
2027
- .description("Manage blob containers");
2028
-
2029
- containers
2030
- .command("list")
2031
- .description("List containers in a storage account")
2032
- .option("--account <name>", "Storage account (auto-detected if only one)")
2033
- .option("--profile <subscription>", "Azure subscription name or ID")
2034
- .action(async (opts) => {
2035
- const { containerList } = await import("./lib/azure-storage.js");
2036
- await containerList({ account: opts.account, profile: opts.profile });
2037
- });
2038
-
2039
- containers
2040
- .command("create <name>")
2041
- .description("Create a new container")
2042
- .option("--account <name>", "Storage account (auto-detected if only one)")
2043
- .option("--profile <subscription>", "Azure subscription name or ID")
2044
- .action(async (name, opts) => {
2045
- const { containerCreate } = await import("./lib/azure-storage.js");
2046
- await containerCreate({ account: opts.account, name, profile: opts.profile });
2047
- });
2048
-
2049
- containers
2050
- .command("delete <name>")
2051
- .description("Delete a container (asks for confirmation)")
2052
- .option("--account <name>", "Storage account (auto-detected if only one)")
2053
- .option("--profile <subscription>", "Azure subscription name or ID")
2054
- .action(async (name, opts) => {
2055
- const { containerDelete } = await import("./lib/azure-storage.js");
2056
- await containerDelete({ account: opts.account, name, profile: opts.profile });
2057
- });
2058
-
2059
- const blob = storage
2060
- .command("blob")
2061
- .description("Manage blobs in a container");
2062
-
2063
- blob
2064
- .command("list")
2065
- .description("List blobs in a container")
2066
- .option("--account <name>", "Storage account (auto-detected if only one)")
2067
- .requiredOption("--container <name>", "Container name")
2068
- .option("--prefix <prefix>", "Filter by blob name prefix")
2069
- .option("--profile <subscription>", "Azure subscription name or ID")
2070
- .action(async (opts) => {
2071
- const { blobList } = await import("./lib/azure-storage.js");
2072
- await blobList({ account: opts.account, container: opts.container, prefix: opts.prefix, profile: opts.profile });
2073
- });
2074
-
2075
- blob
2076
- .command("upload <file>")
2077
- .description("Upload a file as a blob")
2078
- .option("--account <name>", "Storage account (auto-detected if only one)")
2079
- .requiredOption("--container <name>", "Container name")
2080
- .option("--name <blob-name>", "Blob name (default: filename)")
2081
- .option("--overwrite", "Overwrite existing blob")
2082
- .option("--profile <subscription>", "Azure subscription name or ID")
2083
- .action(async (file, opts) => {
2084
- const { blobUpload } = await import("./lib/azure-storage.js");
2085
- await blobUpload({ account: opts.account, container: opts.container, file, name: opts.name, overwrite: opts.overwrite, profile: opts.profile });
2086
- });
2087
-
2088
- blob
2089
- .command("download <name>")
2090
- .description("Download a blob to a local file")
2091
- .option("--account <name>", "Storage account (auto-detected if only one)")
2092
- .requiredOption("--container <name>", "Container name")
2093
- .option("--dest <path>", "Local destination path (default: blob filename)")
2094
- .option("--profile <subscription>", "Azure subscription name or ID")
2095
- .action(async (name, opts) => {
2096
- const { blobDownload } = await import("./lib/azure-storage.js");
2097
- await blobDownload({ account: opts.account, container: opts.container, name, dest: opts.dest, profile: opts.profile });
2098
- });
2099
-
2100
- blob
2101
- .command("delete <name>")
2102
- .description("Delete a blob (asks for confirmation)")
2103
- .option("--account <name>", "Storage account (auto-detected if only one)")
2104
- .requiredOption("--container <name>", "Container name")
2105
- .option("--profile <subscription>", "Azure subscription name or ID")
2106
- .action(async (name, opts) => {
2107
- const { blobDelete } = await import("./lib/azure-storage.js");
2108
- await blobDelete({ account: opts.account, container: opts.container, name, profile: opts.profile });
2109
- });
2110
-
2111
- blob
2112
- .command("url <name>")
2113
- .description("Generate a temporary SAS URL for a blob")
2114
- .option("--account <name>", "Storage account (auto-detected if only one)")
2115
- .requiredOption("--container <name>", "Container name")
2116
- .option("--expiry <hours>", "Link expiry in hours (default: 24)", "24")
2117
- .option("--profile <subscription>", "Azure subscription name or ID")
2118
- .action(async (name, opts) => {
2119
- const { blobUrl } = await import("./lib/azure-storage.js");
2120
- await blobUrl({ account: opts.account, container: opts.container, name, expiry: opts.expiry, profile: opts.profile });
2121
- });
2122
-
2123
- // ── Encryption ─────────────────────────────────────────────────────
2124
- const encryption = storage
2125
- .command("encryption")
2126
- .description("Show or configure storage account encryption settings");
2127
-
2128
- encryption
2129
- .command("show", { isDefault: true })
2130
- .description("Show encryption posture for a storage account")
2131
- .option("--account <name>", "Storage account (auto-detected if only one)")
2132
- .option("--profile <subscription>", "Azure subscription name or ID")
2133
- .action(async (opts) => {
2134
- const { storageEncryption } = await import("./lib/azure-storage.js");
2135
- await storageEncryption({ account: opts.account, profile: opts.profile });
2136
- });
2137
-
2138
- encryption
2139
- .command("configure")
2140
- .description("Configure encryption and security settings")
2141
- .option("--account <name>", "Storage account (auto-detected if only one)")
2142
- .option("--profile <subscription>", "Azure subscription name or ID")
2143
- .option("--https-only", "Enforce HTTPS-only traffic")
2144
- .option("--min-tls <version>", "Minimum TLS version (TLS1_0, TLS1_1, TLS1_2)")
2145
- .option("--disable-shared-key", "Disable shared key auth (force AAD-only)")
2146
- .option("--enable-shared-key", "Re-enable shared key auth")
2147
- .option("--cmk", "Use customer-managed key (requires --key-vault, --key-name)")
2148
- .option("--key-vault <uri>", "Key Vault URI for CMK")
2149
- .option("--key-name <name>", "Key name in Key Vault")
2150
- .option("--key-version <version>", "Key version (default: latest)")
2151
- .option("--microsoft-keys", "Revert to Microsoft-managed keys")
2152
- .option("--infra-encryption", "Check/recommend infrastructure (double) encryption")
2153
- .option("--soft-delete <days>", "Enable blob soft delete (0 to disable)")
2154
- .option("--versioning", "Enable blob versioning")
2155
- .option("--no-versioning", "Disable blob versioning")
2156
- .option("--disable-blob-public", "Block anonymous read access on all containers")
2157
- .option("--deny-public-access", "Firewall: deny all networks except allowed IPs/VNets")
2158
- .option("--allow-public-access", "Firewall: allow all networks (remove restriction)")
2159
- .action(async (opts) => {
2160
- const { storageEncryptionConfigure } = await import("./lib/azure-storage.js");
2161
- await storageEncryptionConfigure({
2162
- account: opts.account, profile: opts.profile,
2163
- httpsOnly: opts.httpsOnly, minTls: opts.minTls,
2164
- disableSharedKey: opts.disableSharedKey, enableSharedKey: opts.enableSharedKey,
2165
- cmk: opts.cmk, keyVault: opts.keyVault, keyName: opts.keyName, keyVersion: opts.keyVersion,
2166
- microsoftKeys: opts.microsoftKeys, infraEncryption: opts.infraEncryption,
2167
- softDelete: opts.softDelete, versioning: opts.versioning,
2168
- disableBlobPublic: opts.disableBlobPublic,
2169
- denyPublicAccess: opts.denyPublicAccess, allowPublicAccess: opts.allowPublicAccess,
2170
- });
2171
- });
2172
-
2173
- // ── Azure OpenAI (Cognitive Services) ───────────────────────────────
2174
- const openai = azure
2175
- .command("openai")
2176
- .description("Create/list Azure OpenAI resources (e.g. in uaenorth so VMs can use them)");
2177
-
2178
- openai
2179
- .command("list", { isDefault: true })
2180
- .description("List Azure OpenAI resources in the subscription")
2181
- .option("--profile <subscription>", "Azure subscription name or ID")
2182
- .action(async (opts) => {
2183
- const { openaiList } = await import("./lib/azure-openai.js");
2184
- await openaiList({ profile: opts.profile });
2185
- });
2186
-
2187
- openai
2188
- .command("create <name>")
2189
- .description("Create an Azure OpenAI resource in a region (default: uaenorth)")
2190
- .requiredOption("--resource-group <name>", "Resource group (create with: az group create --name <name> --location uaenorth)")
2191
- .option("--location <region>", "Azure region (default: uaenorth)", "uaenorth")
2192
- .option("--sku <sku>", "SKU (default: S0)", "S0")
2193
- .option("--profile <subscription>", "Azure subscription name or ID")
2194
- .action(async (name, opts) => {
2195
- const { openaiCreate } = await import("./lib/azure-openai.js");
2196
- await openaiCreate({
2197
- name,
2198
- resourceGroup: opts.resourceGroup,
2199
- location: opts.location,
2200
- sku: opts.sku,
2201
- profile: opts.profile,
2202
- });
2203
- });
2204
-
2205
- openai
2206
- .command("allowlist-me")
2207
- .description("Add an IP to Azure OpenAI resource allowlists (uses az cli)")
2208
- .option("--profile <subscription>", "Azure subscription name or ID")
2209
- .option("--all", "Allowlist on every OpenAI/AIServices resource across all subscriptions")
2210
- .option("--name <resource>", "Target a specific Azure OpenAI resource by name")
2211
- .option("--resource-group <rg>", "Resource group (auto-detected if omitted)")
2212
- .option("--ip <address>", "IP address to allowlist (auto-detected if omitted)")
2213
- .action(async (opts) => {
2214
- const { openaiAllowlistMe } = await import("./lib/azure-openai.js");
2215
- await openaiAllowlistMe({
2216
- profile: opts.profile,
2217
- all: opts.all,
2218
- name: opts.name,
2219
- resourceGroup: opts.resourceGroup,
2220
- ip: opts.ip,
2221
- });
2222
- });
2223
-
2224
- openai
2225
- .command("debug-vm [vmName] [run]")
2226
- .description("Run a single agent request on the VM with DEBUG=1 to capture Azure OpenAI connection errors")
2227
- .option("--vm-name <name>", "Target VM (default: active VM)")
2228
- .option("--agent <name>", "Agent name (default: foundation)", "foundation")
2229
- .option("--profile <subscription>", "Azure subscription name or ID")
2230
- .action(async (vmName, _run, opts) => {
2231
- const name = vmName === "run" ? undefined : (vmName || opts.vmName);
2232
- const { azureOpenAiDebugVm } = await import("./lib/azure.js");
2233
- await azureOpenAiDebugVm({
2234
- vmName: name,
2235
- agent: opts.agent,
2236
- profile: opts.profile,
2237
- });
2238
- });
2239
-
2240
- // ── Key Vault ────────────────────────────────────────────────────────
2241
- const keyvault = azure
2242
- .command("keyvault")
2243
- .description("Manage Azure Key Vaults and secrets");
2244
-
2245
- keyvault
2246
- .command("list", { isDefault: true })
2247
- .description("List Key Vaults in the subscription")
2248
- .option("--profile <subscription>", "Azure subscription name or ID")
2249
- .action(async (opts) => {
2250
- const { keyvaultList } = await import("./lib/azure-keyvault.js");
2251
- await keyvaultList({ profile: opts.profile });
2252
- });
2253
-
2254
- keyvault
2255
- .command("show")
2256
- .description("Show Key Vault details and object counts")
2257
- .option("--vault <name>", "Key Vault name (auto-detected if only one)")
2258
- .option("--profile <subscription>", "Azure subscription name or ID")
2259
- .action(async (opts) => {
2260
- const { keyvaultShow } = await import("./lib/azure-keyvault.js");
2261
- await keyvaultShow({ vault: opts.vault, profile: opts.profile });
2262
- });
2263
-
2264
- keyvault
2265
- .command("create <name>")
2266
- .description("Create a new Key Vault")
2267
- .option("--resource-group <name>", "Resource group (lists available if omitted)")
2268
- .requiredOption("--location <region>", "Azure region (e.g. eastus, westeurope, uaenorth)")
2269
- .option("--sku <sku>", "SKU: standard or premium (default: standard)", "standard")
2270
- .option("--enable-purge-protection", "Enable purge protection (irreversible)")
2271
- .option("--retention-days <days>", "Soft-delete retention days (default: 90)")
2272
- .option("--profile <subscription>", "Azure subscription name or ID")
2273
- .action(async (name, opts) => {
2274
- const { keyvaultCreate } = await import("./lib/azure-keyvault.js");
2275
- await keyvaultCreate({
2276
- name, resourceGroup: opts.resourceGroup, location: opts.location,
2277
- sku: opts.sku, enablePurgeProtection: opts.enablePurgeProtection,
2278
- retentionDays: opts.retentionDays, profile: opts.profile,
2279
- });
2280
- });
2281
-
2282
- keyvault
2283
- .command("delete")
2284
- .description("Delete a Key Vault (soft-delete)")
2285
- .option("--vault <name>", "Key Vault name (auto-detected if only one)")
2286
- .option("--purge", "Permanently purge after deletion (requires purge protection off)")
2287
- .option("--profile <subscription>", "Azure subscription name or ID")
2288
- .action(async (opts) => {
2289
- const { keyvaultDelete } = await import("./lib/azure-keyvault.js");
2290
- await keyvaultDelete({ vault: opts.vault, purge: opts.purge, profile: opts.profile });
2291
- });
2292
-
2293
- keyvault
2294
- .command("sync")
2295
- .description("Pull secrets from Key Vault into .env files (reads .env.keyvault templates)")
2296
- .option("--vault <name>", "Override default vault for all references")
2297
- .option("--profile <subscription>", "Azure subscription name or ID")
2298
- .action(async (opts) => {
2299
- const { keyvaultSync } = await import("./lib/azure-keyvault.js");
2300
- await keyvaultSync({ vault: opts.vault, profile: opts.profile });
2301
- });
2302
-
2303
- keyvault
2304
- .command("setup")
2305
- .description("Interactive setup: pick vault, set auto-sync, scaffold .env.keyvault templates")
2306
- .option("--profile <subscription>", "Azure subscription name or ID")
2307
- .action(async (opts) => {
2308
- const { keyvaultSetup } = await import("./lib/azure-keyvault.js");
2309
- await keyvaultSetup({ profile: opts.profile });
2310
- });
2311
-
2312
- keyvault
2313
- .command("status")
2314
- .description("Show Key Vault sync status: auth, config, discovered templates")
2315
- .option("--profile <subscription>", "Azure subscription name or ID")
2316
- .action(async (opts) => {
2317
- const { keyvaultStatus } = await import("./lib/azure-keyvault.js");
2318
- await keyvaultStatus({ profile: opts.profile });
2319
- });
2320
-
2321
- const kvSecret = keyvault
2322
- .command("secret")
2323
- .description("Manage secrets in a Key Vault");
2324
-
2325
- kvSecret
2326
- .command("list", { isDefault: true })
2327
- .description("List secrets in a Key Vault")
2328
- .option("--vault <name>", "Key Vault name (auto-detected if only one)")
2329
- .option("--include-managed", "Include managed (certificate-linked) secrets")
2330
- .option("--profile <subscription>", "Azure subscription name or ID")
2331
- .action(async (opts) => {
2332
- const { secretList } = await import("./lib/azure-keyvault.js");
2333
- await secretList({ vault: opts.vault, includeManaged: opts.includeManaged, profile: opts.profile });
2334
- });
2335
-
2336
- kvSecret
2337
- .command("show <name>")
2338
- .description("Show secret metadata (add --show-value to reveal)")
2339
- .option("--vault <name>", "Key Vault name (auto-detected if only one)")
2340
- .option("--version <version>", "Specific secret version (default: latest)")
2341
- .option("--show-value", "Display the secret value")
2342
- .option("--profile <subscription>", "Azure subscription name or ID")
2343
- .action(async (name, opts) => {
2344
- const { secretShow } = await import("./lib/azure-keyvault.js");
2345
- await secretShow({ vault: opts.vault, name, version: opts.version, showValue: opts.showValue, profile: opts.profile });
2346
- });
2347
-
2348
- kvSecret
2349
- .command("set <name>")
2350
- .description("Set a secret value (creates or updates)")
2351
- .option("--vault <name>", "Key Vault name (auto-detected if only one)")
2352
- .option("--value <value>", "Secret value (use --file for binary/large)")
2353
- .option("--file <path>", "Read secret value from a file")
2354
- .option("--content-type <type>", "Content type (e.g. text/plain, application/json)")
2355
- .option("--expires <datetime>", "Expiration date (ISO 8601)")
2356
- .option("--disabled", "Create the secret in disabled state")
2357
- .option("--profile <subscription>", "Azure subscription name or ID")
2358
- .action(async (name, opts) => {
2359
- const { secretSet } = await import("./lib/azure-keyvault.js");
2360
- await secretSet({
2361
- vault: opts.vault, name, value: opts.value, file: opts.file,
2362
- contentType: opts.contentType, expires: opts.expires,
2363
- disabled: opts.disabled, profile: opts.profile,
2364
- });
2365
- });
2366
-
2367
- kvSecret
2368
- .command("delete <name>")
2369
- .description("Delete a secret (soft-delete, recoverable)")
2370
- .option("--vault <name>", "Key Vault name (auto-detected if only one)")
2371
- .option("--profile <subscription>", "Azure subscription name or ID")
2372
- .action(async (name, opts) => {
2373
- const { secretDelete } = await import("./lib/azure-keyvault.js");
2374
- await secretDelete({ vault: opts.vault, name, profile: opts.profile });
2375
- });
2376
-
2377
- const kvKey = keyvault
2378
- .command("key")
2379
- .description("Manage cryptographic keys in a Key Vault");
2380
-
2381
- kvKey
2382
- .command("list", { isDefault: true })
2383
- .description("List keys in a Key Vault")
2384
- .option("--vault <name>", "Key Vault name (auto-detected if only one)")
2385
- .option("--profile <subscription>", "Azure subscription name or ID")
2386
- .action(async (opts) => {
2387
- const { keyList } = await import("./lib/azure-keyvault.js");
2388
- await keyList({ vault: opts.vault, profile: opts.profile });
2389
- });
2390
-
2391
- kvKey
2392
- .command("show <name>")
2393
- .description("Show key details and rotation policy")
2394
- .option("--vault <name>", "Key Vault name (auto-detected if only one)")
2395
- .option("--profile <subscription>", "Azure subscription name or ID")
2396
- .action(async (name, opts) => {
2397
- const { keyShow } = await import("./lib/azure-keyvault.js");
2398
- await keyShow({ vault: opts.vault, name, profile: opts.profile });
2399
- });
2400
-
2401
- kvKey
2402
- .command("create <name>")
2403
- .description("Create a key with optional auto-rotation policy")
2404
- .option("--vault <name>", "Key Vault name (auto-detected if only one)")
2405
- .option("--type <kty>", "Key type: RSA, RSA-HSM, EC, EC-HSM, oct, oct-HSM (default: RSA)", "RSA")
2406
- .option("--size <bits>", "RSA key size: 2048, 3072, 4096 (default: 2048)", "2048")
2407
- .option("--curve <curve>", "EC curve: P-256, P-384, P-521 (default: P-256)")
2408
- .option("--ops <operations>", "Space-separated key operations (encrypt decrypt sign verify wrapKey unwrapKey)")
2409
- .option("--rotate-every <duration>", "Auto-rotation interval as ISO 8601 duration (e.g. P90D, P6M, P1Y)")
2410
- .option("--expires-in <duration>", "Key expiry as ISO 8601 duration (e.g. P1Y, P2Y)")
2411
- .option("--notify-before <duration>", "Notify before expiry (default: P30D)")
2412
- .option("--disabled", "Create the key in disabled state")
2413
- .option("--profile <subscription>", "Azure subscription name or ID")
2414
- .action(async (name, opts) => {
2415
- const { keyCreate } = await import("./lib/azure-keyvault.js");
2416
- await keyCreate({
2417
- vault: opts.vault, name, type: opts.type, size: opts.size,
2418
- curve: opts.curve, ops: opts.ops, rotateEvery: opts.rotateEvery,
2419
- expiresIn: opts.expiresIn, notifyBefore: opts.notifyBefore,
2420
- disabled: opts.disabled, profile: opts.profile,
2421
- });
2422
- });
2423
-
2424
- kvKey
2425
- .command("rotate <name>")
2426
- .description("Rotate a key on-demand (creates a new version)")
2427
- .option("--vault <name>", "Key Vault name (auto-detected if only one)")
2428
- .option("--profile <subscription>", "Azure subscription name or ID")
2429
- .action(async (name, opts) => {
2430
- const { keyRotate } = await import("./lib/azure-keyvault.js");
2431
- await keyRotate({ vault: opts.vault, name, profile: opts.profile });
2432
- });
2433
-
2434
- const kvRotationPolicy = kvKey
2435
- .command("rotation-policy")
2436
- .description("Manage key auto-rotation policy");
2437
-
2438
- kvRotationPolicy
2439
- .command("show <name>")
2440
- .description("Show the rotation policy for a key")
2441
- .option("--vault <name>", "Key Vault name (auto-detected if only one)")
2442
- .option("--profile <subscription>", "Azure subscription name or ID")
2443
- .action(async (name, opts) => {
2444
- const { keyRotationPolicyShow } = await import("./lib/azure-keyvault.js");
2445
- await keyRotationPolicyShow({ vault: opts.vault, name, profile: opts.profile });
2446
- });
2447
-
2448
- kvRotationPolicy
2449
- .command("set <name>")
2450
- .description("Set auto-rotation policy for a key")
2451
- .option("--vault <name>", "Key Vault name (auto-detected if only one)")
2452
- .requiredOption("--rotate-every <duration>", "Rotation interval (ISO 8601: P30D, P90D, P6M, P1Y)")
2453
- .option("--expires-in <duration>", "Key expiry duration (ISO 8601: P1Y, P2Y)")
2454
- .option("--notify-before <duration>", "Notify before expiry (default: P30D)")
2455
- .option("--profile <subscription>", "Azure subscription name or ID")
2456
- .action(async (name, opts) => {
2457
- const { keyRotationPolicySet } = await import("./lib/azure-keyvault.js");
2458
- await keyRotationPolicySet({
2459
- vault: opts.vault, name, rotateEvery: opts.rotateEvery,
2460
- expiresIn: opts.expiresIn, notifyBefore: opts.notifyBefore,
2461
- profile: opts.profile,
2462
- });
2463
- });
2464
-
2465
- // ── AKS (Azure Kubernetes Service) ──────────────────────────────────
2466
- const aks = azure
2467
- .command("aks")
2468
- .description("Manage Foundation on AKS clusters (bootstrapped via Flux)");
2469
-
2470
- aks
2471
- .command("up [name]")
2472
- .description("Create an AKS cluster, create GHCR pull secret, and bootstrap Flux (meshxdata/flux)")
2473
- .option("--profile <subscription>", "Azure subscription name or ID")
2474
- .option("--location <region>", "Azure region (default: uaenorth)")
2475
- .option("--node-count <count>", "Initial node count (default: 3)")
2476
- .option("--min-count <count>", "Autoscaler min nodes (default: 1)")
2477
- .option("--max-count <count>", "Autoscaler max nodes (default: 5)")
2478
- .option("--node-vm-size <size>", "Node VM size (default: Standard_D8s_v3)")
2479
- .option("--kubernetes-version <version>", "K8s version (default: 1.30)")
2480
- .option("--tier <tier>", "AKS tier: free | standard | premium (default: standard)")
2481
- .option("--network-plugin <plugin>", "Network plugin: azure | kubenet (default: azure)")
2482
- .option("--max-pods <count>", "Max pods per node (default: 110)")
2483
- .option("--resource-group <name>", "Resource group (default: foundation-aks-rg)")
2484
- .option("--flux-repo <repo>", "GitHub repo for Flux (default: flux)")
2485
- .option("--flux-owner <owner>", "GitHub owner/org for Flux (default: meshxdata)")
2486
- .option("--flux-path <path>", "Path in repo for cluster manifests (default: clusters/<name>)")
2487
- .option("--flux-branch <branch>", "Git branch for Flux (default: main)")
2488
- .option("--github-token <token>", "GitHub PAT for Flux + GHCR pull (default: $GITHUB_TOKEN)")
2489
- .option("--no-flux", "Skip Flux bootstrap")
2490
- .option("--no-postgres", "Skip Postgres Flexible Server provisioning")
2491
-
2492
- .option("--dai", "Include DAI (Dashboards AI) workloads")
2493
- .action(async (name, opts) => {
2494
- const { aksUp } = await import("./lib/azure-aks.js");
2495
- await aksUp({
2496
- clusterName: name, profile: opts.profile, location: opts.location,
2497
- nodeCount: opts.nodeCount ? Number(opts.nodeCount) : undefined,
2498
- minCount: opts.minCount ? Number(opts.minCount) : undefined,
2499
- maxCount: opts.maxCount ? Number(opts.maxCount) : undefined,
2500
- nodeVmSize: opts.nodeVmSize, kubernetesVersion: opts.kubernetesVersion,
2501
- tier: opts.tier, networkPlugin: opts.networkPlugin, maxPods: opts.maxPods ? Number(opts.maxPods) : undefined,
2502
- resourceGroup: opts.resourceGroup,
2503
- fluxRepo: opts.fluxRepo, fluxOwner: opts.fluxOwner,
2504
- fluxPath: opts.fluxPath, fluxBranch: opts.fluxBranch,
2505
- githubToken: opts.githubToken,
2506
-
2507
- noFlux: opts.flux === false,
2508
- noPostgres: opts.postgres === false,
2509
- dai: opts.dai === true,
2510
- });
2511
- });
2512
-
2513
- aks
2514
- .command("down [name]")
2515
- .description("Destroy the AKS cluster and all workloads")
2516
- .option("--profile <subscription>", "Azure subscription name or ID")
2517
- .option("--yes", "Skip confirmation prompt")
2518
- .action(async (name, opts) => {
2519
- const { aksDown } = await import("./lib/azure-aks.js");
2520
- await aksDown({ clusterName: name, profile: opts.profile, yes: opts.yes });
2521
- });
2522
-
2523
- aks
2524
- .command("list")
2525
- .description("List all tracked AKS clusters")
2526
- .option("--profile <subscription>", "Azure subscription name or ID")
2527
- .action(async (opts) => {
2528
- const { aksList } = await import("./lib/azure-aks.js");
2529
- await aksList({ profile: opts.profile });
2530
- });
2531
-
2532
- aks
2533
- .command("status [name]")
2534
- .description("Show cluster state, node pools, and Flux health")
2535
- .option("--profile <subscription>", "Azure subscription name or ID")
2536
- .action(async (name, opts) => {
2537
- const { aksStatus } = await import("./lib/azure-aks.js");
2538
- await aksStatus({ clusterName: name, profile: opts.profile });
2539
- });
2540
-
2541
- const aksConfig = aks
2542
- .command("config")
2543
- .description("Manage service versions on the AKS cluster");
2544
-
2545
- aksConfig
2546
- .command("versions [name]", { isDefault: true })
2547
- .description("Show Foundation service image tags running on the cluster")
2548
- .action(async (name) => {
2549
- const { aksConfigVersions } = await import("./lib/azure-aks.js");
2550
- await aksConfigVersions({ clusterName: name });
2551
- });
2552
-
2553
- aks
2554
- .command("terraform [name]")
2555
- .description("Generate Terraform HCL for the AKS cluster and its resources")
2556
- .option("--profile <subscription>", "Azure subscription name or ID")
2557
- .option("--output <file>", "Write HCL to a file instead of stdout")
2558
- .action(async (name, opts) => {
2559
- const { aksTerraform } = await import("./lib/azure-aks.js");
2560
- await aksTerraform({ clusterName: name, profile: opts.profile, output: opts.output });
2561
- });
2562
-
2563
- aks
2564
- .command("test [name]")
2565
- .description("Run QA automation tests locally against an AKS cluster")
2566
- .action(async (name) => {
2567
- const { readClusterState, writeClusterState, clusterDomain } = await import("./lib/azure-aks.js");
2568
- const { resolveCliSrc } = await import("./lib/azure-helpers.js");
2569
- const { rootDir } = await import(resolveCliSrc("project.js"));
2570
- const fsp = await import("node:fs/promises");
2571
- const path = await import("node:path");
2572
-
2573
- const cluster = readClusterState(name);
2574
- if (!cluster?.clusterName) {
2575
- console.error(chalk.red(`\n No AKS cluster tracked: "${name || "(none active)"}"`));
2576
- console.error(chalk.dim(" Create one: fops azure aks up <name>\n"));
2577
- process.exit(1);
2578
- }
2579
-
2580
- const domain = cluster.domain || clusterDomain(cluster.clusterName);
2581
- const baseUrl = `https://${domain}`;
2582
- const apiUrl = `${baseUrl}/api`;
2583
-
2584
- const root = rootDir();
2585
- if (!root) {
2586
- console.error(chalk.red("\n Foundation project root not found. Run from the compose repo or set FOUNDATION_ROOT.\n"));
2587
- process.exit(1);
2588
- }
2589
-
2590
- const qaDir = path.join(root, "foundation-qa-automation");
2591
- try { await fsp.access(qaDir); } catch {
2592
- console.error(chalk.red("\n foundation-qa-automation/ not found in project root."));
2593
- console.error(chalk.dim(" Run: git submodule update --init\n"));
2594
- process.exit(1);
2595
- }
2596
-
2597
- const { execa: execaFn } = await import("execa");
2598
-
2599
- // For AKS: use a tracked VM for SSH fallback
2600
- const { listVms: lv, sshCmd: sc, knockForVm: kfv } = await import("./lib/azure.js");
2601
- const { vms: allVms } = lv();
2602
- const vmEntry = Object.entries(allVms).find(([, v]) => v.publicIp);
2603
-
2604
- console.log(chalk.dim(` Authenticating against ${apiUrl}…`));
2605
- const auth = await resolveRemoteAuth({
2606
- apiUrl,
2607
- ip: vmEntry?.[1]?.publicIp,
2608
- vmState: vmEntry?.[1],
2609
- execaFn, sshCmd: sc, knockForVm: kfv, suppressTlsWarning,
2610
- });
2611
- let { bearerToken, qaUser, qaPass, useTokenMode } = auth;
2612
-
2613
- if (!bearerToken && !qaUser) {
2614
- console.error(chalk.red("\n No credentials found (local or remote)."));
2615
- console.error(chalk.dim(" Set BEARER_TOKEN or QA_USERNAME/QA_PASSWORD in env or ~/.fops.json\n"));
2616
- process.exit(1);
2617
- }
2618
-
2619
- // Write QA .env
2620
- const envPath = path.join(qaDir, ".env");
2621
- const examplePath = path.join(qaDir, ".env.example");
2622
- let envContent;
2623
- try { envContent = await fsp.readFile(examplePath, "utf8"); } catch {
2624
- envContent = await fsp.readFile(envPath, "utf8").catch(() => "");
2625
- }
2626
- const setVar = (content, key, value) => {
2627
- const re = new RegExp(`^${key}=.*`, "m");
2628
- return re.test(content) ? content.replace(re, `${key}=${value}`) : content + `\n${key}=${value}`;
2629
- };
2630
- envContent = setVar(envContent, "API_URL", apiUrl);
2631
- envContent = setVar(envContent, "DEV_API_URL", apiUrl);
2632
- envContent = setVar(envContent, "LIVE_API_URL", apiUrl);
2633
- envContent = setVar(envContent, "QA_USERNAME", qaUser);
2634
- envContent = setVar(envContent, "QA_PASSWORD", qaPass);
2635
- envContent = setVar(envContent, "QA_X_ACCOUNT", "root");
2636
- envContent = setVar(envContent, "ADMIN_USERNAME", qaUser);
2637
- envContent = setVar(envContent, "ADMIN_PASSWORD", qaPass);
2638
- envContent = setVar(envContent, "ADMIN_X_ACCOUNT", "root");
2639
- envContent = setVar(envContent, "CDO_USERNAME", qaUser);
2640
- envContent = setVar(envContent, "CDO_PASSWORD", qaPass);
2641
- envContent = setVar(envContent, "CDO_X_ACCOUNT", "root");
2642
- envContent = setVar(envContent, "OWNER_EMAIL", qaUser);
2643
- envContent = setVar(envContent, "OWNER_NAME", "Foundation Operator");
2644
- if (bearerToken) {
2645
- envContent = setVar(envContent, "BEARER_TOKEN", bearerToken);
2646
- envContent = setVar(envContent, "TOKEN_AUTH0", bearerToken);
2647
- }
2648
- await fsp.writeFile(envPath, envContent);
2649
- console.log(chalk.green(` ✓ Configured QA .env → ${apiUrl}`));
2650
-
2651
- // Ensure venv + deps
2652
- try { await fsp.access(path.join(qaDir, "venv")); } catch {
2653
- console.log(chalk.cyan(" Setting up QA automation environment…"));
2654
- await execaFn("python3", ["-m", "venv", "venv"], { cwd: qaDir, stdio: "inherit" });
2655
- await execaFn("bash", ["-c", "source venv/bin/activate && pip install -r requirements.txt && playwright install"], { cwd: qaDir, stdio: "inherit" });
2656
- }
2657
-
2658
- const authMode = useTokenMode ? "bearer token (--use-token)" : `user/pass (${qaUser})`;
2659
- console.log(chalk.cyan(`\n Running QA tests against AKS ${cluster.clusterName} (${baseUrl}) [${authMode}]…\n`));
2660
- const pytestArgsList = [
2661
- "tests/",
2662
- "--env", "staging",
2663
- "--role", "FOUNDATION_ADMIN",
2664
- "--numprocesses=0",
2665
- "-v",
2666
- "--durations=0",
2667
- "--html=./playwright-report/report.html",
2668
- "--self-contained-html",
2669
- "--ignore=tests/e2e/landscape",
2670
- "--ignore=tests/e2e/procedures",
2671
- "--ignore=tests/e2e/upload_file_s3",
2672
- "--ignore=tests/roles",
2673
- "--ignore=tests/unit/data_product/test_data_product_consumers_v2.py",
2674
- "--ignore=tests/unit/data_product/test_data_product_query.py",
2675
- "--ignore=tests/unit/data_product/test_data_product_quality_v2.py",
2676
- "--ignore=tests/unit/data_product/test_data_product_schema_v2.py",
2677
- ];
2678
- if (useTokenMode) pytestArgsList.push("--use-token");
2679
- const pytestArgs = pytestArgsList.join(" ");
2680
-
2681
- const testEnv = {
2682
- ...process.env,
2683
- API_URL: apiUrl,
2684
- DEV_API_URL: apiUrl,
2685
- LIVE_API_URL: apiUrl,
2686
- ROLE_NAME: "FOUNDATION_ADMIN",
2687
- QA_USERNAME: qaUser,
2688
- QA_PASSWORD: qaPass,
2689
- ADMIN_USERNAME: qaUser,
2690
- ADMIN_PASSWORD: qaPass,
2691
- QA_X_ACCOUNT: "root",
2692
- ADMIN_X_ACCOUNT: "root",
2693
- CDO_USERNAME: qaUser,
2694
- CDO_PASSWORD: qaPass,
2695
- CDO_X_ACCOUNT: "root",
2696
- OWNER_EMAIL: qaUser,
2697
- OWNER_NAME: "Foundation Operator",
2698
- };
2699
- if (bearerToken) {
2700
- testEnv.BEARER_TOKEN = bearerToken;
2701
- testEnv.TOKEN_AUTH0 = bearerToken;
2702
- }
2703
-
2704
- const aksStartMs = Date.now();
2705
- const aksProc = execaFn(
2706
- "bash",
2707
- ["-c", `source venv/bin/activate && pytest ${pytestArgs}`],
2708
- { cwd: qaDir, timeout: 600_000, reject: false, env: testEnv },
2709
- );
2710
- let aksCaptured = "";
2711
- aksProc.stdout?.on("data", (d) => { const s = d.toString(); aksCaptured += s; process.stdout.write(s); });
2712
- aksProc.stderr?.on("data", (d) => { const s = d.toString(); aksCaptured += s; process.stderr.write(s); });
2713
- const { exitCode } = await aksProc;
2714
- const aksWallSec = Math.round((Date.now() - aksStartMs) / 1000);
2715
-
2716
- const aksCounts = parsePytestSummary(aksCaptured);
2717
- const aksTiming = parsePytestDurations(aksCaptured);
2718
- const qaResult = {
2719
- passed: exitCode === 0,
2720
- exitCode,
2721
- at: new Date().toISOString(),
2722
- ...(aksCounts.passed != null && { numPassed: aksCounts.passed }),
2723
- ...(aksCounts.failed != null && { numFailed: aksCounts.failed }),
2724
- ...(aksCounts.error != null && { numErrors: aksCounts.error }),
2725
- ...(aksCounts.errors != null && { numErrors: aksCounts.errors }),
2726
- ...(aksCounts.skipped != null && { numSkipped: aksCounts.skipped }),
2727
- durationSec: aksCounts.durationSec || aksWallSec,
2728
- ...(aksTiming && { timing: aksTiming }),
2729
- };
2730
- writeClusterState(cluster.clusterName, { qa: qaResult });
2731
-
2732
- if (exitCode === 0) {
2733
- console.log(chalk.green("\n ✓ QA tests passed\n"));
2734
- } else {
2735
- console.error(chalk.red(`\n QA tests failed (exit ${exitCode}).`));
2736
- console.error(chalk.dim(` Report: ${path.join(qaDir, "playwright-report", "report.html")}\n`));
2737
- process.exitCode = 1;
2738
- }
2739
-
2740
- try {
2741
- const { resultsPush } = await import("./lib/azure-results.js");
2742
- await resultsPush(cluster.clusterName, qaResult, { quiet: false });
2743
- } catch (pushErr) {
2744
- console.log(chalk.dim(` (Result push skipped: ${pushErr.message})`));
2745
- }
2746
- });
2747
-
2748
- aks
2749
- .command("kubeconfig [name]")
2750
- .description("Merge cluster kubeconfig into ~/.kube/config")
2751
- .option("--profile <subscription>", "Azure subscription name or ID")
2752
- .option("--admin", "Get admin credentials (cluster-admin role)")
2753
- .action(async (name, opts) => {
2754
- const { aksKubeconfig } = await import("./lib/azure-aks.js");
2755
- await aksKubeconfig({ clusterName: name, profile: opts.profile, admin: opts.admin });
2756
- });
2757
-
2758
- aks
2759
- .command("bootstrap [clusterName]")
2760
- .description("Create demo data mesh on the cluster (same as fops bootstrap, targeting AKS backend)")
2761
- .option("--api-url <url>", "Foundation backend API URL (e.g. https://foundation.example.com/api)")
2762
- .option("--yes", "Use credentials from env or ~/.fops.json, skip prompt")
2763
- .option("--profile <subscription>", "Azure subscription name or ID")
2764
- .action(async (clusterName, opts) => {
2765
- const { aksDataBootstrap } = await import("./lib/azure-aks.js");
2766
- await aksDataBootstrap({
2767
- clusterName,
2768
- profile: opts.profile,
2769
- apiUrl: opts.apiUrl,
2770
- yes: opts.yes,
2771
- });
2772
- });
2773
-
2774
- // ── Node pool management ─────────────────────────────────────────────
2775
- const nodePool = aks
2776
- .command("node-pool")
2777
- .description("Manage AKS node pools");
2778
-
2779
- nodePool
2780
- .command("add [clusterName]")
2781
- .description("Add a node pool to the cluster")
2782
- .requiredOption("--pool-name <name>", "Node pool name")
2783
- .option("--profile <subscription>", "Azure subscription name or ID")
2784
- .option("--node-count <count>", "Node count (default: 3)")
2785
- .option("--node-vm-size <size>", "VM size (default: Standard_D8s_v3)")
2786
- .option("--mode <mode>", "Pool mode: System | User")
2787
- .option("--labels <labels>", "Node labels (key=value pairs)")
2788
- .option("--taints <taints>", "Node taints (key=value:effect)")
2789
- .option("--max-pods <count>", "Max pods per node")
2790
- .action(async (clusterName, opts) => {
2791
- const { aksNodePoolAdd } = await import("./lib/azure-aks.js");
2792
- await aksNodePoolAdd({
2793
- clusterName, profile: opts.profile, poolName: opts.poolName,
2794
- nodeCount: opts.nodeCount ? Number(opts.nodeCount) : undefined,
2795
- nodeVmSize: opts.nodeVmSize, mode: opts.mode,
2796
- labels: opts.labels, taints: opts.taints,
2797
- maxPods: opts.maxPods ? Number(opts.maxPods) : undefined,
2798
- });
2799
- });
2800
-
2801
- nodePool
2802
- .command("remove [clusterName]")
2803
- .description("Remove a node pool from the cluster")
2804
- .requiredOption("--pool-name <name>", "Node pool name to remove")
2805
- .option("--profile <subscription>", "Azure subscription name or ID")
2806
- .action(async (clusterName, opts) => {
2807
- const { aksNodePoolRemove } = await import("./lib/azure-aks.js");
2808
- await aksNodePoolRemove({ clusterName, profile: opts.profile, poolName: opts.poolName });
2809
- });
2810
-
2811
- // ── Flux subcommands ─────────────────────────────────────────────────
2812
- const flux = aks
2813
- .command("flux")
2814
- .description("Manage Flux GitOps on the AKS cluster");
2815
-
2816
- flux
2817
- .command("init <clusterName>")
2818
- .description("Scaffold cluster manifests from the Foundation Flux template")
2819
- .requiredOption("--flux-repo <path>", "Path to local flux repo clone")
2820
- .option("--overlay <name>", "Overlay name for app kustomizations (default: <name>-azure)")
2821
- .option("--namespace <ns>", "Kubernetes namespace for the tenant (default: velora)")
2822
- .option("--postgres-host <host>", "Postgres hostname")
2823
- .option("--acr-username <user>", "ACR pull username")
2824
- .option("--acr-password <pass>", "ACR pull password")
2825
- .action(async (clusterName, opts) => {
2826
- const { aksFluxInit } = await import("./lib/azure-aks.js");
2827
- await aksFluxInit({
2828
- clusterName, fluxRepo: opts.fluxRepo,
2829
- overlay: opts.overlay, namespace: opts.namespace,
2830
- postgresHost: opts.postgresHost,
2831
- acrUsername: opts.acrUsername, acrPassword: opts.acrPassword,
2832
- });
2833
- });
2834
-
2835
- flux
2836
- .command("bootstrap [clusterName]")
2837
- .description("Bootstrap Flux on the cluster with a GitHub repo")
2838
- .requiredOption("--flux-repo <repo>", "GitHub repo name")
2839
- .requiredOption("--flux-owner <owner>", "GitHub owner/org")
2840
- .option("--flux-path <path>", "Path in repo for cluster manifests (default: clusters/<name>)")
2841
- .option("--flux-branch <branch>", "Git branch (default: main)")
2842
- .option("--github-token <token>", "GitHub PAT (default: $GITHUB_TOKEN)")
2843
- .option("--profile <subscription>", "Azure subscription name or ID")
2844
- .action(async (clusterName, opts) => {
2845
- const { aksFluxBootstrap } = await import("./lib/azure-aks.js");
2846
- await aksFluxBootstrap({
2847
- clusterName, profile: opts.profile,
2848
- repo: opts.fluxRepo, owner: opts.fluxOwner,
2849
- path: opts.fluxPath, branch: opts.fluxBranch,
2850
- githubToken: opts.githubToken,
2851
- });
2852
- });
2853
-
2854
- flux
2855
- .command("status [clusterName]")
2856
- .description("Show Flux sources, kustomizations, and helm releases")
2857
- .action(async (clusterName) => {
2858
- const { aksFluxStatus } = await import("./lib/azure-aks.js");
2859
- await aksFluxStatus({ clusterName });
2860
- });
2861
-
2862
- flux
2863
- .command("reconcile [clusterName]")
2864
- .description("Trigger a Flux reconciliation (pull + apply)")
2865
- .option("--source <name>", "Git source name (default: flux-system)")
2866
- .action(async (clusterName, opts) => {
2867
- const { aksFluxReconcile } = await import("./lib/azure-aks.js");
2868
- await aksFluxReconcile({ clusterName, source: opts.source });
2869
- });
32
+ registerVmCommands(azure, api, registry);
33
+ registerFleetCommands(azure);
34
+ registerTestCommands(azure);
35
+ registerInfraCommands(azure);
2870
36
  });
2871
37
 
2872
- // ── Cost optimization agent tools ──────────────────────────────────────
38
+ // ── Cost agent tools + agent ───────────────────────────────────────────
39
+
2873
40
  const { registerCostTools } = await import("./lib/azure-cost.js");
2874
41
  await registerCostTools(api);
2875
42
 
2876
- // ── Cost agent ─────────────────────────────────────────────────────────
2877
43
  api.registerAgent({
2878
44
  name: "azure",
2879
45
  description: "Azure cost optimization — finds waste, checks utilization, enforces budgets",
@@ -2901,11 +67,11 @@ export async function register(api) {
2901
67
  ].join("\n"),
2902
68
  });
2903
69
 
2904
- // ── Security agent tools ──────────────────────────────────────────────
70
+ // ── Security agent tools + agent ───────────────────────────────────────
71
+
2905
72
  const { registerSecurityTools } = await import("./lib/azure-security.js");
2906
73
  await registerSecurityTools(api);
2907
74
 
2908
- // ── Security agent ────────────────────────────────────────────────────
2909
75
  api.registerAgent({
2910
76
  name: "azure-security",
2911
77
  description: "Azure security assessment — audits VMs, AKS, storage, and runs OWASP ZAP DAST scans",
@@ -2943,7 +109,8 @@ export async function register(api) {
2943
109
  ].join("\n"),
2944
110
  });
2945
111
 
2946
- // ── Doctor check ───────────────────────────────────
112
+ // ── Doctor checks ──────────────────────────────────────────────────────
113
+
2947
114
  api.registerDoctorCheck({
2948
115
  name: "Azure CLI",
2949
116
  fn: async (ok, warn) => {
@@ -2982,7 +149,6 @@ export async function register(api) {
2982
149
  },
2983
150
  });
2984
151
 
2985
- // ── Doctor checks (AKS prereqs) ────────────────────
2986
152
  api.registerDoctorCheck({
2987
153
  name: "kubectl",
2988
154
  fn: async (ok, warn) => {
@@ -3020,7 +186,8 @@ export async function register(api) {
3020
186
  },
3021
187
  });
3022
188
 
3023
- // ── Hook: before:up — auto-sync Key Vault secrets ──
189
+ // ── Hook: before:up — auto-sync Key Vault secrets ─────────────────────
190
+
3024
191
  api.registerHook("before:up", async () => {
3025
192
  const { readKeyvaultConfig, discoverKeyvaultTemplates, syncSecrets } = await import("./lib/azure-keyvault.js");
3026
193
  const cfg = readKeyvaultConfig();
@@ -3044,7 +211,6 @@ export async function register(api) {
3044
211
  const templates = discoverKeyvaultTemplates(root);
3045
212
  if (templates.length === 0) return;
3046
213
 
3047
- // Quick check: is az cli even logged in?
3048
214
  try {
3049
215
  const { execa } = await import("execa");
3050
216
  await execa("az", ["account", "show", "--output", "none"], { timeout: 10000 });
@@ -3060,15 +226,15 @@ export async function register(api) {
3060
226
  }
3061
227
  });
3062
228
 
3063
- // ── Knowledge source ───────────────────────────────
229
+ // ── Knowledge source ───────────────────────────────────────────────────
230
+
3064
231
  api.registerKnowledgeSource({
3065
232
  name: "Azure VM & AKS Management",
3066
233
  description: "Provision and manage Foundation on Azure VMs and AKS clusters",
3067
234
  search(query) {
3068
235
  const keywords = ["azure", "vm", "cloud", "provision", "deploy", "resize", "deallocate", "ssh", "az cli", "cost", "subscription", "spend", "fleet", "rollout", "snapshot", "drift", "sync", "health", "audit", "security", "compliance", "encryption", "aks", "kubernetes", "k8s", "flux", "gitops", "cluster", "node pool", "kubeconfig", "helm"];
3069
236
  const q = query.toLowerCase();
3070
- const match = keywords.some((kw) => q.includes(kw));
3071
- if (!match) return [];
237
+ if (!keywords.some((kw) => q.includes(kw))) return [];
3072
238
 
3073
239
  return [
3074
240
  {
@@ -3157,23 +323,12 @@ export async function register(api) {
3157
323
  " ISO 8601 durations: P30D = 30 days, P90D = 90 days, P6M = 6 months, P1Y = 1 year",
3158
324
  "",
3159
325
  "### Key Vault Secret Sync (.env.keyvault)",
3160
- "Inject secrets from Azure Key Vault into `.env` files using `kv://` references (mirrors the 1Password plugin).",
326
+ "Inject secrets from Azure Key Vault into `.env` files using `kv://` references.",
3161
327
  "",
3162
328
  "- `fops azure keyvault setup` — Interactive wizard: pick vault, set auto-sync, scaffold `.env.keyvault` templates",
3163
329
  "- `fops azure keyvault sync` — Pull secrets from Key Vault → merge into `.env` files",
3164
330
  "- `fops azure keyvault status` — Show auth, config, discovered templates",
3165
331
  "",
3166
- "**Template format (`.env.keyvault`):**",
3167
- "```",
3168
- "# KEY=kv://<vault>/<secret-name>",
3169
- "BEARER_TOKEN=kv://my-vault/bearer-token",
3170
- "DB_PASSWORD=kv://my-vault/db-password",
3171
- "S3_SECRET_KEY=kv://my-vault/s3-secret-key",
3172
- "```",
3173
- "",
3174
- "When `autoSync: true` in config, secrets sync automatically before `fops up`.",
3175
- "Config stored in `~/.fops.json` under `plugins.entries.fops-plugin-azure.config.keyvault`.",
3176
- "",
3177
332
  "### Audit & Compliance",
3178
333
  "- `fops azure audit` — Run all security checks (VMs, AKS, storage encryption)",
3179
334
  "- `fops azure audit vm [name]` — Audit VM security (disk encryption, NSG, managed identity, patching)",
@@ -3184,7 +339,6 @@ export async function register(api) {
3184
339
  "- `fops azure audit zap [vmName]` — Authenticated OWASP ZAP DAST scan against a VM's frontend",
3185
340
  " Options: --target <url>, --spider-minutes, --ajax-minutes, --active-scan-minutes, --max-minutes",
3186
341
  "- `fops azure audit sessions [vmName]` — View SSH session recordings across the fleet",
3187
- " All audit commands support --verbose to show info-level suggestions",
3188
342
  "",
3189
343
  "### Fleet Management",
3190
344
  "- `fops azure exec <command>` — Run a command on all VMs in parallel",
@@ -3234,7 +388,8 @@ export async function register(api) {
3234
388
  },
3235
389
  });
3236
390
 
3237
- // ── Embed index source ──────────────────────────────
391
+ // ── Embed index source ─────────────────────────────────────────────────
392
+
3238
393
  api.registerIndexSource({
3239
394
  name: "azure",
3240
395
  async fn(_root, log = () => {}) {
@@ -3288,7 +443,7 @@ export async function register(api) {
3288
443
  });
3289
444
  }
3290
445
 
3291
- // ── Security audit findings ──────────────────────────
446
+ // ── Security audit findings ──────────────────────────────────────
3292
447
  try {
3293
448
  const { auditVms, auditAks, auditStorage } = await import("./lib/azure-audit.js");
3294
449
 
@@ -3321,7 +476,7 @@ export async function register(api) {
3321
476
  }
3322
477
  } catch { /* audit not available or failed */ }
3323
478
 
3324
- // ── ZAP scan results ──────────────────────────────────
479
+ // ── ZAP scan results ─────────────────────────────────────────────
3325
480
  try {
3326
481
  const fsp = await import("node:fs/promises");
3327
482
  const pathMod = await import("node:path");
@@ -3364,9 +519,7 @@ export async function register(api) {
3364
519
  }
3365
520
  } catch { /* no reports or fs error */ }
3366
521
 
3367
- // ── Remote landscape (data mesh data from running VMs) ─────
3368
- // Try HTTPS first; if the VM has an OAuth gateway (traefik) that blocks
3369
- // /iam/login, fall back to SSH + curl against localhost:9001 on the VM.
522
+ // ── Remote landscape (data mesh data from running VMs) ────────────
3370
523
  const targetVm = process.env.__FOPS_AZURE_EMBED_VM;
3371
524
  const upVms = Object.entries(cache.vms || {})
3372
525
  .filter(([name, vm]) => (vm.status === "up" || vm.status === "degraded") && (!targetVm || name === targetVm));
@@ -3384,7 +537,6 @@ export async function register(api) {
3384
537
  } catch (err) {
3385
538
  log(` azure/${name}: HTTPS landscape error — ${err.message}`);
3386
539
  }
3387
- // HTTPS auth failed — fall back to SSH tunnel
3388
540
  if (vm.publicIp && creds.user && creds.password) {
3389
541
  try {
3390
542
  log(` azure/${name}: falling back to SSH…`);
@@ -3393,21 +545,17 @@ export async function register(api) {
3393
545
  log(` azure/${name}: SSH landscape error — ${sshErr.message}`);
3394
546
  }
3395
547
  }
3396
- log(` azure/${name}: auth failed via HTTPS and SSH, skipping landscape`);
3397
548
  return [];
3398
- }),
549
+ })
3399
550
  );
3400
551
  for (const r of landscapeResults) {
3401
- if (r.status === "fulfilled") chunks.push(...r.value);
552
+ if (r.status === "fulfilled" && Array.isArray(r.value)) {
553
+ chunks.push(...r.value);
554
+ }
3402
555
  }
3403
- } else {
3404
- log(" azure: no Foundation credentials, skipping remote landscape");
3405
556
  }
3406
557
  }
3407
558
 
3408
- const auditCount = chunks.filter(c => c.metadata?.resourceType?.startsWith("security") || c.metadata?.resourceType === "zap-scan").length;
3409
- const landscapeCount = chunks.filter(c => c.metadata?.resourceType === "remote-landscape").length;
3410
- log(` azure: ${Object.keys(cache.vms || {}).length} VMs, ${Object.keys(cache.clusters || {}).length} clusters, ${auditCount} security chunks, ${landscapeCount} landscape entities`);
3411
559
  return chunks;
3412
560
  },
3413
561
  });