@meshxdata/fops 0.1.36 → 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.
- package/CHANGELOG.md +22 -0
- package/fops.mjs +37 -14
- package/package.json +1 -1
- package/src/plugins/bundled/fops-plugin-azure/index.js +44 -2896
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-auth.js +454 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-helpers.js +51 -13
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops.js +182 -27
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision.js +62 -8
- package/src/plugins/bundled/fops-plugin-azure/lib/azure.js +2 -2
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/fleet-cmds.js +254 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/infra-cmds.js +890 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/test-cmds.js +314 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +892 -0
- package/src/plugins/bundled/fops-plugin-file/demo/orders_renamed.aligned.csv +1 -1
- package/src/plugins/bundled/fops-plugin-file/index.js +81 -12
- package/src/plugins/bundled/fops-plugin-file/lib/match.js +133 -15
- package/src/plugins/bundled/fops-plugin-file/lib/report.js +3 -0
- package/src/plugins/bundled/fops-plugin-foundation/lib/client.js +9 -5
- package/src/plugins/bundled/fops-plugin-foundation-graphql/lib/graphql/resolvers/data-product.js +32 -0
- package/src/plugins/bundled/fops-plugin-foundation-graphql/lib/graphql/schema.js +20 -1
|
@@ -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
|
-
|
|
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
|
-
*
|
|
634
|
-
*
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
}
|