@meshxdata/fops 0.1.52 → 0.1.54

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 (86) hide show
  1. package/CHANGELOG.md +559 -0
  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/fleet-registry.js +38 -2
  6. package/src/plugins/__test-fixtures__/fake-plugin.js +2 -0
  7. package/src/plugins/__test-fixtures__/no-register-plugin.js +2 -0
  8. package/src/plugins/__test-fixtures__/with-register/index.js +2 -0
  9. package/src/plugins/__test-fixtures__/without-register/index.js +2 -0
  10. package/src/plugins/api.js +4 -0
  11. package/src/plugins/builtins/docker-compose.js +65 -0
  12. package/src/plugins/bundled/fops-plugin-azure/index.js +4 -0
  13. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-core.js +44 -53
  14. package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-storage.js +2 -2
  15. package/src/plugins/bundled/fops-plugin-azure/lib/azure-cost.js +52 -22
  16. package/src/plugins/bundled/fops-plugin-azure/lib/azure-helpers.js +6 -2
  17. package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops.js +113 -7
  18. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision-init.js +13 -4
  19. package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision.js +91 -14
  20. package/src/plugins/bundled/fops-plugin-azure/lib/azure-service.js +507 -0
  21. package/src/plugins/bundled/fops-plugin-azure/lib/azure-sync.js +146 -7
  22. package/src/plugins/bundled/fops-plugin-azure/lib/azure.js +1 -1
  23. package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +61 -0
  24. package/src/plugins/bundled/fops-plugin-cloud/api.js +712 -0
  25. package/src/plugins/bundled/fops-plugin-cloud/fops.plugin.json +6 -0
  26. package/src/plugins/bundled/fops-plugin-cloud/index.js +208 -0
  27. package/src/plugins/bundled/fops-plugin-cloud/lib/azure-provider.js +81 -0
  28. package/src/plugins/bundled/fops-plugin-cloud/lib/provider.js +50 -0
  29. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/favicon-C49brna2.svg +15 -0
  30. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/index-CVqQ_kKW.js +65 -0
  31. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/index-DZetahP3.css +1 -0
  32. package/src/plugins/bundled/fops-plugin-cloud/ui/dist/index.html +28 -0
  33. package/src/plugins/bundled/fops-plugin-cloud/ui/index.html +27 -0
  34. package/src/plugins/bundled/fops-plugin-cloud/ui/package-lock.json +2634 -0
  35. package/src/plugins/bundled/fops-plugin-cloud/ui/package.json +29 -0
  36. package/src/plugins/bundled/fops-plugin-cloud/ui/postcss.config.cjs +5 -0
  37. package/src/plugins/bundled/fops-plugin-cloud/ui/src/App.jsx +32 -0
  38. package/src/plugins/bundled/fops-plugin-cloud/ui/src/api/client.js +114 -0
  39. package/src/plugins/bundled/fops-plugin-cloud/ui/src/api/queries.js +111 -0
  40. package/src/plugins/bundled/fops-plugin-cloud/ui/src/components/LogPanel.jsx +162 -0
  41. package/src/plugins/bundled/fops-plugin-cloud/ui/src/components/ThemeToggle.jsx +46 -0
  42. package/src/plugins/bundled/fops-plugin-cloud/ui/src/css/additional-styles/utility-patterns.css +147 -0
  43. package/src/plugins/bundled/fops-plugin-cloud/ui/src/css/style.css +138 -0
  44. package/src/plugins/bundled/fops-plugin-cloud/ui/src/favicon.svg +15 -0
  45. package/src/plugins/bundled/fops-plugin-cloud/ui/src/lib/utils.ts +19 -0
  46. package/src/plugins/bundled/fops-plugin-cloud/ui/src/main.jsx +25 -0
  47. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Audit.jsx +164 -0
  48. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Costs.jsx +305 -0
  49. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/CreateResource.jsx +285 -0
  50. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Fleet.jsx +307 -0
  51. package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Resources.jsx +229 -0
  52. package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/Header.jsx +132 -0
  53. package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/Sidebar.jsx +174 -0
  54. package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/SidebarLinkGroup.jsx +21 -0
  55. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/AuthContext.jsx +170 -0
  56. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Info.jsx +49 -0
  57. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/ThemeContext.jsx +37 -0
  58. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Transition.jsx +116 -0
  59. package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Utils.js +63 -0
  60. package/src/plugins/bundled/fops-plugin-cloud/ui/vite.config.js +23 -0
  61. package/src/plugins/bundled/fops-plugin-foundation/test-helpers.js +65 -0
  62. package/src/plugins/loader.js +34 -1
  63. package/src/plugins/registry.js +15 -0
  64. package/src/plugins/schemas.js +17 -0
  65. package/src/project.js +1 -1
  66. package/src/serve.js +196 -2
  67. package/src/shell.js +21 -1
  68. package/src/web/admin.html.js +236 -0
  69. package/src/web/api.js +73 -0
  70. package/src/web/dist/assets/index-BphVaAUd.css +1 -0
  71. package/src/web/dist/assets/index-CSckLzuG.js +129 -0
  72. package/src/web/dist/index.html +2 -2
  73. package/src/web/frontend/index.html +16 -0
  74. package/src/web/frontend/src/App.jsx +445 -0
  75. package/src/web/frontend/src/components/ChatView.jsx +910 -0
  76. package/src/web/frontend/src/components/InputBox.jsx +523 -0
  77. package/src/web/frontend/src/components/Sidebar.jsx +410 -0
  78. package/src/web/frontend/src/components/StatusBar.jsx +37 -0
  79. package/src/web/frontend/src/components/TabBar.jsx +87 -0
  80. package/src/web/frontend/src/hooks/useWebSocket.js +412 -0
  81. package/src/web/frontend/src/index.css +78 -0
  82. package/src/web/frontend/src/main.jsx +6 -0
  83. package/src/web/frontend/vite.config.js +21 -0
  84. package/src/web/server.js +64 -1
  85. package/src/web/dist/assets/index-NXC8Hvnp.css +0 -1
  86. package/src/web/dist/assets/index-QH1N4ejK.js +0 -112
@@ -44,7 +44,7 @@ async function ensureGhcrOnVm(ssh, user, githubToken, { timeout = 60000 } = {})
44
44
  // ── Configure a fresh or restarted VM ───────────────────────────────────────
45
45
 
46
46
  export async function configureVm(execa, ip, user, publicUrl, { githubToken, k3s, traefik, dai, deferStartToReconcile, quiet } = {}) {
47
- const ssh = (cmd) => sshCmd(execa, ip, user, cmd);
47
+ const ssh = (cmd, timeout) => sshCmd(execa, ip, user, cmd, timeout);
48
48
 
49
49
  if (!quiet) console.log(chalk.dim(" Configuring VM..."));
50
50
 
@@ -72,6 +72,68 @@ export async function configureVm(execa, ip, user, publicUrl, { githubToken, k3s
72
72
  ].join("\n");
73
73
  await ssh(setupBatch);
74
74
 
75
+ // Verify Docker is installed — if missing, install it before anything else
76
+ const { exitCode: dockerCheck } = await ssh("sudo docker info >/dev/null 2>&1");
77
+ if (dockerCheck !== 0) {
78
+ if (!quiet) console.log(chalk.yellow(" ⚠ Docker not found — installing..."));
79
+ // Repo setup is idempotent (tolerates partial prior attempts); install+start uses &&
80
+ const repoSetup = [
81
+ "export DEBIAN_FRONTEND=noninteractive",
82
+ "while fuser /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock /var/cache/apt/archives/lock >/dev/null 2>&1; do sleep 3; done",
83
+ "sudo install -m 0755 -d /etc/apt/keyrings",
84
+ "curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/docker.gpg",
85
+ "sudo chmod a+r /etc/apt/keyrings/docker.gpg",
86
+ `echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null`,
87
+ ].join("; ");
88
+ const installAndStart = [
89
+ "sudo apt-get update -qq",
90
+ "sudo apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin",
91
+ "sudo systemctl enable docker && sudo systemctl start docker",
92
+ `sudo usermod -aG docker ${user}`,
93
+ ].join(" && ");
94
+ const { exitCode: installExit } = await ssh(`${repoSetup}; ${installAndStart}`, 300000);
95
+ if (installExit === 0) {
96
+ if (!quiet) console.log(chalk.green(" ✓ Docker installed"));
97
+ } else {
98
+ console.log(chalk.red(" ✗ Docker installation failed — container operations will not work"));
99
+ console.log(chalk.dim(` SSH in and check: ssh ${user}@${ip} "sudo apt-get install -y docker-ce"`));
100
+ }
101
+ }
102
+
103
+ // Verify Node.js + fops CLI are installed — if missing, install them
104
+ const { stdout: fopsWhich } = await ssh("command -v fops 2>/dev/null || echo MISSING");
105
+ if (!fopsWhich?.trim() || fopsWhich.includes("MISSING")) {
106
+ if (!quiet) console.log(chalk.yellow(" ⚠ fops CLI not found — installing Node.js + fops..."));
107
+ const installNode = [
108
+ "export DEBIAN_FRONTEND=noninteractive",
109
+ "if ! command -v node >/dev/null 2>&1; then curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash - && sudo apt-get install -y -qq nodejs; fi",
110
+ ].join("; ");
111
+ await ssh(installNode, 120000);
112
+ // Install fops globally, retry with sudo if needed
113
+ let fopsInstalled = false;
114
+ const { exitCode: userInstall } = await ssh("npm install -g @meshxdata/fops@latest 2>&1", 300000);
115
+ if (userInstall === 0) {
116
+ fopsInstalled = true;
117
+ } else {
118
+ const { exitCode: sudoInstall } = await ssh(
119
+ "sudo bash -c 'D=\"$(npm root -g)/@meshxdata\"; rm -rf \"$D\" 2>/dev/null; npm install -g @meshxdata/fops@latest' 2>&1",
120
+ 300000,
121
+ );
122
+ fopsInstalled = sudoInstall === 0;
123
+ }
124
+ // Ensure fops is on PATH
125
+ await ssh(
126
+ 'MJS="$(npm root -g 2>/dev/null)/@meshxdata/fops/fops.mjs"; [ -f "$MJS" ] || MJS="$(sudo npm root -g 2>/dev/null)/@meshxdata/fops/fops.mjs"; [ -f "$MJS" ] && sudo ln -sf "$MJS" /usr/local/bin/fops; true',
127
+ 15000,
128
+ );
129
+ if (fopsInstalled) {
130
+ if (!quiet) console.log(chalk.green(" ✓ fops CLI installed"));
131
+ } else {
132
+ console.log(chalk.red(" ✗ fops CLI installation failed"));
133
+ console.log(chalk.dim(` SSH in and check: ssh ${user}@${ip} "sudo npm install -g @meshxdata/fops@latest"`));
134
+ }
135
+ }
136
+
75
137
  let ghcrOk = false;
76
138
  if (githubToken) {
77
139
  if (!quiet) console.log(chalk.dim(" Configuring GitHub/GHCR credentials..."));
@@ -458,9 +520,12 @@ async function vmReconcileNetworking(ctx) {
458
520
  console.log(chalk.yellow(" ⚠ No NSG attached to NIC"));
459
521
  }
460
522
 
523
+ // Accelerated networking — only supported on D/E/F/M series (2+ vCPU), not B-series
524
+ const vmSize = (iv.hardwareProfile?.vmSize || "").toLowerCase();
525
+ const supportsAccelNet = !vmSize.startsWith("standard_b") && !vmSize.startsWith("standard_a");
461
526
  if (nic.enableAcceleratedNetworking) {
462
527
  reconcileOk("Accelerated networking", "enabled");
463
- } else {
528
+ } else if (supportsAccelNet) {
464
529
  console.log(chalk.yellow(" ↻ Accelerated networking not enabled — enabling…"));
465
530
  const { exitCode: anCode } = await execa("az", [
466
531
  "network", "nic", "update", "-g", rg, "-n", ctx.nicName,
@@ -469,8 +534,9 @@ async function vmReconcileNetworking(ctx) {
469
534
  ], { reject: false, timeout: 30000 });
470
535
  console.log(anCode === 0
471
536
  ? chalk.green(` ✓ ${"Accelerated networking".padEnd(RECONCILE_LABEL_WIDTH)} — enabled`)
472
- : chalk.yellow(" ⚠ Could not enable accelerated networking (VM size may not support it)"));
537
+ : chalk.yellow(" ⚠ Could not enable accelerated networking"));
473
538
  }
539
+ // B-series and A-series VMs don't support accelerated networking — skip silently
474
540
  }
475
541
 
476
542
  if (!ctx.ip) ctx.ip = await resolvePublicIp(execa, rg, vmName);
@@ -832,10 +898,16 @@ async function vmReconcileSecurity(ctx) {
832
898
  reconcileOk("Boot diagnostics", "enabled");
833
899
  } else {
834
900
  console.log(chalk.yellow(" ↻ Boot diagnostics not enabled — enabling..."));
835
- const { exitCode: bdCode } = await execa("az", [
836
- "vm", "boot-diagnostics", "enable", "-g", rg, "-n", vmName, "--output", "none",
837
- ...subArgs(sub),
838
- ], { reject: false, timeout: 30000 });
901
+ let bdCode = 1;
902
+ for (let attempt = 0; attempt < 3; attempt++) {
903
+ const res = await execa("az", [
904
+ "vm", "boot-diagnostics", "enable", "-g", rg, "-n", vmName, "--output", "none",
905
+ ...subArgs(sub),
906
+ ], { reject: false, timeout: 30000 });
907
+ bdCode = res.exitCode;
908
+ if (bdCode === 0) break;
909
+ if (attempt < 2) await new Promise((r) => setTimeout(r, 5000));
910
+ }
839
911
  if (bdCode === 0) {
840
912
  await new Promise((r) => setTimeout(r, 2000));
841
913
  const { stdout: bdJson } = await execa("az", [
@@ -880,20 +952,25 @@ async function vmReconcileSecurity(ctx) {
880
952
  console.log(amCode === 0
881
953
  ? chalk.green(` ✓ ${"Antimalware extension".padEnd(RECONCILE_LABEL_WIDTH)} — installed`)
882
954
  : chalk.yellow(" ⚠ Could not install antimalware extension"));
883
- } else {
884
- console.log(chalk.dim(` Antimalware extension — Windows only (skipped on Linux)`));
885
955
  }
956
+ // Linux VMs don't need antimalware — skip silently
886
957
 
887
958
  if (isTrustedLaunch) {
888
959
  if (hasGuestAttestation) {
889
960
  reconcileOk("Guest Attestation extension", "installed");
890
961
  } else {
891
962
  console.log(chalk.yellow(" ↻ Guest Attestation missing — installing…"));
892
- const { exitCode: gaCode } = await execa("az", [
893
- "vm", "extension", "set", "-g", rg, "--vm-name", vmName,
894
- "-n", "GuestAttestation", "--publisher", "Microsoft.Azure.Security.LinuxAttestation",
895
- "--output", "none", ...subArgs(sub),
896
- ], { reject: false, timeout: 120000 });
963
+ let gaCode = 1;
964
+ for (let attempt = 0; attempt < 3; attempt++) {
965
+ const res = await execa("az", [
966
+ "vm", "extension", "set", "-g", rg, "--vm-name", vmName,
967
+ "-n", "GuestAttestation", "--publisher", "Microsoft.Azure.Security.LinuxAttestation",
968
+ "--output", "none", ...subArgs(sub),
969
+ ], { reject: false, timeout: 120000 });
970
+ gaCode = res.exitCode;
971
+ if (gaCode === 0) break;
972
+ if (attempt < 2) await new Promise((r) => setTimeout(r, 10000));
973
+ }
897
974
  console.log(gaCode === 0
898
975
  ? chalk.green(` ✓ ${"Guest Attestation extension".padEnd(RECONCILE_LABEL_WIDTH)} — installed`)
899
976
  : chalk.yellow(" ⚠ Could not install Guest Attestation extension"));
@@ -0,0 +1,507 @@
1
+ /**
2
+ * AzureService — structured JSON API surface for cross-plugin consumption.
3
+ * Wraps existing azure lib functions to return clean data (no console.log side effects).
4
+ */
5
+
6
+ import fs from "node:fs";
7
+ import nodePath from "node:path";
8
+ import os from "node:os";
9
+ import { listVms, readVmState } from "./azure-state.js";
10
+ import { readAksClusters, readClusterState } from "./azure-aks-state.js";
11
+ import { lazyExeca, resolveCliSrc } from "./azure-helpers.js";
12
+ import { readCache } from "./azure-sync.js";
13
+
14
+ // ── Persistent cost cache ────────────────────────────────────────────
15
+ const COST_CACHE_DIR = nodePath.join(os.homedir(), ".fops", "costs");
16
+ const COST_CACHE_PATH = nodePath.join(COST_CACHE_DIR, "cache.json");
17
+ const COST_CACHE_TTL = 4 * 60 * 60 * 1000; // 4 hours
18
+
19
+ function readCostCache(days) {
20
+ try {
21
+ const raw = JSON.parse(fs.readFileSync(COST_CACHE_PATH, "utf8"));
22
+ if (raw.days === days && raw.cachedAt && Date.now() - raw.cachedAt < COST_CACHE_TTL) {
23
+ return raw;
24
+ }
25
+ } catch {}
26
+ return null;
27
+ }
28
+
29
+ function writeCostCache(data) {
30
+ try {
31
+ fs.mkdirSync(COST_CACHE_DIR, { recursive: true });
32
+ fs.writeFileSync(COST_CACHE_PATH, JSON.stringify({ ...data, cachedAt: Date.now() }));
33
+ } catch {}
34
+ }
35
+
36
+ export class AzureService {
37
+ /**
38
+ * List all tracked VMs with their state.
39
+ * @returns {{ activeVm?: string, vms: object[] }}
40
+ */
41
+ listVms() {
42
+ const { activeVm, vms } = listVms();
43
+ const list = Object.entries(vms || {}).map(([name, vm]) => ({
44
+ name,
45
+ resourceGroup: vm.resourceGroup,
46
+ location: vm.location,
47
+ publicIp: vm.publicIp,
48
+ publicUrl: vm.publicUrl,
49
+ subscriptionId: vm.subscriptionId,
50
+ active: name === activeVm,
51
+ createdAt: vm.createdAt || vm.discoveredAt || null,
52
+ }));
53
+ return { activeVm, vms: list };
54
+ }
55
+
56
+ /**
57
+ * List all tracked AKS clusters.
58
+ * @returns {{ activeCluster?: string, clusters: object[] }}
59
+ */
60
+ listClusters() {
61
+ const { activeCluster, clusters } = readAksClusters();
62
+ const cache = readCache();
63
+ const cachedClusters = cache?.clusters || {};
64
+
65
+ const list = Object.entries(clusters || {}).map(([name, c]) => {
66
+ const cached = cachedClusters[name] || {};
67
+ return {
68
+ name,
69
+ resourceGroup: c.resourceGroup,
70
+ location: c.location,
71
+ kubernetesVersion: cached.kubernetesVersion || c.kubernetesVersion || null,
72
+ fqdn: cached.fqdn || c.fqdn || null,
73
+ active: name === activeCluster,
74
+ // HA pairing (from state or inferred by sync)
75
+ isStandby: cached.isStandby || c.isStandby || false,
76
+ primaryCluster: cached.primaryCluster || c.primaryCluster || null,
77
+ ha: cached.ha || c.ha || null,
78
+ // Storage (from state or discovered by sync)
79
+ storageHA: cached.storageHA || c.storageHA || null,
80
+ storageAccount: cached.storageAccount || null,
81
+ // Key Vault (from state or discovered by sync)
82
+ vault: cached.vault || (c.vault ? {
83
+ keyVaultName: c.vault.keyVaultName || null,
84
+ autoUnseal: c.vault.autoUnseal || false,
85
+ initialized: c.vault.initialized || false,
86
+ } : null),
87
+ // Postgres (from state or discovered by sync)
88
+ postgres: cached.postgres || (c.postgres ? { serverName: c.postgres.serverName, fqdn: c.postgres.fqdn } : null),
89
+ // Extras
90
+ domain: c.domain || null,
91
+ ingressIp: c.ingressIp || null,
92
+ nodeCount: cached.nodes || c.nodeCount || null,
93
+ nodeVmSize: cached.sizes || c.nodeVmSize || null,
94
+ };
95
+ });
96
+ return { activeCluster, clusters: list };
97
+ }
98
+
99
+ /**
100
+ * Get detailed info for a single VM.
101
+ * @param {string} name
102
+ * @returns {object|null}
103
+ */
104
+ getVmDetail(name) {
105
+ const vm = readVmState(name);
106
+ if (!vm) return null;
107
+ return { ...vm, type: "vm" };
108
+ }
109
+
110
+ /**
111
+ * Get detailed info for a single AKS cluster.
112
+ * @param {string} name
113
+ * @returns {object|null}
114
+ */
115
+ getClusterDetail(name) {
116
+ const c = readClusterState(name);
117
+ if (!c) return null;
118
+ return { ...c, type: "cluster" };
119
+ }
120
+
121
+ /**
122
+ * Start a deallocated VM.
123
+ * @param {string} name
124
+ */
125
+ async startVm(name) {
126
+ const { azureStart } = await import("./azure-vm-lifecycle.js");
127
+ await azureStart({ vmName: name });
128
+ return { ok: true, action: "start", vm: name };
129
+ }
130
+
131
+ /**
132
+ * Stop (deallocate) a VM.
133
+ * @param {string} name
134
+ */
135
+ async stopVm(name) {
136
+ const { azureStop } = await import("./azure-vm-lifecycle.js");
137
+ await azureStop({ vmName: name });
138
+ return { ok: true, action: "stop", vm: name };
139
+ }
140
+
141
+ /**
142
+ * Provision a new VM.
143
+ * @param {object} opts - Same options as azureUp
144
+ */
145
+ async provisionVm(opts) {
146
+ const { azureUp } = await import("./azure-vm-lifecycle.js");
147
+ const result = await azureUp(opts);
148
+ return { ok: true, action: "provision", ...result };
149
+ }
150
+
151
+ /**
152
+ * Destroy a VM.
153
+ * @param {string} name
154
+ */
155
+ async deleteVm(name) {
156
+ const { azureDown } = await import("./azure-vm-lifecycle.js");
157
+ await azureDown({ vmName: name });
158
+ return { ok: true, action: "delete", vm: name };
159
+ }
160
+
161
+ /**
162
+ * Resize a VM.
163
+ * @param {string} name
164
+ * @param {object} opts
165
+ */
166
+ async resizeVm(name, opts = {}) {
167
+ const { azureResize } = await import("./azure-vm-lifecycle.js");
168
+ await azureResize({ vmName: name, ...opts });
169
+ return { ok: true, action: "resize", vm: name };
170
+ }
171
+
172
+ /**
173
+ * Provision a new AKS cluster.
174
+ * @param {object} opts - Same options as aksUp
175
+ */
176
+ async provisionCluster(opts) {
177
+ const { aksUp } = await import("./azure-aks-core.js");
178
+ const result = await aksUp(opts);
179
+ return { ok: true, action: "provision", ...result };
180
+ }
181
+
182
+ /**
183
+ * Destroy an AKS cluster.
184
+ * @param {string} name
185
+ */
186
+ async deleteCluster(name) {
187
+ const { aksDown } = await import("./azure-aks-core.js");
188
+ await aksDown({ clusterName: name });
189
+ return { ok: true, action: "delete", cluster: name };
190
+ }
191
+
192
+ /**
193
+ * Sync/discover resources from Azure.
194
+ */
195
+ async syncResources(opts = {}) {
196
+ const { azureSync } = await import("./azure-sync.js");
197
+ const result = await azureSync({ quiet: true, ...opts });
198
+ return result;
199
+ }
200
+
201
+ /**
202
+ * List feature flags for a VM.
203
+ * Uses sync cache if available, falls back to SSH.
204
+ * @param {string} name - VM name
205
+ * @returns {{ flags: { name, label, value, services }[] }}
206
+ */
207
+ async listFeatureFlags(name) {
208
+ const { KNOWN_FLAGS } = await import(resolveCliSrc("feature-flags.js"));
209
+
210
+ // Try cache first (populated by sync)
211
+ const cache = readCache();
212
+ const cached = cache?.vms?.[name];
213
+ if (cached?.flags) {
214
+ const flags = [];
215
+ const seen = new Set();
216
+ for (const [flagName, value] of Object.entries(cached.flags)) {
217
+ seen.add(flagName);
218
+ flags.push({ name: flagName, label: KNOWN_FLAGS[flagName] || flagName, value, services: [] });
219
+ }
220
+ for (const [flagName, label] of Object.entries(KNOWN_FLAGS)) {
221
+ if (!seen.has(flagName)) flags.push({ name: flagName, label, value: false, services: [] });
222
+ }
223
+ return { flags: flags.sort((a, b) => a.name.localeCompare(b.name)), fromCache: true };
224
+ }
225
+
226
+ // Fallback: SSH into VM
227
+ const vm = readVmState(name);
228
+ if (!vm?.publicIp) throw new Error(`VM "${name}" not found or has no IP`);
229
+ const execa = await lazyExeca();
230
+ const { knockForVm, sshCmd, DEFAULTS } = await import("./azure.js");
231
+ await knockForVm(vm);
232
+ const { stdout, exitCode } = await sshCmd(execa, vm.publicIp, DEFAULTS.adminUser,
233
+ "cat /opt/foundation-compose/docker-compose.yaml", 30000);
234
+ if (exitCode !== 0 || !stdout?.trim()) throw new Error("Could not read docker-compose.yaml from VM");
235
+
236
+ const { parseComposeFlagsFromContent } = await import(resolveCliSrc("feature-flags.js"));
237
+ const composeFlags = parseComposeFlagsFromContent(stdout);
238
+
239
+ const flags = [];
240
+ const seen = new Set();
241
+ for (const [flagName, info] of Object.entries(composeFlags)) {
242
+ seen.add(flagName);
243
+ flags.push({ name: flagName, label: KNOWN_FLAGS[flagName] || flagName, value: info.value, services: [...info.services] });
244
+ }
245
+ for (const [flagName, label] of Object.entries(KNOWN_FLAGS)) {
246
+ if (!seen.has(flagName)) flags.push({ name: flagName, label, value: false, services: [] });
247
+ }
248
+ return { flags: flags.sort((a, b) => a.name.localeCompare(b.name)), fromCache: false };
249
+ }
250
+
251
+ /**
252
+ * Set feature flags on a VM and restart affected services.
253
+ * @param {string} name - VM name
254
+ * @param {object} flagValues - { MX_FF_NAME: true/false, ... }
255
+ */
256
+ async setFeatureFlags(name, flagValues) {
257
+ const vm = readVmState(name);
258
+ if (!vm?.publicIp) throw new Error(`VM "${name}" not found or has no IP`);
259
+ const execa = await lazyExeca();
260
+ const { knockForVm, sshCmd, DEFAULTS } = await import("./azure.js");
261
+ await knockForVm(vm);
262
+ const ssh = (cmd, timeout = 30000) => sshCmd(execa, vm.publicIp, DEFAULTS.adminUser, cmd, timeout);
263
+
264
+ const { stdout: composeContent, exitCode } = await ssh("cat /opt/foundation-compose/docker-compose.yaml");
265
+ if (exitCode !== 0) throw new Error("Could not read docker-compose.yaml");
266
+
267
+ const { KNOWN_FLAGS, parseComposeFlagsFromContent, applyComposeFlagChanges } = await import(resolveCliSrc("feature-flags.js"));
268
+ const composeFlags = parseComposeFlagsFromContent(composeContent);
269
+
270
+ const changes = [];
271
+ const affectedServices = new Set();
272
+ for (const [flagName, newValue] of Object.entries(flagValues)) {
273
+ const flag = composeFlags[flagName];
274
+ if (!flag) continue;
275
+ if (flag.value !== newValue) {
276
+ for (const line of flag.lines) changes.push({ lineNum: line.lineNum, newValue: String(newValue) });
277
+ for (const svc of flag.services) affectedServices.add(svc);
278
+ }
279
+ }
280
+
281
+ if (changes.length === 0) return { ok: true, changed: 0, restarted: [] };
282
+
283
+ const updatedContent = applyComposeFlagChanges(composeContent, changes);
284
+ const b64 = Buffer.from(updatedContent).toString("base64");
285
+ const { exitCode: writeCode } = await ssh(`echo '${b64}' | base64 -d > /opt/foundation-compose/docker-compose.yaml`);
286
+ if (writeCode !== 0) throw new Error("Failed to write docker-compose.yaml on VM");
287
+
288
+ console.log(` ✓ Updated ${changes.length} flag value(s) on ${name}`);
289
+
290
+ const serviceList = [...affectedServices];
291
+ if (serviceList.length > 0) {
292
+ console.log(` Restarting: ${serviceList.join(", ")}`);
293
+ await ssh(`cd /opt/foundation-compose && docker compose up -d --remove-orphans ${serviceList.join(" ")}`, 120000);
294
+ console.log(` ✓ Services restarted`);
295
+ }
296
+
297
+ return { ok: true, changed: changes.length, restarted: serviceList };
298
+ }
299
+
300
+ /**
301
+ * Deploy stack to a VM (pull + restart).
302
+ * @param {string} name - VM name
303
+ * @param {object} opts
304
+ */
305
+ async deployStack(name, opts = {}) {
306
+ const { azureDeploy } = await import("./azure-ops.js");
307
+ await azureDeploy({ vmName: name, ...opts });
308
+ return { ok: true, action: "deploy", vm: name };
309
+ }
310
+
311
+ /**
312
+ * Get cached sync data.
313
+ * @returns {object|null}
314
+ */
315
+ getCachedStatus() {
316
+ return readCache();
317
+ }
318
+
319
+ /**
320
+ * Get fleet overview from sync cache.
321
+ * @returns {object}
322
+ */
323
+ getFleet() {
324
+ const cache = readCache();
325
+ if (!cache?.vms) return { vms: {}, clusters: {}, updatedAt: null };
326
+ return {
327
+ vms: cache.vms || {},
328
+ clusters: cache.clusters || {},
329
+ updatedAt: cache.updatedAt || null,
330
+ };
331
+ }
332
+
333
+ /**
334
+ * Get cost data for tracked VMs and clusters.
335
+ * @param {object} opts
336
+ * @param {number} opts.days - Number of days to look back (default: 30)
337
+ * @returns {{ vmCosts, clusterCosts, currency, days, error? }}
338
+ */
339
+ async getCosts(opts = {}) {
340
+ const days = opts.days || 30;
341
+ const execa = await lazyExeca();
342
+ const { ensureAzAuth, ensureAzCli } = await import("./azure.js");
343
+ await ensureAzCli(execa);
344
+
345
+ const { vms } = this.listVms();
346
+ const vmNames = vms.map((v) => v.name);
347
+
348
+ const { clusters } = this.listClusters();
349
+ const cache = readCache();
350
+ const cachedClusters = cache?.clusters || {};
351
+ const clusterRgs = clusters.map((c) => {
352
+ const cached = cachedClusters[c.name] || {};
353
+ const rg = c.resourceGroup || cached.resourceGroup;
354
+ const loc = c.location || cached.location;
355
+ return {
356
+ name: c.name,
357
+ rgs: [rg, `mc_${rg}_${c.name}_${loc}`].filter(Boolean),
358
+ };
359
+ });
360
+
361
+ const end = new Date();
362
+ const start = new Date();
363
+ start.setDate(start.getDate() - days);
364
+ const startDate = start.toISOString().split("T")[0];
365
+ const endDate = end.toISOString().split("T")[0];
366
+ const timePeriod = { from: startDate, to: endDate };
367
+ const baseDataset = {
368
+ granularity: "None",
369
+ aggregation: { totalCost: { name: "Cost", function: "Sum" } },
370
+ };
371
+
372
+ try {
373
+ const account = await ensureAzAuth(execa);
374
+ const subId = account.id;
375
+
376
+ const costQuery = async (body) => {
377
+ const { stdout } = await execa("az", [
378
+ "rest", "--method", "POST",
379
+ "--url", `https://management.azure.com/subscriptions/${subId}/providers/Microsoft.CostManagement/query?api-version=2023-11-01`,
380
+ "--body", JSON.stringify(body),
381
+ "--output", "json",
382
+ ], { timeout: 90000 });
383
+ const result = JSON.parse(stdout);
384
+ return result.rows || result.properties?.rows || [];
385
+ };
386
+
387
+ const vmCosts = {};
388
+ const clusterCosts = {};
389
+ let currency = "USD";
390
+
391
+ if (vmNames.length > 0) {
392
+ const rows = await costQuery({
393
+ type: "ActualCost", timeframe: "Custom", timePeriod,
394
+ dataset: {
395
+ ...baseDataset,
396
+ grouping: [{ type: "Dimension", name: "ResourceId" }],
397
+ filter: { dimensions: { name: "ResourceType", operator: "In", values: [
398
+ "microsoft.compute/virtualmachines", "microsoft.compute/disks",
399
+ "microsoft.network/publicipaddresses", "microsoft.network/networkinterfaces",
400
+ "microsoft.network/networksecuritygroups",
401
+ ]}},
402
+ },
403
+ });
404
+
405
+ const lowerNames = vmNames.map((n) => n.toLowerCase());
406
+ for (const row of rows) {
407
+ const amount = parseFloat(row[0]) || 0;
408
+ const resourceId = row[1] || "";
409
+ if (row[2]) currency = row[2];
410
+ if (amount < 0.001) continue;
411
+ const resourceName = resourceId.split("/").pop().toLowerCase();
412
+ const owner = lowerNames.find((n) => resourceName === n || resourceName.startsWith(n + "_") || resourceName.startsWith(n + "publicip") || resourceName === n + "-nsg" || resourceName === n + "-nic");
413
+ if (owner) vmCosts[owner] = (vmCosts[owner] || 0) + amount;
414
+ }
415
+ }
416
+
417
+ if (clusterRgs.length > 0) {
418
+ const rows = await costQuery({
419
+ type: "ActualCost", timeframe: "Custom", timePeriod,
420
+ dataset: {
421
+ ...baseDataset,
422
+ grouping: [{ type: "Dimension", name: "ResourceGroupName" }],
423
+ },
424
+ });
425
+
426
+ const lowerRgs = new Map(clusterRgs.map(({ name, rgs }) => [name, rgs.map((r) => r.toLowerCase())]));
427
+ for (const row of rows) {
428
+ const amount = parseFloat(row[0]) || 0;
429
+ const rg = (row[1] || "").toLowerCase();
430
+ if (row[2]) currency = row[2];
431
+ if (amount < 0.001 || !rg) continue;
432
+ for (const [clusterName, rgList] of lowerRgs) {
433
+ if (rgList.includes(rg)) clusterCosts[clusterName] = (clusterCosts[clusterName] || 0) + amount;
434
+ }
435
+ }
436
+ }
437
+
438
+ // Also get cost by service type for summary
439
+ const serviceRows = await costQuery({
440
+ type: "ActualCost", timeframe: "Custom", timePeriod,
441
+ dataset: {
442
+ ...baseDataset,
443
+ grouping: [{ type: "Dimension", name: "ServiceName" }],
444
+ },
445
+ });
446
+
447
+ const byService = {};
448
+ for (const row of serviceRows) {
449
+ const amount = parseFloat(row[0]) || 0;
450
+ const svc = row[1] || "Other";
451
+ if (row[2]) currency = row[2];
452
+ if (amount > 0.01) byService[svc] = amount;
453
+ }
454
+
455
+ const result = { vmCosts, clusterCosts, byService, currency, days };
456
+ writeCostCache(result);
457
+ return result;
458
+ } catch (e) {
459
+ // Serve from cache on failure (429, network errors, etc.)
460
+ const cached = readCostCache(days);
461
+ if (cached) {
462
+ return { ...cached, fromCache: true };
463
+ }
464
+ return { vmCosts: {}, clusterCosts: {}, byService: {}, currency: "USD", days, error: e.message };
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Grant Foundation Admin role to users on a VM.
470
+ * @param {string} name - VM name
471
+ * @param {object} opts - { username?, auth0Sub? }
472
+ */
473
+ async grantAdmin(name, opts = {}) {
474
+ const { azureGrantAdmin } = await import("./azure-ops.js");
475
+ await azureGrantAdmin({ vmName: name, ...opts });
476
+ return { ok: true, action: "grant-admin", vm: name };
477
+ }
478
+
479
+ /**
480
+ * Get live VM status from Azure.
481
+ * @param {string} name
482
+ */
483
+ async getVmLiveStatus(name) {
484
+ const vm = readVmState(name);
485
+ if (!vm) return null;
486
+ try {
487
+ const execa = await lazyExeca();
488
+ const rg = vm.resourceGroup;
489
+ const sub = vm.subscriptionId;
490
+ const args = ["vm", "show", "--name", name, "--resource-group", rg, "--show-details", "-o", "json"];
491
+ if (sub) args.push("--subscription", sub);
492
+ const { stdout, exitCode } = await execa("az", args, { timeout: 30000, reject: false });
493
+ if (exitCode !== 0) return { name, status: "unknown", error: "az vm show failed" };
494
+ const data = JSON.parse(stdout);
495
+ return {
496
+ name,
497
+ powerState: data.powerState || null,
498
+ provisioningState: data.provisioningState || null,
499
+ vmSize: data.hardwareProfile?.vmSize || null,
500
+ osType: data.storageProfile?.osDisk?.osType || null,
501
+ location: data.location || null,
502
+ };
503
+ } catch (e) {
504
+ return { name, status: "error", error: e.message };
505
+ }
506
+ }
507
+ }