@meshxdata/fops 0.1.51 → 0.1.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +207 -21
  2. package/package.json +2 -6
  3. package/src/agent/agent.js +6 -0
  4. package/src/commands/setup.js +34 -0
  5. package/src/doctor.js +11 -8
  6. package/src/fleet-registry.js +38 -2
  7. package/src/plugins/__test-fixtures__/fake-plugin.js +2 -0
  8. package/src/plugins/__test-fixtures__/no-register-plugin.js +2 -0
  9. package/src/plugins/__test-fixtures__/with-register/index.js +2 -0
  10. package/src/plugins/__test-fixtures__/without-register/index.js +2 -0
  11. package/src/plugins/api.js +4 -0
  12. package/src/plugins/builtins/docker-compose.js +59 -0
  13. package/src/plugins/bundled/fops-plugin-azure/index.js +4 -0
  14. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-core.js +53 -53
  15. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-secrets.js +151 -0
  16. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-storage.js +2 -2
  17. package/src/plugins/bundled/fops-plugin-azure/lib/azure-cost.js +52 -22
  18. package/src/plugins/bundled/fops-plugin-azure/lib/azure-fleet.js +12 -4
  19. package/src/plugins/bundled/fops-plugin-azure/lib/azure-helpers.js +6 -2
  20. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops.js +113 -7
  21. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision-init.js +13 -4
  22. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision.js +91 -14
  23. package/src/plugins/bundled/fops-plugin-azure/lib/azure-service.js +507 -0
  24. package/src/plugins/bundled/fops-plugin-azure/lib/azure-sync.js +146 -7
  25. package/src/plugins/bundled/fops-plugin-azure/lib/azure.js +1 -1
  26. package/src/plugins/bundled/fops-plugin-azure/lib/commands/test-cmds.js +28 -0
  27. package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +61 -0
  28. package/src/plugins/bundled/fops-plugin-cloud/api.js +712 -0
  29. package/src/plugins/bundled/fops-plugin-cloud/fops.plugin.json +6 -0
  30. package/src/plugins/bundled/fops-plugin-cloud/index.js +208 -0
  31. package/src/plugins/bundled/fops-plugin-cloud/lib/azure-provider.js +81 -0
  32. package/src/plugins/bundled/fops-plugin-cloud/lib/provider.js +50 -0
  33. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/favicon-C49brna2.svg +15 -0
  34. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/index-CVqQ_kKW.js +65 -0
  35. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/index-DZetahP3.css +1 -0
  36. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/index.html +28 -0
  37. package/src/plugins/bundled/fops-plugin-cloud/ui/index.html +27 -0
  38. package/src/plugins/bundled/fops-plugin-cloud/ui/package-lock.json +2634 -0
  39. package/src/plugins/bundled/fops-plugin-cloud/ui/package.json +29 -0
  40. package/src/plugins/bundled/fops-plugin-cloud/ui/postcss.config.cjs +5 -0
  41. package/src/plugins/bundled/fops-plugin-cloud/ui/src/App.jsx +32 -0
  42. package/src/plugins/bundled/fops-plugin-cloud/ui/src/api/client.js +114 -0
  43. package/src/plugins/bundled/fops-plugin-cloud/ui/src/api/queries.js +111 -0
  44. package/src/plugins/bundled/fops-plugin-cloud/ui/src/components/LogPanel.jsx +162 -0
  45. package/src/plugins/bundled/fops-plugin-cloud/ui/src/components/ThemeToggle.jsx +46 -0
  46. package/src/plugins/bundled/fops-plugin-cloud/ui/src/css/additional-styles/utility-patterns.css +147 -0
  47. package/src/plugins/bundled/fops-plugin-cloud/ui/src/css/style.css +138 -0
  48. package/src/plugins/bundled/fops-plugin-cloud/ui/src/favicon.svg +15 -0
  49. package/src/plugins/bundled/fops-plugin-cloud/ui/src/lib/utils.ts +19 -0
  50. package/src/plugins/bundled/fops-plugin-cloud/ui/src/main.jsx +25 -0
  51. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Audit.jsx +164 -0
  52. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Costs.jsx +305 -0
  53. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/CreateResource.jsx +285 -0
  54. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Fleet.jsx +307 -0
  55. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Resources.jsx +229 -0
  56. package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/Header.jsx +132 -0
  57. package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/Sidebar.jsx +174 -0
  58. package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/SidebarLinkGroup.jsx +21 -0
  59. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/AuthContext.jsx +170 -0
  60. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Info.jsx +49 -0
  61. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/ThemeContext.jsx +37 -0
  62. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Transition.jsx +116 -0
  63. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Utils.js +63 -0
  64. package/src/plugins/bundled/fops-plugin-cloud/ui/vite.config.js +23 -0
  65. package/src/plugins/bundled/fops-plugin-foundation/test-helpers.js +65 -0
  66. package/src/plugins/loader.js +34 -1
  67. package/src/plugins/registry.js +15 -0
  68. package/src/plugins/schemas.js +17 -0
  69. package/src/project.js +1 -1
  70. package/src/serve.js +196 -2
  71. package/src/shell.js +21 -1
  72. package/src/web/admin.html.js +236 -0
  73. package/src/web/api.js +73 -0
  74. package/src/web/dist/assets/index-BphVaAUd.css +1 -0
  75. package/src/web/dist/assets/index-CSckLzuG.js +129 -0
  76. package/src/web/dist/index.html +2 -2
  77. package/src/web/frontend/index.html +16 -0
  78. package/src/web/frontend/src/App.jsx +445 -0
  79. package/src/web/frontend/src/components/ChatView.jsx +910 -0
  80. package/src/web/frontend/src/components/InputBox.jsx +523 -0
  81. package/src/web/frontend/src/components/Sidebar.jsx +410 -0
  82. package/src/web/frontend/src/components/StatusBar.jsx +37 -0
  83. package/src/web/frontend/src/components/TabBar.jsx +87 -0
  84. package/src/web/frontend/src/hooks/useWebSocket.js +412 -0
  85. package/src/web/frontend/src/index.css +78 -0
  86. package/src/web/frontend/src/main.jsx +6 -0
  87. package/src/web/frontend/vite.config.js +21 -0
  88. package/src/web/server.js +64 -1
  89. package/src/web/dist/assets/index-NXC8Hvnp.css +0 -1
  90. package/src/web/dist/assets/index-QH1N4ejK.js +0 -112
@@ -0,0 +1,712 @@
1
+ /**
2
+ * Cloud panel API routes — Hono sub-app mounted at /cloud/api
3
+ */
4
+
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { Hono } from "hono";
9
+ import { stream } from "hono/streaming";
10
+ import { createRemoteJWKSet, jwtVerify } from "jose";
11
+ import { AzureProvider } from "./lib/azure-provider.js";
12
+
13
+ // ── Flux template reader ────────────────────────────────────────────────
14
+ // Reads service image tags from platform-flux-template kustomization overlays.
15
+ const SVC_SHORT = { backend: "be", frontend: "fe", processor: "pr", watcher: "wa", scheduler: "sc", "storage-engine": "se" };
16
+ const TAG_RE = /^\s*tag:\s*(.+)$/m;
17
+
18
+ /**
19
+ * Given a cluster name (e.g. "demo"), resolve service versions from flux overlays.
20
+ * Tries "{name}-azure" overlay convention in platform-flux-template.
21
+ * @param {string} rootDir - project root (foundation-compose)
22
+ * @param {string} clusterName
23
+ * @returns {{ [shortKey]: { tag: string } }}
24
+ */
25
+ /** Resolve the foundation-compose root directory from ~/.fops.json projectRoot. */
26
+ let _composeRoot = null;
27
+ function resolveComposeRoot() {
28
+ if (_composeRoot !== null) return _composeRoot;
29
+ const envRoot = process.env.FOUNDATION_ROOT || process.env.COMPOSE_ROOT;
30
+ if (envRoot && fs.existsSync(envRoot)) { _composeRoot = envRoot; return _composeRoot; }
31
+ try {
32
+ const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".fops.json"), "utf8"));
33
+ if (cfg.projectRoot && fs.existsSync(cfg.projectRoot)) { _composeRoot = cfg.projectRoot; return _composeRoot; }
34
+ } catch {}
35
+ _composeRoot = "";
36
+ return _composeRoot;
37
+ }
38
+
39
+ function readFluxServiceVersions(rootDir, clusterName) {
40
+ if (!rootDir) return {};
41
+ const fluxBase = path.join(rootDir, "platform-flux-template", "apps", "foundation");
42
+ if (!fs.existsSync(fluxBase)) return {};
43
+
44
+ const overlayName = `${clusterName}-azure`;
45
+ const services = {};
46
+ for (const [svcName, shortKey] of Object.entries(SVC_SHORT)) {
47
+ const kustomPath = path.join(fluxBase, svcName, "overlays", overlayName, "kustomization.yaml");
48
+ try {
49
+ const content = fs.readFileSync(kustomPath, "utf8");
50
+ const m = content.match(TAG_RE);
51
+ if (m) {
52
+ const tag = m[1].trim().replace(/["']/g, "");
53
+ // Skip unresolved template vars like {{IMAGE_TAG}}
54
+ if (!tag.startsWith("{{")) {
55
+ services[shortKey] = { tag };
56
+ }
57
+ }
58
+ } catch { /* overlay not found */ }
59
+ }
60
+ return services;
61
+ }
62
+
63
+ // ── Auth0 config ────────────────────────────────────────────────────────
64
+ /** Auth0 is behind a feature flag. Set CLOUD_AUTH=1 to enable. Default: off. */
65
+ function isAuthEnabled() {
66
+ const flag = process.env.CLOUD_AUTH || "0";
67
+ return flag === "1" || flag === "true";
68
+ }
69
+
70
+ function getAuth0Config() {
71
+ return {
72
+ domain: process.env.AUTH0_DOMAIN || process.env.MX_AUTH0_DOMAIN || "",
73
+ clientId: process.env.NEXT_PUBLIC_AUTH0_CLIENT_ID || process.env.MX_AUTH0_CLIENT_ID || "",
74
+ audience: process.env.AUTH0_AUDIENCE || process.env.MX_AUTH0_AUDIENCE || "",
75
+ };
76
+ }
77
+
78
+ let _jwks = null;
79
+ function getJWKS(domain) {
80
+ if (!_jwks) {
81
+ _jwks = createRemoteJWKSet(new URL(`https://${domain}/.well-known/jwks.json`));
82
+ }
83
+ return _jwks;
84
+ }
85
+
86
+ /**
87
+ * Verify an Auth0 JWT and return decoded payload.
88
+ * Payload includes `permissions` array if RBAC is enabled on the Auth0 API.
89
+ */
90
+ async function verifyToken(token) {
91
+ const { domain, audience } = getAuth0Config();
92
+ if (!domain) throw new Error("AUTH0_DOMAIN not configured");
93
+ const jwks = getJWKS(domain);
94
+ const { payload } = await jwtVerify(token, jwks, {
95
+ issuer: `https://${domain}/`,
96
+ audience,
97
+ });
98
+ return payload;
99
+ }
100
+
101
+ /**
102
+ * Hono middleware — rejects requests without a valid JWT.
103
+ * Sets c.set("user", payload) on success.
104
+ */
105
+ function requireAuth() {
106
+ return async (c, next) => {
107
+ const auth = c.req.header("Authorization");
108
+ if (!auth?.startsWith("Bearer ")) {
109
+ return c.json({ error: "Missing or invalid Authorization header" }, 401);
110
+ }
111
+ try {
112
+ const payload = await verifyToken(auth.slice(7));
113
+ c.set("user", payload);
114
+ return next();
115
+ } catch (e) {
116
+ return c.json({ error: "Invalid token", detail: e.message }, 401);
117
+ }
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Hono middleware — checks that the user has one of the required permissions.
123
+ * Must be used after requireAuth().
124
+ */
125
+ function requirePermission(...perms) {
126
+ return async (c, next) => {
127
+ const user = c.get("user");
128
+ const userPerms = user?.permissions || [];
129
+ const roles = user?.["https://meshx.dev/roles"] || [];
130
+ // Admin role bypasses permission checks
131
+ if (roles.includes("admin")) return next();
132
+ if (perms.some((p) => userPerms.includes(p))) return next();
133
+ return c.json({ error: "Forbidden", required: perms }, 403);
134
+ };
135
+ }
136
+
137
+ // ANSI escape code stripper
138
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
139
+ const strip = (s) => s.replace(ANSI_RE, "");
140
+
141
+ // ── Job store ───────────────────────────────────────────────────────────
142
+ // In-memory store for active/recent provisioning jobs so the UI can
143
+ // reconnect after a page reload and still see buffered log output.
144
+
145
+ const jobs = new Map(); // id → { id, label, status, logs[], result?, error?, startedAt }
146
+ let jobSeq = 0;
147
+ const JOB_TTL_MS = 30 * 60 * 1000; // keep completed jobs for 30 min
148
+
149
+ function createJob(label) {
150
+ const id = `job-${++jobSeq}-${Date.now().toString(36)}`;
151
+ const job = { id, label, status: "running", logs: [], result: null, error: null, startedAt: Date.now() };
152
+ jobs.set(id, job);
153
+ return job;
154
+ }
155
+
156
+ function pruneJobs() {
157
+ const cutoff = Date.now() - JOB_TTL_MS;
158
+ for (const [id, job] of jobs) {
159
+ if (job.status !== "running" && job.startedAt < cutoff) jobs.delete(id);
160
+ }
161
+ }
162
+
163
+ // ── Console capture ─────────────────────────────────────────────────
164
+ // Multiple streaming operations can run concurrently. Instead of monkey-patching
165
+ // console.log per-operation (which breaks when two overlap), we install a single
166
+ // permanent intercept and dispatch to all active listeners.
167
+
168
+ const _origLog = console.log;
169
+ const _origErr = console.error;
170
+ const _listeners = new Set(); // Set<{ send: (type, text) => void }>
171
+
172
+ let _installed = false;
173
+ function installConsoleCapture() {
174
+ if (_installed) return;
175
+ _installed = true;
176
+
177
+ console.log = (...args) => {
178
+ const line = args.map(String).join(" ");
179
+ for (const l of _listeners) l.send("log", line);
180
+ _origLog.apply(console, args);
181
+ };
182
+ console.error = (...args) => {
183
+ const line = args.map(String).join(" ");
184
+ for (const l of _listeners) l.send("error", line);
185
+ _origErr.apply(console, args);
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Run an async function while capturing console.log/error output,
191
+ * streaming each line as an SSE event to the client AND buffering
192
+ * them in a job so the UI can reconnect after reload.
193
+ */
194
+ function streamOperation(c, fn, label = "operation") {
195
+ const job = createJob(label);
196
+ pruneJobs();
197
+ installConsoleCapture();
198
+
199
+ return stream(c, async (s) => {
200
+ c.header("Content-Type", "text/event-stream");
201
+ c.header("Cache-Control", "no-cache");
202
+ c.header("Connection", "keep-alive");
203
+
204
+ await s.write(`data: ${JSON.stringify({ type: "job", jobId: job.id })}\n\n`);
205
+
206
+ const listener = {
207
+ send: (type, text) => {
208
+ const raw = String(text);
209
+ if (!strip(raw).trim()) return;
210
+ job.logs.push({ type, text: raw });
211
+ try { s.write(`data: ${JSON.stringify({ type, text: raw })}\n\n`); } catch { /* client disconnected */ }
212
+ },
213
+ };
214
+ _listeners.add(listener);
215
+
216
+ try {
217
+ const result = await fn();
218
+ job.status = "done";
219
+ job.result = result;
220
+ await s.write(`data: ${JSON.stringify({ type: "done", result })}\n\n`);
221
+ } catch (e) {
222
+ job.status = "error";
223
+ job.error = e.message;
224
+ await s.write(`data: ${JSON.stringify({ type: "error", text: e.message })}\n\n`);
225
+ } finally {
226
+ _listeners.delete(listener);
227
+ }
228
+ });
229
+ }
230
+
231
+ /**
232
+ * Create API routes for the cloud panel.
233
+ * @param {object} registry - Plugin registry
234
+ * @returns {Hono}
235
+ */
236
+ export function createCloudApi(registry) {
237
+ const app = new Hono();
238
+
239
+ // ── Public routes (no auth required) ────────────────
240
+ app.get("/auth-config", (c) => {
241
+ const enabled = isAuthEnabled();
242
+ const cfg = getAuth0Config();
243
+ return c.json({
244
+ enabled,
245
+ domain: cfg.domain,
246
+ clientId: cfg.clientId,
247
+ audience: cfg.audience,
248
+ });
249
+ });
250
+
251
+ // ── Protected routes (skipped when auth is disabled) ──
252
+ app.use("/*", async (c, next) => {
253
+ // Skip auth for auth-config endpoint (already handled above)
254
+ if (c.req.path === "/auth-config") return next();
255
+ // When auth is disabled, grant full access
256
+ if (!isAuthEnabled()) {
257
+ c.set("user", { sub: "local", permissions: ["cloud:admin"], "https://meshx.dev/roles": ["admin"] });
258
+ return next();
259
+ }
260
+ return requireAuth()(c, next);
261
+ });
262
+
263
+ // User profile from token
264
+ app.get("/me", (c) => {
265
+ const user = c.get("user");
266
+ return c.json({
267
+ sub: user.sub,
268
+ email: user.email || user["https://meshx.dev/email"] || "",
269
+ name: user.name || user["https://meshx.dev/name"] || "",
270
+ picture: user.picture || user["https://meshx.dev/picture"] || "",
271
+ roles: user["https://meshx.dev/roles"] || [],
272
+ permissions: user.permissions || [],
273
+ });
274
+ });
275
+
276
+ /** Lazily resolve providers from plugin services */
277
+ function getProviders() {
278
+ const providers = [];
279
+ const azureSvc = registry.services.find((s) => s.name === "azure");
280
+ if (azureSvc) {
281
+ providers.push(new AzureProvider(azureSvc.instance));
282
+ }
283
+ return providers;
284
+ }
285
+
286
+ function getProvider(name) {
287
+ return getProviders().find((p) => p.name === name) || null;
288
+ }
289
+
290
+ // ── Jobs ──────────────────────────────────────────────
291
+
292
+ app.get("/jobs", (c) => {
293
+ const list = [...jobs.values()].map(({ id, label, status, startedAt, logs }) => ({
294
+ id, label, status, startedAt, lineCount: logs.length,
295
+ }));
296
+ return c.json(list);
297
+ });
298
+
299
+ app.get("/jobs/:id", (c) => {
300
+ const job = jobs.get(c.req.param("id"));
301
+ if (!job) return c.json({ error: "Job not found" }, 404);
302
+ const since = parseInt(c.req.query("since") || "0", 10);
303
+ return c.json({
304
+ id: job.id,
305
+ label: job.label,
306
+ status: job.status,
307
+ logs: job.logs.slice(since),
308
+ offset: since,
309
+ total: job.logs.length,
310
+ result: job.result,
311
+ error: job.error,
312
+ });
313
+ });
314
+
315
+ // ── Health ──────────────────────────────────────────────
316
+
317
+ app.get("/health", async (c) => {
318
+ const providers = getProviders();
319
+ const checks = await Promise.all(
320
+ providers.map(async (p) => ({
321
+ provider: p.name,
322
+ ...(await p.healthCheck()),
323
+ })),
324
+ );
325
+ const healthy = checks.every((ch) => ch.available);
326
+ return c.json({ ok: healthy, providers: checks });
327
+ });
328
+
329
+ // ── Providers ───────────────────────────────────────────
330
+
331
+ app.get("/providers", (c) => {
332
+ const providers = getProviders().map((p) => ({ name: p.name }));
333
+ return c.json(providers);
334
+ });
335
+
336
+ // ── Resources ───────────────────────────────────────────
337
+
338
+ app.get("/resources", async (c) => {
339
+ const providers = getProviders();
340
+ let allVms = [];
341
+ let allClusters = [];
342
+ for (const p of providers) {
343
+ try {
344
+ const { vms, clusters } = await p.listResources();
345
+ allVms.push(...vms);
346
+ allClusters.push(...clusters);
347
+ } catch (e) {
348
+ // skip unavailable providers
349
+ }
350
+ }
351
+ return c.json({ vms: allVms, clusters: allClusters });
352
+ });
353
+
354
+ app.get("/resources/:type/:name", async (c) => {
355
+ const { type, name } = c.req.param();
356
+ const providers = getProviders();
357
+ for (const p of providers) {
358
+ try {
359
+ const resource = await p.getResource(type, name);
360
+ if (resource) return c.json(resource);
361
+ } catch { /* try next */ }
362
+ }
363
+ return c.json({ error: "Resource not found" }, 404);
364
+ });
365
+
366
+ app.post("/resources/:type", requirePermission("cloud:write", "cloud:admin"), async (c) => {
367
+ const { type } = c.req.param();
368
+ const body = await c.req.json();
369
+ const providerName = body.provider || "azure";
370
+ const provider = getProvider(providerName);
371
+ if (!provider) return c.json({ error: `Provider "${providerName}" not available` }, 400);
372
+ const name = body.vmName || body.clusterName || type;
373
+ return streamOperation(c, () => provider.createResource(type, body), `create-${type}-${name}`);
374
+ });
375
+
376
+ app.post("/resources/:type/:name/:action", requirePermission("cloud:write", "cloud:admin"), async (c) => {
377
+ const { type, name, action } = c.req.param();
378
+ let body = {};
379
+ try { body = await c.req.json(); } catch { /* no body is fine */ }
380
+ const providerName = body.provider || "azure";
381
+ const provider = getProvider(providerName);
382
+ if (!provider) return c.json({ error: `Provider "${providerName}" not available` }, 400);
383
+ return streamOperation(c, () => provider.performAction(type, name, action, body));
384
+ });
385
+
386
+ app.delete("/resources/:type/:name", requirePermission("cloud:write", "cloud:admin"), async (c) => {
387
+ const { type, name } = c.req.param();
388
+ const providers = getProviders();
389
+ for (const p of providers) {
390
+ try {
391
+ const resource = await p.getResource(type, name);
392
+ if (resource) {
393
+ return streamOperation(c, () => p.deleteResource(type, name));
394
+ }
395
+ } catch { /* try next */ }
396
+ }
397
+ return c.json({ error: "Resource not found" }, 404);
398
+ });
399
+
400
+ // ── Feature Flags ─────────────────────────────────────────
401
+
402
+ app.get("/flags/:vmName", async (c) => {
403
+ const { vmName } = c.req.param();
404
+ const provider = getProvider("azure");
405
+ if (!provider) return c.json({ error: "Azure provider not available" }, 400);
406
+ try {
407
+ const result = await provider.svc.listFeatureFlags(vmName);
408
+ return c.json(result);
409
+ } catch (e) {
410
+ return c.json({ error: e.message }, 500);
411
+ }
412
+ });
413
+
414
+ app.post("/flags/:vmName", requirePermission("cloud:write", "cloud:admin"), async (c) => {
415
+ const { vmName } = c.req.param();
416
+ const body = await c.req.json();
417
+ const provider = getProvider("azure");
418
+ if (!provider) return c.json({ error: "Azure provider not available" }, 400);
419
+ return streamOperation(c, () => provider.svc.setFeatureFlags(vmName, body.flags || {}), `flags-${vmName}`);
420
+ });
421
+
422
+ // ── Deploy ───────────────────────────────────────────────
423
+
424
+ app.post("/deploy/:vmName", requirePermission("cloud:deploy", "cloud:admin"), async (c) => {
425
+ const { vmName } = c.req.param();
426
+ let body = {};
427
+ try { body = await c.req.json(); } catch {}
428
+ const provider = getProvider("azure");
429
+ if (!provider) return c.json({ error: "Azure provider not available" }, 400);
430
+ return streamOperation(c, () => provider.svc.deployStack(vmName, body), `deploy-${vmName}`);
431
+ });
432
+
433
+ // ── Fleet ───────────────────────────────────────────────
434
+
435
+ // Proxy helper: try fops serve API first, fall back to providers
436
+ const FOPS_SERVE_URL = process.env.FOPS_API_URL || "http://127.0.0.1:4100";
437
+ async function proxyFopsServe(path, { method = "GET", body } = {}) {
438
+ const opts = { signal: AbortSignal.timeout(60_000), method };
439
+ if (body !== undefined) {
440
+ opts.headers = { "Content-Type": "application/json" };
441
+ opts.body = JSON.stringify(body);
442
+ }
443
+ const res = await fetch(`${FOPS_SERVE_URL}${path}`, opts);
444
+ if (!res.ok) return null;
445
+ return res.json();
446
+ }
447
+ // Run tool directly from registry (works without fops serve)
448
+ async function runToolDirect(name, input = {}) {
449
+ const tool = (registry.tools || []).find((t) => t.name === name);
450
+ if (!tool) return null;
451
+ const fn = tool.execute || tool.handler;
452
+ if (typeof fn !== "function") return null;
453
+ const result = await fn(input);
454
+ if (typeof result === "string") {
455
+ try { return JSON.parse(result); } catch { return result; }
456
+ }
457
+ return result;
458
+ }
459
+
460
+ // Tool proxy helper — POST /api/tools/:name, parse stringified result
461
+ async function proxyTool(name, input = {}) {
462
+ const raw = await proxyFopsServe(`/api/tools/${name}`, { method: "POST", body: input });
463
+ if (!raw) return null;
464
+ const result = raw.result;
465
+ // Tool handlers return { tool, result } where result may be a JSON string
466
+ if (typeof result === "string") {
467
+ try { return JSON.parse(result); } catch { return result; }
468
+ }
469
+ return result || raw;
470
+ }
471
+
472
+ app.get("/fleet", async (c) => {
473
+ const SVC_MAP = { backend: "be", frontend: "fe", processor: "pr", watcher: "wa", scheduler: "sc", storage: "se" };
474
+
475
+ // Try fops serve fleet data first (has live container/scrape data)
476
+ let vmsObj = null;
477
+ try {
478
+ const serveData = await proxyFopsServe("/api/fleet");
479
+ if (serveData?.vms?.length) {
480
+ vmsObj = {};
481
+ for (const vm of serveData.vms) {
482
+ const services = {};
483
+ if (vm.services) {
484
+ for (const [fullName, status] of Object.entries(vm.services)) {
485
+ const shortKey = SVC_MAP[fullName] || fullName;
486
+ services[shortKey] = status;
487
+ }
488
+ }
489
+ const containerParts = (vm.containers || "").toString().split("/");
490
+ const running = parseInt(containerParts[0]) || 0;
491
+ const total = parseInt(containerParts[1]) || running;
492
+ vmsObj[vm.vm] = {
493
+ publicUrl: vm.url || `https://${vm.vm}.meshx.app`,
494
+ location: vm.location || "",
495
+ status: vm.status,
496
+ branch: vm.branch || "",
497
+ commit: vm.commit || "",
498
+ sha: vm.commit || "",
499
+ running,
500
+ total,
501
+ services,
502
+ fopsVersion: vm.fopsVersion || "",
503
+ disk: vm.disk || "",
504
+ memory: vm.memory || "",
505
+ load: vm.load || 0,
506
+ };
507
+ }
508
+ }
509
+ } catch { /* fall through */ }
510
+
511
+ // Fetch cluster data from provider fleet cache (has flux, services, status from sync)
512
+ const rootDir = resolveComposeRoot();
513
+ let clustersObj = {};
514
+ const providers = getProviders();
515
+ for (const p of providers) {
516
+ try {
517
+ const fleet = await p.getFleet();
518
+ for (const [name, cluster] of Object.entries(fleet?.clusters || {})) {
519
+ // Use sync cache services, fall back to reading flux template overlays
520
+ let services = cluster.services || {};
521
+ if (!services || Object.keys(services).length === 0) {
522
+ services = rootDir ? readFluxServiceVersions(rootDir, name) : {};
523
+ }
524
+ // Derive flux info from template if not in sync cache
525
+ let flux = cluster.flux || null;
526
+ if (!flux && rootDir && Object.keys(services).length > 0) {
527
+ flux = { owner: "meshxdata", repo: "platform-flux-template", path: `apps/foundation`, branch: "main" };
528
+ }
529
+
530
+ clustersObj[name] = {
531
+ location: cluster.location || "",
532
+ kubernetesVersion: cluster.kubernetesVersion || "",
533
+ status: cluster.status || "unknown",
534
+ domain: cluster.fqdn || "",
535
+ active: cluster.active || false,
536
+ isStandby: cluster.isStandby || false,
537
+ nodes: cluster.nodes ?? null,
538
+ services,
539
+ ha: cluster.ha || null,
540
+ flux,
541
+ storageAccount: cluster.storageAccount || "",
542
+ storageHA: cluster.storageHA || null,
543
+ vault: cluster.vault || null,
544
+ postgres: cluster.postgres || null,
545
+ };
546
+ }
547
+ } catch { /* skip */ }
548
+ }
549
+
550
+ if (vmsObj) {
551
+ return c.json({ azure: { vms: vmsObj, clusters: clustersObj } });
552
+ }
553
+
554
+ // Full fallback — no fops serve data at all
555
+ const fleets = {};
556
+ for (const p of providers) {
557
+ try {
558
+ const fleet = await p.getFleet();
559
+ fleets[p.name] = { ...fleet, clusters: { ...(fleet?.clusters || {}), ...clustersObj } };
560
+ } catch { /* skip */ }
561
+ }
562
+ return c.json(fleets);
563
+ });
564
+
565
+ // ── Costs ───────────────────────────────────────────────
566
+
567
+ app.get("/costs", async (c) => {
568
+ const days = parseInt(c.req.query("days") || "30", 10);
569
+ const providers = getProviders();
570
+ const results = {};
571
+ for (const p of providers) {
572
+ try {
573
+ results[p.name] = await p.getCosts({ days });
574
+ } catch (e) {
575
+ results[p.name] = { error: e.message };
576
+ }
577
+ }
578
+ return c.json(results);
579
+ });
580
+
581
+ // ── Costs (direct tool execution, proxy fallback) ──────
582
+
583
+ app.get("/costs/budgets", async (c) => {
584
+ try {
585
+ const data = await runToolDirect("azure_budgets") || await proxyTool("azure_budgets");
586
+ return c.json(data || {});
587
+ } catch (e) {
588
+ return c.json({ error: e.message }, 500);
589
+ }
590
+ });
591
+
592
+ app.get("/costs/waste", async (c) => {
593
+ try {
594
+ const data = await runToolDirect("azure_vm_waste") || await proxyTool("azure_vm_waste");
595
+ return c.json(data || {});
596
+ } catch (e) {
597
+ return c.json({ error: e.message }, 500);
598
+ }
599
+ });
600
+
601
+ app.get("/costs/recommendations", async (c) => {
602
+ try {
603
+ const data = await runToolDirect("azure_advisor_recommendations", { category: "Cost" }) || await proxyTool("azure_advisor_recommendations", { category: "Cost" });
604
+ return c.json(data || {});
605
+ } catch (e) {
606
+ return c.json({ error: e.message }, 500);
607
+ }
608
+ });
609
+
610
+ // ── Audit (cached — Azure audit is slow and rate-limited) ──────────
611
+
612
+ let _auditCache = null;
613
+ let _auditCacheTs = 0;
614
+ const AUDIT_CACHE_TTL = 60 * 60 * 1000; // 1 hour
615
+
616
+ app.get("/audit", async (c) => {
617
+ const forceRefresh = c.req.query("refresh") === "true";
618
+
619
+ // Return cached data if fresh
620
+ if (!forceRefresh && _auditCache && Date.now() - _auditCacheTs < AUDIT_CACHE_TTL) {
621
+ return c.json({ ..._auditCache, _cached: true, _cachedAt: new Date(_auditCacheTs).toISOString() });
622
+ }
623
+
624
+ // Collect fleet-based findings (fast, from SSH scrape data)
625
+ let fleetResult = null;
626
+ let fleetAuditData = null;
627
+ try {
628
+ const fleetAudit = await proxyFopsServe("/api/fleet/audit");
629
+ if (fleetAudit && !fleetAudit.error && fleetAudit.vms?.length) {
630
+ fleetAuditData = fleetAudit;
631
+ const vmFindings = [];
632
+ for (const vm of fleetAudit.vms) {
633
+ const findings = [];
634
+ if (vm.status === "unhealthy") findings.push({ severity: "warn", check: "vm-status", message: `VM ${vm.vm} is unhealthy`, fix: `fops azure up ${vm.vm}` });
635
+ if (vm.status === "degraded") findings.push({ severity: "info", check: "vm-status", message: `VM ${vm.vm} is degraded (${vm.containers})`, fix: `fops azure doctor ${vm.vm}` });
636
+ const svcs = vm.services || {};
637
+ for (const [svc, status] of Object.entries(svcs)) {
638
+ if (typeof status === "string" && status === "down") {
639
+ findings.push({ severity: "warn", check: `service-${svc}`, message: `${svc} is down on ${vm.vm}`, fix: `fops azure ssh ${vm.vm} -- docker compose restart foundation-${svc}` });
640
+ }
641
+ }
642
+ vmFindings.push({ vm: vm.vm, location: "", findings });
643
+ }
644
+ fleetResult = {
645
+ vms: vmFindings,
646
+ findings: vmFindings.flatMap((v) => v.findings),
647
+ };
648
+ }
649
+ } catch { /* fleet unavailable, continue */ }
650
+
651
+ // Full Azure infrastructure audit (disk encryption, NSG, AKS, storage, etc.)
652
+ let azureAudit = null;
653
+ try {
654
+ azureAudit = await runToolDirect("security_audit_all") || await proxyTool("security_audit_all");
655
+ } catch { /* azure audit unavailable */ }
656
+
657
+ // Merge fleet findings with Azure audit findings
658
+ const result = {
659
+ vms: { vms: [], findings: [] },
660
+ aks: { clusters: [], findings: [] },
661
+ storage: { accounts: [], findings: [] },
662
+ };
663
+
664
+ if (azureAudit) {
665
+ result.vms = azureAudit.vms || result.vms;
666
+ result.aks = azureAudit.aks || result.aks;
667
+ result.storage = azureAudit.storage || result.storage;
668
+ }
669
+
670
+ // Merge fleet VM findings into Azure VM findings (avoid duplicates by vm name)
671
+ if (fleetResult) {
672
+ const azureVmNames = new Set((result.vms.vms || []).map((v) => v.vm));
673
+ for (const fv of fleetResult.vms) {
674
+ const existing = (result.vms.vms || []).find((v) => v.vm === fv.vm);
675
+ if (existing) {
676
+ // Append fleet-specific findings to the Azure audit entry
677
+ existing.findings = [...(existing.findings || []), ...fv.findings];
678
+ } else {
679
+ result.vms.vms = [...(result.vms.vms || []), fv];
680
+ }
681
+ }
682
+ // Rebuild top-level VM findings from merged per-VM findings
683
+ result.vms.findings = (result.vms.vms || []).flatMap((v) => v.findings || []);
684
+ result._fleet = fleetAuditData;
685
+ }
686
+
687
+ result._source = azureAudit && fleetResult ? "fleet+azure" : azureAudit ? "azure" : fleetResult ? "fleet-scrape" : "none";
688
+
689
+ _auditCache = result;
690
+ _auditCacheTs = Date.now();
691
+ return c.json(result);
692
+ });
693
+
694
+ // ── Sync ────────────────────────────────────────────────
695
+
696
+ app.post("/sync", requirePermission("cloud:write", "cloud:admin"), async (c) => {
697
+ const providers = getProviders();
698
+ return streamOperation(c, async () => {
699
+ const results = {};
700
+ for (const p of providers) {
701
+ try {
702
+ results[p.name] = await p.syncResources();
703
+ } catch (e) {
704
+ results[p.name] = { error: e.message };
705
+ }
706
+ }
707
+ return results;
708
+ });
709
+ });
710
+
711
+ return app;
712
+ }