@meshxdata/fops 0.1.35 → 0.1.37

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.

@@ -0,0 +1,454 @@
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 → .env → ~/.fops.json.
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
+ return null;
30
+ }
31
+
32
+ // VMs use Traefik with self-signed certs — skip TLS verification for VM API calls.
33
+ let _tlsWarningSuppressed = false;
34
+ export function suppressTlsWarning() {
35
+ if (_tlsWarningSuppressed) return;
36
+ _tlsWarningSuppressed = true;
37
+ const origEmit = process.emitWarning;
38
+ process.emitWarning = (warning, ...args) => {
39
+ if (typeof warning === "string" && warning.includes("NODE_TLS_REJECT_UNAUTHORIZED")) return;
40
+ return origEmit.call(process, warning, ...args);
41
+ };
42
+ }
43
+
44
+ export async function vmFetch(url, opts = {}) {
45
+ suppressTlsWarning();
46
+ const prev = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
47
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
48
+ try {
49
+ return await fetch(url, { signal: AbortSignal.timeout(10_000), ...opts });
50
+ } finally {
51
+ if (prev === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
52
+ else process.env.NODE_TLS_REJECT_UNAUTHORIZED = prev;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Resolve Auth0 config from ~/.fops.json → .env files.
58
+ * Returns { domain, clientId, clientSecret, audience } or null.
59
+ */
60
+ export function resolveAuth0Config() {
61
+ try {
62
+ const raw = JSON.parse(fs.readFileSync(pathMod.join(os.homedir(), ".fops.json"), "utf8"));
63
+ const a0 = raw?.plugins?.entries?.["fops-plugin-foundation"]?.config?.auth0;
64
+ if (a0?.domain && a0?.clientId) return a0;
65
+
66
+ const projectRoot = raw?.projectRoot || "";
67
+ const envCandidates = [
68
+ ...(projectRoot ? [pathMod.join(projectRoot, ".env")] : []),
69
+ pathMod.resolve(".env"),
70
+ pathMod.resolve("..", ".env"),
71
+ ];
72
+ for (const ep of envCandidates) {
73
+ try {
74
+ const lines = fs.readFileSync(ep, "utf8").split("\n");
75
+ const get = (k) => {
76
+ const ln = lines.find((l) => l.startsWith(`${k}=`));
77
+ return ln ? ln.slice(k.length + 1).trim().replace(/^["']|["']$/g, "") : "";
78
+ };
79
+ const domain = get("MX_AUTH0_DOMAIN") || get("AUTH0_DOMAIN");
80
+ const clientId = get("MX_AUTH0_CLIENT_ID") || get("AUTH0_CLIENT_ID");
81
+ const clientSecret = get("MX_AUTH0_CLIENT_SECRET") || get("AUTH0_CLIENT_SECRET");
82
+ const audience = get("MX_AUTH0_AUDIENCE") || get("AUTH0_AUDIENCE");
83
+ if (domain && clientId) return { domain, clientId, clientSecret, audience };
84
+ } catch { /* try next */ }
85
+ }
86
+ } catch { /* no fops.json */ }
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Authenticate against a remote VM's Foundation API and return a bearer token.
92
+ * Tries the backend /iam/login first, then falls back to Auth0 ROPC.
93
+ */
94
+ export async function authenticateVm(vmUrl, ip, creds) {
95
+ if (creds.bearerToken) return creds.bearerToken;
96
+
97
+ const hasDomain = vmUrl && !vmUrl.match(/^https?:\/\/\d+\.\d+\.\d+\.\d+/);
98
+ const apiUrls = hasDomain
99
+ ? [`${vmUrl}/api`, ...(ip ? [`http://${ip}:9001/api`] : [])]
100
+ : [`${vmUrl}/api`, `https://${ip}:3002/api`, `http://${ip}:9001/api`];
101
+
102
+ for (const apiBase of apiUrls) {
103
+ try {
104
+ const resp = await vmFetch(`${apiBase}/iam/login`, {
105
+ method: "POST",
106
+ headers: { "Content-Type": "application/json" },
107
+ body: JSON.stringify({ user: creds.user, password: creds.password }),
108
+ });
109
+ if (resp.ok) {
110
+ const data = await resp.json();
111
+ const token = data.access_token || data.token;
112
+ if (token) return token;
113
+ }
114
+ } catch { /* try next URL */ }
115
+ }
116
+
117
+ // Auth0 ROPC fallback
118
+ const auth0 = resolveAuth0Config();
119
+ if (auth0 && creds.user && creds.password) {
120
+ try {
121
+ const body = {
122
+ grant_type: "password",
123
+ client_id: auth0.clientId,
124
+ username: creds.user,
125
+ password: creds.password,
126
+ scope: "openid",
127
+ };
128
+ if (auth0.clientSecret) body.client_secret = auth0.clientSecret;
129
+ if (auth0.audience) body.audience = auth0.audience;
130
+
131
+ const resp = await fetch(`https://${auth0.domain}/oauth/token`, {
132
+ method: "POST",
133
+ headers: { "Content-Type": "application/json" },
134
+ body: JSON.stringify(body),
135
+ signal: AbortSignal.timeout(10_000),
136
+ });
137
+ if (resp.ok) {
138
+ const data = await resp.json();
139
+ if (data.access_token) return data.access_token;
140
+ }
141
+ } catch { /* auth0 fallback failed */ }
142
+ }
143
+
144
+ return null;
145
+ }
146
+
147
+ export function isJwt(token) {
148
+ return token && token.split(".").length === 3;
149
+ }
150
+
151
+ /**
152
+ * Resolve a valid JWT bearer token for a remote VM/cluster.
153
+ * Auth chain: local bearer → pre-auth /iam/login → Auth0 ROPC → SSH fetch from VM.
154
+ * Returns { bearerToken, user, password, useTokenMode } or throws.
155
+ */
156
+ export async function resolveRemoteAuth(opts = {}) {
157
+ const {
158
+ apiUrl, ip, vmState, execaFn: execa, sshCmd: ssh,
159
+ knockForVm: knock, suppressTlsWarning: suppressTls,
160
+ } = opts;
161
+ const log = opts.log || console.log;
162
+
163
+ const creds = resolveFoundationCreds();
164
+ let qaUser = creds?.user || process.env.QA_USERNAME || process.env.FOUNDATION_USERNAME || "operator@local";
165
+ let qaPass = creds?.password || process.env.QA_PASSWORD || "";
166
+ let bearerToken = creds?.bearerToken || "";
167
+
168
+ // 1) Use local bearer if it's a valid JWT
169
+ if (bearerToken && isJwt(bearerToken)) {
170
+ return { bearerToken, qaUser, qaPass, useTokenMode: true };
171
+ }
172
+ bearerToken = "";
173
+
174
+ // 2) Pre-auth against the backend /iam/login
175
+ if (qaUser && qaPass && apiUrl) {
176
+ try {
177
+ if (suppressTls) suppressTls();
178
+ const prev = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
179
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
180
+ try {
181
+ const resp = await fetch(`${apiUrl}/iam/login`, {
182
+ method: "POST",
183
+ headers: { "Content-Type": "application/json" },
184
+ body: JSON.stringify({ username: qaUser, password: qaPass }),
185
+ signal: AbortSignal.timeout(10_000),
186
+ });
187
+ if (resp.ok) {
188
+ const data = await resp.json();
189
+ bearerToken = data.access_token || data.token || "";
190
+ if (bearerToken) {
191
+ log(chalk.green(` ✓ Authenticated as ${qaUser}`));
192
+ return { bearerToken, qaUser, qaPass, useTokenMode: true };
193
+ }
194
+ } else {
195
+ log(chalk.dim(` Local creds rejected: HTTP ${resp.status}`));
196
+ }
197
+ } finally {
198
+ if (prev === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
199
+ else process.env.NODE_TLS_REJECT_UNAUTHORIZED = prev;
200
+ }
201
+ } catch (e) {
202
+ log(chalk.dim(` Pre-auth failed: ${e.cause?.code || e.message}`));
203
+ }
204
+ }
205
+
206
+ // 3) Auth0 ROPC with local config
207
+ if (qaUser && qaPass) {
208
+ const auth0Cfg = resolveAuth0Config();
209
+ if (auth0Cfg) {
210
+ try {
211
+ log(chalk.dim(` Trying Auth0 ROPC (${auth0Cfg.domain})…`));
212
+ const body = {
213
+ grant_type: "password", client_id: auth0Cfg.clientId,
214
+ username: qaUser, password: qaPass, scope: "openid",
215
+ };
216
+ if (auth0Cfg.clientSecret) body.client_secret = auth0Cfg.clientSecret;
217
+ if (auth0Cfg.audience) body.audience = auth0Cfg.audience;
218
+ const resp = await fetch(`https://${auth0Cfg.domain}/oauth/token`, {
219
+ method: "POST",
220
+ headers: { "Content-Type": "application/json" },
221
+ body: JSON.stringify(body),
222
+ signal: AbortSignal.timeout(10_000),
223
+ });
224
+ if (resp.ok) {
225
+ const data = await resp.json();
226
+ if (data.access_token) {
227
+ log(chalk.green(` ✓ Authenticated as ${qaUser} via Auth0`));
228
+ return { bearerToken: data.access_token, qaUser, qaPass, useTokenMode: true };
229
+ }
230
+ } else {
231
+ log(chalk.dim(` Auth0 rejected: HTTP ${resp.status}`));
232
+ }
233
+ } catch (e) {
234
+ log(chalk.dim(` Auth0 failed: ${e.message}`));
235
+ }
236
+ }
237
+ }
238
+
239
+ // 4) SSH fallback — fetch credentials from VM and authenticate
240
+ if (ip && ssh && execa) {
241
+ if (knock && vmState) await knock(vmState);
242
+ log(chalk.dim(" Fetching credentials from remote VM…"));
243
+ try {
244
+ const sshUser = vmState?.adminUser || "azureuser";
245
+ const { stdout } = await ssh(
246
+ execa, ip, sshUser,
247
+ "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",
248
+ 15_000,
249
+ );
250
+ const remoteEnv = {};
251
+ for (const line of (stdout || "").split("\n")) {
252
+ const m = line.match(/^([A-Z_]+)=(.*)$/);
253
+ if (m) remoteEnv[m[1]] = m[2].trim().replace(/^["']|["']$/g, "");
254
+ }
255
+
256
+ const remoteToken = remoteEnv.BEARER_TOKEN;
257
+ if (remoteToken && isJwt(remoteToken)) {
258
+ log(chalk.green(" ✓ Got JWT bearer token from VM"));
259
+ return { bearerToken: remoteToken, qaUser, qaPass, useTokenMode: true };
260
+ }
261
+
262
+ if (remoteEnv.MX_AUTH0_DOMAIN && remoteEnv.MX_AUTH0_CLIENT_ID) {
263
+ const user = remoteEnv.QA_USERNAME || qaUser;
264
+ const pass = remoteEnv.QA_PASSWORD || qaPass;
265
+ if (user && pass) {
266
+ log(chalk.dim(` Authenticating as ${user} via VM's Auth0…`));
267
+ const body = {
268
+ grant_type: "password", client_id: remoteEnv.MX_AUTH0_CLIENT_ID,
269
+ username: user, password: pass, scope: "openid",
270
+ };
271
+ if (remoteEnv.MX_AUTH0_CLIENT_SECRET) body.client_secret = remoteEnv.MX_AUTH0_CLIENT_SECRET;
272
+ if (remoteEnv.MX_AUTH0_AUDIENCE) body.audience = remoteEnv.MX_AUTH0_AUDIENCE;
273
+ try {
274
+ const resp = await fetch(`https://${remoteEnv.MX_AUTH0_DOMAIN}/oauth/token`, {
275
+ method: "POST",
276
+ headers: { "Content-Type": "application/json" },
277
+ body: JSON.stringify(body),
278
+ signal: AbortSignal.timeout(10_000),
279
+ });
280
+ if (resp.ok) {
281
+ const data = await resp.json();
282
+ if (data.access_token) {
283
+ log(chalk.green(` ✓ Authenticated via VM's Auth0`));
284
+ return { bearerToken: data.access_token, qaUser: user, qaPass: pass, useTokenMode: true };
285
+ }
286
+ }
287
+ } catch (e) {
288
+ log(chalk.dim(` VM Auth0 login failed: ${e.message}`));
289
+ }
290
+ }
291
+ }
292
+
293
+ if (remoteToken) {
294
+ log(chalk.yellow(" ⚠ Using non-JWT token from VM (may cause 401)"));
295
+ return { bearerToken: remoteToken, qaUser, qaPass, useTokenMode: true };
296
+ }
297
+ } catch (e) {
298
+ log(chalk.dim(` SSH credential fetch failed: ${e.message}`));
299
+ }
300
+ }
301
+
302
+ return { bearerToken: "", qaUser, qaPass, useTokenMode: false };
303
+ }
304
+
305
+ /**
306
+ * Fetch landscape entities from a remote VM's Foundation API.
307
+ * Returns embeddable chunks tagged with the VM name.
308
+ */
309
+ export async function fetchRemoteLandscape(vmName, vmUrl, ip, token, log) {
310
+ const hasDomain = vmUrl && !vmUrl.match(/^https?:\/\/\d+\.\d+\.\d+\.\d+/);
311
+ const apiBase = hasDomain ? `${vmUrl}/api` : `http://${ip}:9001/api`;
312
+ const headers = { Authorization: `Bearer ${token}`, "x-org": "root" };
313
+
314
+ let authRejected = 0;
315
+ const fetchJson = async (path) => {
316
+ const resp = await vmFetch(`${apiBase}${path}`, { headers, signal: AbortSignal.timeout(15_000) });
317
+ if (resp.status === 401 || resp.status === 403) { authRejected++; return null; }
318
+ if (!resp.ok) return null;
319
+ return resp.json();
320
+ };
321
+
322
+ const [meshRes, dpRes, dsRes, dSysRes] = await Promise.all([
323
+ fetchJson("/data/mesh/list?per_page=100").catch(() => null),
324
+ fetchJson("/data/data_product/list?per_page=200").catch(() => null),
325
+ fetchJson("/data/data_source/list?per_page=200").catch(() => null),
326
+ fetchJson("/data/data_system/list?per_page=100").catch(() => null),
327
+ ]);
328
+
329
+ if (authRejected >= 4) {
330
+ const err = new Error("token rejected by all endpoints");
331
+ err.code = "AUTH_REJECTED";
332
+ throw err;
333
+ }
334
+
335
+ const { parseListResponse } = await import("../fops-plugin-foundation/lib/api-spec.js");
336
+ const chunks = [];
337
+
338
+ for (const m of parseListResponse(meshRes)) {
339
+ 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");
340
+ chunks.push({ title: `vm/${vmName}/mesh/${m.name}`, content: text, metadata: { resourceType: "remote-landscape", entityType: "mesh", vm: vmName, identifier: m.identifier, fileHash: hashContent(text) } });
341
+ }
342
+ for (const p of parseListResponse(dpRes)) {
343
+ 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");
344
+ 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) } });
345
+ }
346
+ for (const s of parseListResponse(dsRes)) {
347
+ 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");
348
+ 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) } });
349
+ }
350
+ for (const sys of parseListResponse(dSysRes)) {
351
+ 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");
352
+ 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) } });
353
+ }
354
+
355
+ const meshCount = parseListResponse(meshRes).length;
356
+ const prodCount = parseListResponse(dpRes).length;
357
+ const srcCount = parseListResponse(dsRes).length;
358
+ const sysCount = parseListResponse(dSysRes).length;
359
+ log(` azure/${vmName}: ${meshCount} meshes, ${prodCount} products, ${srcCount} sources, ${sysCount} systems`);
360
+
361
+ return chunks;
362
+ }
363
+
364
+ /**
365
+ * Fetch remote landscape by SSHing into the VM and curling localhost:9001 directly,
366
+ * bypassing Traefik/OAuth. Used when the HTTPS endpoint rejects credentials.
367
+ */
368
+ export async function fetchLandscapeViaSsh(vmName, ip, creds, log) {
369
+ const { lazyExeca, sshCmd, knockForVm, DEFAULTS } = await import("./azure.js");
370
+ const execa = await lazyExeca();
371
+ const user = DEFAULTS.adminUser;
372
+
373
+ try { await knockForVm({ publicIp: ip, vmName }); } catch {}
374
+
375
+ const ssh = (cmd, timeout = 15000) => sshCmd(execa, ip, user, cmd, timeout);
376
+
377
+ const loginPayload = JSON.stringify({ user: creds.user, password: creds.password });
378
+ const { stdout: loginOut, exitCode: loginExit } = await ssh(
379
+ `curl -sf -X POST http://localhost:9001/api/iam/login -H 'Content-Type: application/json' -d '${loginPayload.replace(/'/g, "'\\''")}'`,
380
+ 15000,
381
+ );
382
+
383
+ if (loginExit !== 0 || !loginOut?.trim()) {
384
+ log(` azure/${vmName}: SSH auth failed (exit ${loginExit})`);
385
+ return [];
386
+ }
387
+
388
+ let token;
389
+ try {
390
+ const data = JSON.parse(loginOut.trim());
391
+ token = data.access_token || data.token;
392
+ } catch {
393
+ log(` azure/${vmName}: SSH auth returned invalid JSON`);
394
+ return [];
395
+ }
396
+ if (!token) {
397
+ log(` azure/${vmName}: SSH auth returned no token`);
398
+ return [];
399
+ }
400
+
401
+ const authHeader = `Authorization: Bearer ${token}`;
402
+ const { stdout: dataOut, exitCode: dataExit } = await ssh(
403
+ [
404
+ `curl -sf -H '${authHeader}' -H 'x-org: root' 'http://localhost:9001/api/data/mesh/list?per_page=100'`,
405
+ `echo '___SEP___'`,
406
+ `curl -sf -H '${authHeader}' -H 'x-org: root' 'http://localhost:9001/api/data/data_product/list?per_page=200'`,
407
+ `echo '___SEP___'`,
408
+ `curl -sf -H '${authHeader}' -H 'x-org: root' 'http://localhost:9001/api/data/data_source/list?per_page=200'`,
409
+ `echo '___SEP___'`,
410
+ `curl -sf -H '${authHeader}' -H 'x-org: root' 'http://localhost:9001/api/data/data_system/list?per_page=100'`,
411
+ ].join(" && "),
412
+ 30000,
413
+ );
414
+
415
+ if (dataExit !== 0 || !dataOut?.trim()) {
416
+ log(` azure/${vmName}: SSH landscape fetch failed (exit ${dataExit})`);
417
+ return [];
418
+ }
419
+
420
+ const parts = dataOut.split("___SEP___");
421
+ const parseJson = (s) => { try { return JSON.parse(s.trim()); } catch { return null; } };
422
+ const meshRes = parseJson(parts[0] || "");
423
+ const dpRes = parseJson(parts[1] || "");
424
+ const dsRes = parseJson(parts[2] || "");
425
+ const dSysRes = parseJson(parts[3] || "");
426
+
427
+ const { parseListResponse } = await import("../fops-plugin-foundation/lib/api-spec.js");
428
+ const chunks = [];
429
+
430
+ for (const m of parseListResponse(meshRes)) {
431
+ 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");
432
+ chunks.push({ title: `vm/${vmName}/mesh/${m.name}`, content: text, metadata: { resourceType: "remote-landscape", entityType: "mesh", vm: vmName, identifier: m.identifier, fileHash: hashContent(text) } });
433
+ }
434
+ for (const p of parseListResponse(dpRes)) {
435
+ 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");
436
+ 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) } });
437
+ }
438
+ for (const s of parseListResponse(dsRes)) {
439
+ 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");
440
+ 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) } });
441
+ }
442
+ for (const sys of parseListResponse(dSysRes)) {
443
+ 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");
444
+ 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) } });
445
+ }
446
+
447
+ const meshCount = parseListResponse(meshRes).length;
448
+ const prodCount = parseListResponse(dpRes).length;
449
+ const srcCount = parseListResponse(dsRes).length;
450
+ const sysCount = parseListResponse(dSysRes).length;
451
+ log(` azure/${vmName}: ${meshCount} meshes, ${prodCount} products, ${srcCount} sources, ${sysCount} systems (via SSH)`);
452
+
453
+ return chunks;
454
+ }
@@ -618,7 +618,9 @@ const FOPS_KNOCK_TAG = "fopsKnock";
618
618
  /** Persist knock sequence to VM tag so other machines can fetch it. */
619
619
  export async function setVmKnockTag(execa, rg, vmName, knockSequence, sub) {
620
620
  if (!knockSequence?.length) return;
621
- const value = knockSequence.join(",");
621
+ // Use dash delimiter — Azure CLI --set treats commas as array separators
622
+ // which wraps the value in parens "(49198, 49200, 49180)" and breaks parsing.
623
+ const value = knockSequence.join("-");
622
624
  try {
623
625
  await execa("az", [
624
626
  "vm", "update", "--resource-group", rg, "--name", vmName,
@@ -629,23 +631,54 @@ export async function setVmKnockTag(execa, rg, vmName, knockSequence, sub) {
629
631
  } catch { /* non-fatal */ }
630
632
  }
631
633
 
634
+ // Per-process cache: vmName → true, so we only fetch from Azure once per CLI invocation.
635
+ const _vmStateSynced = new Set();
636
+
632
637
  /**
633
- * If local state has no knock sequence, try to load it from the VM's Azure tag
634
- * (set at provision time). Use when switching to another machine.
638
+ * Sync VM state (public IP + knock sequence) from Azure on the first call per process.
639
+ * Azure is the canonical source of truth local state drifts when VMs are
640
+ * restarted/reallocated (new IP) or knock sequences change.
635
641
  */
636
642
  export async function ensureKnockSequence(state) {
637
- if (state?.knockSequence?.length) return state;
638
643
  if (!state?.resourceGroup || !state?.vmName) return state;
644
+ if (_vmStateSynced.has(state.vmName)) return readVmState(state.vmName);
639
645
  const execa = await lazyExeca();
640
- const { stdout, exitCode } = await execa("az", [
641
- "vm", "show", "-g", state.resourceGroup, "-n", state.vmName,
642
- "--query", `tags.${FOPS_KNOCK_TAG}`, "-o", "tsv",
643
- ], { timeout: 15000, reject: false }).catch(() => ({ stdout: "", exitCode: 1 }));
644
- const raw = (stdout || "").trim();
645
- if (exitCode !== 0 || !raw) return state;
646
- const knockSequence = raw.split(",").map((s) => parseInt(s, 10)).filter((n) => Number.isInteger(n) && n > 0);
647
- if (knockSequence.length < 2) return state;
648
- writeVmState(state.vmName, { knockSequence });
646
+ _vmStateSynced.add(state.vmName);
647
+
648
+ // Fetch public IP and knock sequence tag in parallel
649
+ const [ipResult, tagResult] = await Promise.all([
650
+ execa("az", [
651
+ "vm", "list-ip-addresses", "-g", state.resourceGroup, "-n", state.vmName, "--output", "json",
652
+ ], { timeout: 15000, reject: false }).catch(() => ({ stdout: "[]", exitCode: 1 })),
653
+ execa("az", [
654
+ "vm", "show", "-g", state.resourceGroup, "-n", state.vmName,
655
+ "--query", `tags.${FOPS_KNOCK_TAG}`, "-o", "tsv",
656
+ ], { timeout: 15000, reject: false }).catch(() => ({ stdout: "", exitCode: 1 })),
657
+ ]);
658
+
659
+ const patch = {};
660
+
661
+ // Sync public IP
662
+ try {
663
+ const ips = JSON.parse(ipResult.stdout || "[]");
664
+ const freshIp = ips?.[0]?.virtualMachine?.network?.publicIpAddresses?.[0]?.ipAddress || "";
665
+ if (freshIp && freshIp !== state.publicIp) patch.publicIp = freshIp;
666
+ } catch {}
667
+
668
+ // Sync knock sequence — tag value may use dash or comma delimiter;
669
+ // Azure CLI --set sometimes wraps comma values in parens like "(49198, 49200, 49180)"
670
+ const raw = (tagResult.stdout || "").trim().replace(/[()]/g, "");
671
+ if (tagResult.exitCode === 0 && raw) {
672
+ const knockSequence = raw.split(/[-,]/).map((s) => parseInt(s.trim(), 10)).filter((n) => Number.isInteger(n) && n > 0);
673
+ if (knockSequence.length >= 2) {
674
+ const local = state.knockSequence;
675
+ if (!local?.length || local.length !== knockSequence.length || local.some((v, i) => v !== knockSequence[i])) {
676
+ patch.knockSequence = knockSequence;
677
+ }
678
+ }
679
+ }
680
+
681
+ if (Object.keys(patch).length > 0) writeVmState(state.vmName, patch);
649
682
  return readVmState(state.vmName);
650
683
  }
651
684
 
@@ -656,6 +689,11 @@ export async function knockForVm(vmNameOrState) {
656
689
  ? readVmState(vmNameOrState) : vmNameOrState;
657
690
  state = await ensureKnockSequence(state);
658
691
  if (state?.knockSequence?.length && state.publicIp) {
692
+ // Close stale mux before knocking — ControlPersist=600 keeps the master alive for
693
+ // 10 min, but if the VM rebooted the underlying TCP is broken and all SSH commands
694
+ // through it will fail even after a successful knock.
695
+ const execa = await lazyExeca();
696
+ await closeMux(execa, state.publicIp, DEFAULTS.adminUser);
659
697
  await performKnock(state.publicIp, state.knockSequence, { quiet: true });
660
698
  }
661
699
  }