@meshxdata/fops 0.1.52 → 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.
- package/CHANGELOG.md +372 -0
- package/package.json +2 -6
- package/src/agent/agent.js +6 -0
- package/src/commands/setup.js +34 -0
- package/src/fleet-registry.js +38 -2
- package/src/plugins/__test-fixtures__/fake-plugin.js +2 -0
- package/src/plugins/__test-fixtures__/no-register-plugin.js +2 -0
- package/src/plugins/__test-fixtures__/with-register/index.js +2 -0
- package/src/plugins/__test-fixtures__/without-register/index.js +2 -0
- package/src/plugins/api.js +4 -0
- package/src/plugins/builtins/docker-compose.js +59 -0
- package/src/plugins/bundled/fops-plugin-azure/index.js +4 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-core.js +44 -53
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-storage.js +2 -2
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-cost.js +52 -22
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-helpers.js +6 -2
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops.js +113 -7
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision-init.js +13 -4
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision.js +91 -14
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-service.js +507 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-sync.js +146 -7
- package/src/plugins/bundled/fops-plugin-azure/lib/azure.js +1 -1
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +61 -0
- package/src/plugins/bundled/fops-plugin-cloud/api.js +712 -0
- package/src/plugins/bundled/fops-plugin-cloud/fops.plugin.json +6 -0
- package/src/plugins/bundled/fops-plugin-cloud/index.js +208 -0
- package/src/plugins/bundled/fops-plugin-cloud/lib/azure-provider.js +81 -0
- package/src/plugins/bundled/fops-plugin-cloud/lib/provider.js +50 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/favicon-C49brna2.svg +15 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/index-CVqQ_kKW.js +65 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/dist/assets/index-DZetahP3.css +1 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/dist/index.html +28 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/index.html +27 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/package-lock.json +2634 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/package.json +29 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/postcss.config.cjs +5 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/App.jsx +32 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/api/client.js +114 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/api/queries.js +111 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/components/LogPanel.jsx +162 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/components/ThemeToggle.jsx +46 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/css/additional-styles/utility-patterns.css +147 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/css/style.css +138 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/favicon.svg +15 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/lib/utils.ts +19 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/main.jsx +25 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Audit.jsx +164 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Costs.jsx +305 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/CreateResource.jsx +285 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Fleet.jsx +307 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/pages/Resources.jsx +229 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/Header.jsx +132 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/Sidebar.jsx +174 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/partials/SidebarLinkGroup.jsx +21 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/AuthContext.jsx +170 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Info.jsx +49 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/ThemeContext.jsx +37 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Transition.jsx +116 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/src/utils/Utils.js +63 -0
- package/src/plugins/bundled/fops-plugin-cloud/ui/vite.config.js +23 -0
- package/src/plugins/bundled/fops-plugin-foundation/test-helpers.js +65 -0
- package/src/plugins/loader.js +34 -1
- package/src/plugins/registry.js +15 -0
- package/src/plugins/schemas.js +17 -0
- package/src/project.js +1 -1
- package/src/serve.js +196 -2
- package/src/shell.js +21 -1
- package/src/web/admin.html.js +236 -0
- package/src/web/api.js +73 -0
- package/src/web/dist/assets/index-BphVaAUd.css +1 -0
- package/src/web/dist/assets/index-CSckLzuG.js +129 -0
- package/src/web/dist/index.html +2 -2
- package/src/web/frontend/index.html +16 -0
- package/src/web/frontend/src/App.jsx +445 -0
- package/src/web/frontend/src/components/ChatView.jsx +910 -0
- package/src/web/frontend/src/components/InputBox.jsx +523 -0
- package/src/web/frontend/src/components/Sidebar.jsx +410 -0
- package/src/web/frontend/src/components/StatusBar.jsx +37 -0
- package/src/web/frontend/src/components/TabBar.jsx +87 -0
- package/src/web/frontend/src/hooks/useWebSocket.js +412 -0
- package/src/web/frontend/src/index.css +78 -0
- package/src/web/frontend/src/main.jsx +6 -0
- package/src/web/frontend/vite.config.js +21 -0
- package/src/web/server.js +64 -1
- package/src/web/dist/assets/index-NXC8Hvnp.css +0 -1
- package/src/web/dist/assets/index-QH1N4ejK.js +0 -112
|
@@ -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
|
+
}
|