@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.

Potentially problematic release.


This version of @meshxdata/fops might be problematic. Click here for more details.

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
@@ -148,7 +148,7 @@ function resolveFluxConfig(clusterName, opts) {
148
148
  return {
149
149
  fluxRepo: opts?.fluxRepo ?? tracked?.flux?.repo ?? az.fluxRepo ?? project?.fluxRepo ?? AKS_DEFAULTS.fluxRepo,
150
150
  fluxOwner: opts?.fluxOwner ?? tracked?.flux?.owner ?? az.fluxOwner ?? project?.fluxOwner ?? AKS_DEFAULTS.fluxOwner,
151
- fluxPath: opts?.fluxPath || tracked?.flux?.path || az.fluxPath || project?.fluxPath || AKS_DEFAULTS.fluxPath,
151
+ fluxPath: opts?.fluxPath || tracked?.flux?.path || az.fluxPath || project?.fluxPath || `clusters/${clusterName}`,
152
152
  fluxBranch: opts?.fluxBranch ?? tracked?.flux?.branch ?? az.fluxBranch ?? project?.fluxBranch ?? AKS_DEFAULTS.fluxBranch,
153
153
  };
154
154
  }
@@ -182,6 +182,107 @@ function requireCluster(name) {
182
182
  };
183
183
  }
184
184
 
185
+ // ── Flux local-repo scaffolding ───────────────────────────────────────────────
186
+
187
+ /**
188
+ * Auto-detect the local flux repo clone.
189
+ * Searches common relative paths from the project root and CWD.
190
+ */
191
+ function findFluxLocalRepo() {
192
+ const state = readState();
193
+ const projectRoot = state.azure?.projectRoot || state.projectRoot;
194
+
195
+ const candidates = [];
196
+ if (projectRoot) {
197
+ candidates.push(path.resolve(projectRoot, "..", "flux"));
198
+ candidates.push(path.resolve(projectRoot, "flux"));
199
+ }
200
+ candidates.push(path.resolve("../flux"));
201
+ candidates.push(path.resolve("../../flux"));
202
+
203
+ for (const p of candidates) {
204
+ if (fs.existsSync(path.join(p, "clusters"))) return p;
205
+ }
206
+ return null;
207
+ }
208
+
209
+ /**
210
+ * Resolve the bundled cluster template directory shipped with the CLI.
211
+ */
212
+ function resolveClusterTemplate() {
213
+ const thisDir = path.dirname(fileURLToPath(import.meta.url));
214
+ return path.resolve(thisDir, "..", "templates", "cluster");
215
+ }
216
+
217
+ /**
218
+ * Scaffold a new cluster directory in the local flux repo from the bundled
219
+ * template, substituting {{CLUSTER_NAME}} and {{OVERLAY}} placeholders.
220
+ * Then commits and pushes the change.
221
+ */
222
+ async function scaffoldFluxCluster(execa, { clusterName, fluxLocalRepo, overlay }) {
223
+ const templateDir = resolveClusterTemplate();
224
+ const destDir = path.join(fluxLocalRepo, "clusters", clusterName);
225
+
226
+ if (!fs.existsSync(templateDir)) {
227
+ console.log(WARN(` ⚠ Cluster template not found at ${templateDir}`));
228
+ return false;
229
+ }
230
+
231
+ if (fs.existsSync(destDir)) {
232
+ console.log(OK(` ✓ Cluster directory already exists: clusters/${clusterName}`));
233
+ return true;
234
+ }
235
+
236
+ const vars = {
237
+ "{{CLUSTER_NAME}}": clusterName,
238
+ "{{OVERLAY}}": overlay || "demo-azure",
239
+ };
240
+
241
+ hint("Scaffolding Flux cluster manifests…");
242
+
243
+ function copyDir(src, dest) {
244
+ fs.mkdirSync(dest, { recursive: true });
245
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
246
+ const srcPath = path.join(src, entry.name);
247
+ const destPath = path.join(dest, entry.name);
248
+ if (entry.isDirectory()) {
249
+ copyDir(srcPath, destPath);
250
+ } else {
251
+ let content = fs.readFileSync(srcPath, "utf8");
252
+ for (const [k, v] of Object.entries(vars)) {
253
+ content = content.replaceAll(k, v);
254
+ }
255
+ fs.writeFileSync(destPath, content);
256
+ }
257
+ }
258
+ }
259
+
260
+ copyDir(templateDir, destDir);
261
+ console.log(OK(` ✓ Cluster directory created: clusters/${clusterName}`));
262
+
263
+ // List remaining placeholders
264
+ const remaining = [];
265
+ for (const line of fs.readFileSync(path.join(destDir, "kustomization.yaml"), "utf8").split("\n")) {
266
+ const m = line.match(/\{\{(\w+)\}\}/g);
267
+ if (m) remaining.push(...m);
268
+ }
269
+
270
+ // Git add + commit + push
271
+ hint("Committing and pushing to flux repo…");
272
+ try {
273
+ await execa("git", ["-C", fluxLocalRepo, "add", `clusters/${clusterName}`], { timeout: 15000 });
274
+ await execa("git", ["-C", fluxLocalRepo, "commit", "-m", `Add cluster ${clusterName}`], { timeout: 15000 });
275
+ await execa("git", ["-C", fluxLocalRepo, "push"], { timeout: 60000 });
276
+ console.log(OK(` ✓ Pushed clusters/${clusterName} to flux repo`));
277
+ } catch (err) {
278
+ const msg = (err.stderr || err.message || "").split("\n")[0];
279
+ console.log(WARN(` ⚠ Git push failed: ${msg}`));
280
+ hint(`Manually commit and push clusters/${clusterName} in the flux repo`);
281
+ }
282
+
283
+ return true;
284
+ }
285
+
185
286
  // ── Flux helpers ──────────────────────────────────────────────────────────────
186
287
 
187
288
  async function ensureFluxCli(execa) {
@@ -244,6 +345,18 @@ export async function aksUp(opts = {}) {
244
345
  if (exists === 0) {
245
346
  console.log(WARN(`\n Cluster "${clusterName}" already exists — reconciling…`));
246
347
 
348
+ // Scaffold cluster directory if it doesn't exist yet
349
+ if (!opts.noFlux) {
350
+ const fluxLocalRepo = opts.fluxLocalRepo || findFluxLocalRepo();
351
+ if (fluxLocalRepo) {
352
+ await scaffoldFluxCluster(execa, {
353
+ clusterName,
354
+ fluxLocalRepo,
355
+ overlay: opts.overlay,
356
+ });
357
+ }
358
+ }
359
+
247
360
  const maxPods = opts.maxPods || 110;
248
361
  const ctx = { execa, clusterName, rg, sub, opts, minCount, maxCount, maxPods };
249
362
  await reconcileCluster(ctx);
@@ -347,7 +460,22 @@ export async function aksUp(opts = {}) {
347
460
  const fluxRepo = opts.fluxRepo ?? AKS_DEFAULTS.fluxRepo;
348
461
  const fluxOwner = opts.fluxOwner ?? AKS_DEFAULTS.fluxOwner;
349
462
  const fluxBranch = opts.fluxBranch ?? AKS_DEFAULTS.fluxBranch;
350
- const fluxPath = opts.fluxPath || AKS_DEFAULTS.fluxPath;
463
+ const fluxPath = opts.fluxPath || `clusters/${clusterName}`;
464
+
465
+ // Scaffold cluster directory in the flux repo before bootstrapping
466
+ if (!opts.noFlux) {
467
+ const fluxLocalRepo = opts.fluxLocalRepo || findFluxLocalRepo();
468
+ if (fluxLocalRepo) {
469
+ await scaffoldFluxCluster(execa, {
470
+ clusterName,
471
+ fluxLocalRepo,
472
+ templateCluster: opts.templateCluster,
473
+ });
474
+ } else {
475
+ console.log(WARN(" ⚠ Local flux repo not found — skipping cluster scaffolding."));
476
+ hint("Pass --flux-local-repo <path> or clone meshxdata/flux next to foundation-compose.");
477
+ }
478
+ }
351
479
 
352
480
  if (opts.noFlux) {
353
481
  console.log("");
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Auth chain and landscape-fetch utilities for the Azure plugin.
3
+ * Extracted from index.js to keep the top-level plugin file small.
4
+ */
5
+ import crypto from "node:crypto";
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import pathMod from "node:path";
9
+ import chalk from "chalk";
10
+
11
+ export function hashContent(text) {
12
+ return crypto.createHash("sha256").update(text).digest("hex").slice(0, 16);
13
+ }
14
+
15
+ /**
16
+ * Resolve Foundation credentials from env → ~/.fops.json → .env files.
17
+ * Returns { bearerToken } or { user, password } or null.
18
+ */
19
+ export function resolveFoundationCreds() {
20
+ if (process.env.BEARER_TOKEN?.trim()) return { bearerToken: process.env.BEARER_TOKEN.trim() };
21
+ if (process.env.QA_USERNAME?.trim() && process.env.QA_PASSWORD)
22
+ return { user: process.env.QA_USERNAME.trim(), password: process.env.QA_PASSWORD };
23
+ try {
24
+ const raw = JSON.parse(fs.readFileSync(pathMod.join(os.homedir(), ".fops.json"), "utf8"));
25
+ const cfg = raw?.plugins?.entries?.["fops-plugin-foundation"]?.config || {};
26
+ if (cfg.bearerToken?.trim()) return { bearerToken: cfg.bearerToken.trim() };
27
+ if (cfg.user?.trim() && cfg.password) return { user: cfg.user.trim(), password: cfg.password };
28
+ } catch { /* no fops.json */ }
29
+
30
+ // Fall back to .env files for credentials
31
+ const envCandidates = [pathMod.resolve(".env"), pathMod.resolve("..", ".env")];
32
+ try {
33
+ const raw = JSON.parse(fs.readFileSync(pathMod.join(os.homedir(), ".fops.json"), "utf8"));
34
+ if (raw?.projectRoot) envCandidates.unshift(pathMod.join(raw.projectRoot, ".env"));
35
+ } catch { /* ignore */ }
36
+ for (const ep of envCandidates) {
37
+ try {
38
+ const lines = fs.readFileSync(ep, "utf8").split("\n");
39
+ const get = (k) => {
40
+ const ln = lines.find((l) => l.startsWith(`${k}=`));
41
+ return ln ? ln.slice(k.length + 1).trim().replace(/^["']|["']$/g, "") : "";
42
+ };
43
+ const user = get("QA_USERNAME") || get("FOUNDATION_USERNAME");
44
+ const pass = get("QA_PASSWORD") || get("FOUNDATION_PASSWORD");
45
+ if (user && pass) return { user, password: pass };
46
+ } catch { /* try next */ }
47
+ }
48
+ return null;
49
+ }
50
+
51
+ // VMs use Traefik with self-signed certs — skip TLS verification for VM API calls.
52
+ let _tlsWarningSuppressed = false;
53
+ export function suppressTlsWarning() {
54
+ if (_tlsWarningSuppressed) return;
55
+ _tlsWarningSuppressed = true;
56
+ const origEmit = process.emitWarning;
57
+ process.emitWarning = (warning, ...args) => {
58
+ if (typeof warning === "string" && warning.includes("NODE_TLS_REJECT_UNAUTHORIZED")) return;
59
+ return origEmit.call(process, warning, ...args);
60
+ };
61
+ }
62
+
63
+ export async function vmFetch(url, opts = {}) {
64
+ suppressTlsWarning();
65
+ const prev = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
66
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
67
+ try {
68
+ return await fetch(url, { signal: AbortSignal.timeout(10_000), ...opts });
69
+ } finally {
70
+ if (prev === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
71
+ else process.env.NODE_TLS_REJECT_UNAUTHORIZED = prev;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Resolve Auth0 config from ~/.fops.json → .env files.
77
+ * Returns { domain, clientId, clientSecret, audience } or null.
78
+ */
79
+ export function resolveAuth0Config() {
80
+ try {
81
+ const raw = JSON.parse(fs.readFileSync(pathMod.join(os.homedir(), ".fops.json"), "utf8"));
82
+ const a0 = raw?.plugins?.entries?.["fops-plugin-foundation"]?.config?.auth0;
83
+ if (a0?.domain && a0?.clientId) return a0;
84
+
85
+ const projectRoot = raw?.projectRoot || "";
86
+ const envCandidates = [
87
+ ...(projectRoot ? [pathMod.join(projectRoot, ".env")] : []),
88
+ pathMod.resolve(".env"),
89
+ pathMod.resolve("..", ".env"),
90
+ ];
91
+ for (const ep of envCandidates) {
92
+ try {
93
+ const lines = fs.readFileSync(ep, "utf8").split("\n");
94
+ const get = (k) => {
95
+ const ln = lines.find((l) => l.startsWith(`${k}=`));
96
+ return ln ? ln.slice(k.length + 1).trim().replace(/^["']|["']$/g, "") : "";
97
+ };
98
+ const domain = get("MX_AUTH0_DOMAIN") || get("AUTH0_DOMAIN");
99
+ const clientId = get("MX_AUTH0_CLIENT_ID") || get("AUTH0_CLIENT_ID");
100
+ const clientSecret = get("MX_AUTH0_CLIENT_SECRET") || get("AUTH0_CLIENT_SECRET");
101
+ const audience = get("MX_AUTH0_AUDIENCE") || get("AUTH0_AUDIENCE");
102
+ if (domain && clientId) return { domain, clientId, clientSecret, audience };
103
+ } catch { /* try next */ }
104
+ }
105
+ } catch { /* no fops.json */ }
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * Authenticate against a remote VM's Foundation API and return a bearer token.
111
+ * Tries the backend /iam/login first, then falls back to Auth0 ROPC.
112
+ */
113
+ export async function authenticateVm(vmUrl, ip, creds) {
114
+ if (creds.bearerToken) return creds.bearerToken;
115
+
116
+ const hasDomain = vmUrl && !vmUrl.match(/^https?:\/\/\d+\.\d+\.\d+\.\d+/);
117
+ const apiUrls = hasDomain
118
+ ? [`${vmUrl}/api`, ...(ip ? [`http://${ip}:9001/api`] : [])]
119
+ : [`${vmUrl}/api`, `https://${ip}:3002/api`, `http://${ip}:9001/api`];
120
+
121
+ for (const apiBase of apiUrls) {
122
+ try {
123
+ const resp = await vmFetch(`${apiBase}/iam/login`, {
124
+ method: "POST",
125
+ headers: { "Content-Type": "application/json" },
126
+ body: JSON.stringify({ user: creds.user, password: creds.password }),
127
+ });
128
+ if (resp.ok) {
129
+ const data = await resp.json();
130
+ const token = data.access_token || data.token;
131
+ if (token) return token;
132
+ }
133
+ } catch { /* try next URL */ }
134
+ }
135
+
136
+ // Auth0 ROPC fallback
137
+ const auth0 = resolveAuth0Config();
138
+ if (auth0 && creds.user && creds.password) {
139
+ try {
140
+ const body = {
141
+ grant_type: "password",
142
+ client_id: auth0.clientId,
143
+ username: creds.user,
144
+ password: creds.password,
145
+ scope: "openid",
146
+ };
147
+ if (auth0.clientSecret) body.client_secret = auth0.clientSecret;
148
+ if (auth0.audience) body.audience = auth0.audience;
149
+
150
+ const resp = await fetch(`https://${auth0.domain}/oauth/token`, {
151
+ method: "POST",
152
+ headers: { "Content-Type": "application/json" },
153
+ body: JSON.stringify(body),
154
+ signal: AbortSignal.timeout(10_000),
155
+ });
156
+ if (resp.ok) {
157
+ const data = await resp.json();
158
+ if (data.access_token) return data.access_token;
159
+ }
160
+ } catch { /* auth0 fallback failed */ }
161
+ }
162
+
163
+ return null;
164
+ }
165
+
166
+ export function isJwt(token) {
167
+ return token && token.split(".").length === 3;
168
+ }
169
+
170
+ /**
171
+ * Resolve a valid JWT bearer token for a remote VM/cluster.
172
+ * Auth chain: local bearer → pre-auth /iam/login → Auth0 ROPC → SSH fetch from VM.
173
+ * Returns { bearerToken, user, password, useTokenMode } or throws.
174
+ */
175
+ export async function resolveRemoteAuth(opts = {}) {
176
+ const {
177
+ apiUrl, ip, vmState, execaFn: execa, sshCmd: ssh,
178
+ knockForVm: knock, suppressTlsWarning: suppressTls,
179
+ } = opts;
180
+ const log = opts.log || console.log;
181
+
182
+ const creds = resolveFoundationCreds();
183
+ let qaUser = creds?.user || process.env.QA_USERNAME || process.env.FOUNDATION_USERNAME || "operator@local";
184
+ let qaPass = creds?.password || process.env.QA_PASSWORD || process.env.FOUNDATION_PASSWORD || "";
185
+ let bearerToken = creds?.bearerToken || "";
186
+
187
+ // 1) Use local bearer if it's a valid JWT
188
+ if (bearerToken && isJwt(bearerToken)) {
189
+ return { bearerToken, qaUser, qaPass, useTokenMode: true };
190
+ }
191
+ bearerToken = "";
192
+
193
+ // 2) Pre-auth against the backend /iam/login
194
+ if (qaUser && qaPass && apiUrl) {
195
+ try {
196
+ if (suppressTls) suppressTls();
197
+ const prev = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
198
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
199
+ try {
200
+ const resp = await fetch(`${apiUrl}/iam/login`, {
201
+ method: "POST",
202
+ headers: { "Content-Type": "application/json" },
203
+ body: JSON.stringify({ username: qaUser, password: qaPass }),
204
+ signal: AbortSignal.timeout(10_000),
205
+ });
206
+ if (resp.ok) {
207
+ const data = await resp.json();
208
+ bearerToken = data.access_token || data.token || "";
209
+ if (bearerToken) {
210
+ log(chalk.green(` ✓ Authenticated as ${qaUser}`));
211
+ return { bearerToken, qaUser, qaPass, useTokenMode: true };
212
+ }
213
+ } else {
214
+ log(chalk.dim(` Local creds rejected: HTTP ${resp.status}`));
215
+ }
216
+ } finally {
217
+ if (prev === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
218
+ else process.env.NODE_TLS_REJECT_UNAUTHORIZED = prev;
219
+ }
220
+ } catch (e) {
221
+ log(chalk.dim(` Pre-auth failed: ${e.cause?.code || e.message}`));
222
+ }
223
+ }
224
+
225
+ // 3) Auth0 ROPC with local config
226
+ if (qaUser && qaPass) {
227
+ const auth0Cfg = resolveAuth0Config();
228
+ if (auth0Cfg) {
229
+ try {
230
+ log(chalk.dim(` Trying Auth0 ROPC (${auth0Cfg.domain})…`));
231
+ const body = {
232
+ grant_type: "password", client_id: auth0Cfg.clientId,
233
+ username: qaUser, password: qaPass, scope: "openid",
234
+ };
235
+ if (auth0Cfg.clientSecret) body.client_secret = auth0Cfg.clientSecret;
236
+ if (auth0Cfg.audience) body.audience = auth0Cfg.audience;
237
+ const resp = await fetch(`https://${auth0Cfg.domain}/oauth/token`, {
238
+ method: "POST",
239
+ headers: { "Content-Type": "application/json" },
240
+ body: JSON.stringify(body),
241
+ signal: AbortSignal.timeout(10_000),
242
+ });
243
+ if (resp.ok) {
244
+ const data = await resp.json();
245
+ if (data.access_token) {
246
+ // Validate the token against the target API before committing to it.
247
+ // Local Auth0 config may have a different audience than the remote VM expects.
248
+ let tokenValid = true;
249
+ if (apiUrl) {
250
+ try {
251
+ const prev = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
252
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
253
+ try {
254
+ const check = await fetch(`${apiUrl}/iam/me`, {
255
+ headers: { Authorization: `Bearer ${data.access_token}` },
256
+ signal: AbortSignal.timeout(8_000),
257
+ });
258
+ if (check.status === 401 || check.status === 403) {
259
+ tokenValid = false;
260
+ log(chalk.dim(` Auth0 token rejected by API (wrong audience) — trying SSH fallback…`));
261
+ }
262
+ } finally {
263
+ if (prev === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
264
+ else process.env.NODE_TLS_REJECT_UNAUTHORIZED = prev;
265
+ }
266
+ } catch { /* network error — assume token is OK */ }
267
+ }
268
+ if (tokenValid) {
269
+ log(chalk.green(` ✓ Authenticated as ${qaUser} via Auth0`));
270
+ return { bearerToken: data.access_token, qaUser, qaPass, useTokenMode: true };
271
+ }
272
+ }
273
+ } else {
274
+ log(chalk.dim(` Auth0 rejected: HTTP ${resp.status}`));
275
+ }
276
+ } catch (e) {
277
+ log(chalk.dim(` Auth0 failed: ${e.message}`));
278
+ }
279
+ }
280
+ }
281
+
282
+ // 4) SSH fallback — fetch credentials from VM and authenticate
283
+ if (ip && ssh && execa) {
284
+ if (knock && vmState) await knock(vmState);
285
+ log(chalk.dim(" Fetching credentials from remote VM…"));
286
+ try {
287
+ const sshUser = vmState?.adminUser || "azureuser";
288
+ const { stdout } = await ssh(
289
+ execa, ip, sshUser,
290
+ "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",
291
+ 15_000,
292
+ );
293
+ const remoteEnv = {};
294
+ for (const line of (stdout || "").split("\n")) {
295
+ const m = line.match(/^([A-Z_]+)=(.*)$/);
296
+ if (m) remoteEnv[m[1]] = m[2].trim().replace(/^["']|["']$/g, "");
297
+ }
298
+
299
+ const remoteToken = remoteEnv.BEARER_TOKEN;
300
+ if (remoteToken && isJwt(remoteToken)) {
301
+ log(chalk.green(" ✓ Got JWT bearer token from VM"));
302
+ return { bearerToken: remoteToken, qaUser, qaPass, useTokenMode: true };
303
+ }
304
+
305
+ if (remoteEnv.MX_AUTH0_DOMAIN && remoteEnv.MX_AUTH0_CLIENT_ID) {
306
+ const user = remoteEnv.QA_USERNAME || qaUser;
307
+ const pass = remoteEnv.QA_PASSWORD || qaPass;
308
+ if (user && pass) {
309
+ log(chalk.dim(` Authenticating as ${user} via VM's Auth0…`));
310
+ const body = {
311
+ grant_type: "password", client_id: remoteEnv.MX_AUTH0_CLIENT_ID,
312
+ username: user, password: pass, scope: "openid",
313
+ };
314
+ if (remoteEnv.MX_AUTH0_CLIENT_SECRET) body.client_secret = remoteEnv.MX_AUTH0_CLIENT_SECRET;
315
+ if (remoteEnv.MX_AUTH0_AUDIENCE) body.audience = remoteEnv.MX_AUTH0_AUDIENCE;
316
+ try {
317
+ const resp = await fetch(`https://${remoteEnv.MX_AUTH0_DOMAIN}/oauth/token`, {
318
+ method: "POST",
319
+ headers: { "Content-Type": "application/json" },
320
+ body: JSON.stringify(body),
321
+ signal: AbortSignal.timeout(10_000),
322
+ });
323
+ if (resp.ok) {
324
+ const data = await resp.json();
325
+ if (data.access_token) {
326
+ log(chalk.green(` ✓ Authenticated via VM's Auth0`));
327
+ return { bearerToken: data.access_token, qaUser: user, qaPass: pass, useTokenMode: true };
328
+ }
329
+ }
330
+ } catch (e) {
331
+ log(chalk.dim(` VM Auth0 login failed: ${e.message}`));
332
+ }
333
+ }
334
+ }
335
+
336
+ if (remoteToken) {
337
+ log(chalk.yellow(" ⚠ Using non-JWT token from VM (may cause 401)"));
338
+ return { bearerToken: remoteToken, qaUser, qaPass, useTokenMode: true };
339
+ }
340
+ } catch (e) {
341
+ log(chalk.dim(` SSH credential fetch failed: ${e.message}`));
342
+ }
343
+ }
344
+
345
+ return { bearerToken: "", qaUser, qaPass, useTokenMode: false };
346
+ }
347
+
348
+ /**
349
+ * Fetch landscape entities from a remote VM's Foundation API.
350
+ * Returns embeddable chunks tagged with the VM name.
351
+ */
352
+ export async function fetchRemoteLandscape(vmName, vmUrl, ip, token, log) {
353
+ const hasDomain = vmUrl && !vmUrl.match(/^https?:\/\/\d+\.\d+\.\d+\.\d+/);
354
+ const apiBase = hasDomain ? `${vmUrl}/api` : `http://${ip}:9001/api`;
355
+ const headers = { Authorization: `Bearer ${token}`, "x-org": "root" };
356
+
357
+ let authRejected = 0;
358
+ const fetchJson = async (path) => {
359
+ const resp = await vmFetch(`${apiBase}${path}`, { headers, signal: AbortSignal.timeout(15_000) });
360
+ if (resp.status === 401 || resp.status === 403) { authRejected++; return null; }
361
+ if (!resp.ok) return null;
362
+ return resp.json();
363
+ };
364
+
365
+ const [meshRes, dpRes, dsRes, dSysRes] = await Promise.all([
366
+ fetchJson("/data/mesh/list?per_page=100").catch(() => null),
367
+ fetchJson("/data/data_product/list?per_page=200").catch(() => null),
368
+ fetchJson("/data/data_source/list?per_page=200").catch(() => null),
369
+ fetchJson("/data/data_system/list?per_page=100").catch(() => null),
370
+ ]);
371
+
372
+ if (authRejected >= 4) {
373
+ const err = new Error("token rejected by all endpoints");
374
+ err.code = "AUTH_REJECTED";
375
+ throw err;
376
+ }
377
+
378
+ const { parseListResponse } = await import("../fops-plugin-foundation/lib/api-spec.js");
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
+ for (const p of parseListResponse(dpRes)) {
386
+ 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");
387
+ 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) } });
388
+ }
389
+ for (const s of parseListResponse(dsRes)) {
390
+ 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");
391
+ 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) } });
392
+ }
393
+ for (const sys of parseListResponse(dSysRes)) {
394
+ 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");
395
+ 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) } });
396
+ }
397
+
398
+ const meshCount = parseListResponse(meshRes).length;
399
+ const prodCount = parseListResponse(dpRes).length;
400
+ const srcCount = parseListResponse(dsRes).length;
401
+ const sysCount = parseListResponse(dSysRes).length;
402
+ log(` azure/${vmName}: ${meshCount} meshes, ${prodCount} products, ${srcCount} sources, ${sysCount} systems`);
403
+
404
+ return chunks;
405
+ }
406
+
407
+ /**
408
+ * Fetch remote landscape by SSHing into the VM and curling localhost:9001 directly,
409
+ * bypassing Traefik/OAuth. Used when the HTTPS endpoint rejects credentials.
410
+ */
411
+ export async function fetchLandscapeViaSsh(vmName, ip, creds, log) {
412
+ const { lazyExeca, sshCmd, knockForVm, DEFAULTS } = await import("./azure.js");
413
+ const execa = await lazyExeca();
414
+ const user = DEFAULTS.adminUser;
415
+
416
+ try { await knockForVm({ publicIp: ip, vmName }); } catch {}
417
+
418
+ const ssh = (cmd, timeout = 15000) => sshCmd(execa, ip, user, cmd, timeout);
419
+
420
+ const loginPayload = JSON.stringify({ user: creds.user, password: creds.password });
421
+ const { stdout: loginOut, exitCode: loginExit } = await ssh(
422
+ `curl -sf -X POST http://localhost:9001/api/iam/login -H 'Content-Type: application/json' -d '${loginPayload.replace(/'/g, "'\\''")}'`,
423
+ 15000,
424
+ );
425
+
426
+ if (loginExit !== 0 || !loginOut?.trim()) {
427
+ log(` azure/${vmName}: SSH auth failed (exit ${loginExit})`);
428
+ return [];
429
+ }
430
+
431
+ let token;
432
+ try {
433
+ const data = JSON.parse(loginOut.trim());
434
+ token = data.access_token || data.token;
435
+ } catch {
436
+ log(` azure/${vmName}: SSH auth returned invalid JSON`);
437
+ return [];
438
+ }
439
+ if (!token) {
440
+ log(` azure/${vmName}: SSH auth returned no token`);
441
+ return [];
442
+ }
443
+
444
+ const authHeader = `Authorization: Bearer ${token}`;
445
+ const { stdout: dataOut, exitCode: dataExit } = await ssh(
446
+ [
447
+ `curl -sf -H '${authHeader}' -H 'x-org: root' 'http://localhost:9001/api/data/mesh/list?per_page=100'`,
448
+ `echo '___SEP___'`,
449
+ `curl -sf -H '${authHeader}' -H 'x-org: root' 'http://localhost:9001/api/data/data_product/list?per_page=200'`,
450
+ `echo '___SEP___'`,
451
+ `curl -sf -H '${authHeader}' -H 'x-org: root' 'http://localhost:9001/api/data/data_source/list?per_page=200'`,
452
+ `echo '___SEP___'`,
453
+ `curl -sf -H '${authHeader}' -H 'x-org: root' 'http://localhost:9001/api/data/data_system/list?per_page=100'`,
454
+ ].join(" && "),
455
+ 30000,
456
+ );
457
+
458
+ if (dataExit !== 0 || !dataOut?.trim()) {
459
+ log(` azure/${vmName}: SSH landscape fetch failed (exit ${dataExit})`);
460
+ return [];
461
+ }
462
+
463
+ const parts = dataOut.split("___SEP___");
464
+ const parseJson = (s) => { try { return JSON.parse(s.trim()); } catch { return null; } };
465
+ const meshRes = parseJson(parts[0] || "");
466
+ const dpRes = parseJson(parts[1] || "");
467
+ const dsRes = parseJson(parts[2] || "");
468
+ const dSysRes = parseJson(parts[3] || "");
469
+
470
+ const { parseListResponse } = await import("../fops-plugin-foundation/lib/api-spec.js");
471
+ const chunks = [];
472
+
473
+ for (const m of parseListResponse(meshRes)) {
474
+ 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");
475
+ chunks.push({ title: `vm/${vmName}/mesh/${m.name}`, content: text, metadata: { resourceType: "remote-landscape", entityType: "mesh", vm: vmName, identifier: m.identifier, fileHash: hashContent(text) } });
476
+ }
477
+ for (const p of parseListResponse(dpRes)) {
478
+ 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");
479
+ 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) } });
480
+ }
481
+ for (const s of parseListResponse(dsRes)) {
482
+ 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");
483
+ 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) } });
484
+ }
485
+ for (const sys of parseListResponse(dSysRes)) {
486
+ 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");
487
+ 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) } });
488
+ }
489
+
490
+ const meshCount = parseListResponse(meshRes).length;
491
+ const prodCount = parseListResponse(dpRes).length;
492
+ const srcCount = parseListResponse(dsRes).length;
493
+ const sysCount = parseListResponse(dSysRes).length;
494
+ log(` azure/${vmName}: ${meshCount} meshes, ${prodCount} products, ${srcCount} sources, ${sysCount} systems (via SSH)`);
495
+
496
+ return chunks;
497
+ }