@meshxdata/fops 0.1.48 → 0.1.50
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 +368 -0
- package/package.json +1 -1
- package/src/commands/lifecycle.js +30 -11
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-core.js +347 -6
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-data-bootstrap.js +421 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-flux.js +5 -179
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-naming.js +14 -4
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-postgres.js +171 -4
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks-storage.js +303 -8
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-aks.js +2 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-auth.js +1 -1
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-fleet-swarm.js +936 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-fleet.js +10 -918
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-helpers.js +5 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-keyvault-keys.js +413 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-keyvault.js +14 -399
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops-config.js +754 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops-knock.js +527 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops-ssh.js +427 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-ops.js +99 -1686
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision-health.js +279 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision-init.js +186 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-provision.js +66 -444
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-results.js +11 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-vm-lifecycle.js +5 -540
- package/src/plugins/bundled/fops-plugin-azure/lib/azure-vm-terraform.js +544 -0
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/infra-cmds.js +75 -3
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/test-cmds.js +227 -11
- package/src/plugins/bundled/fops-plugin-azure/lib/commands/vm-cmds.js +2 -1
- package/src/plugins/bundled/fops-plugin-azure/lib/pytest-parse.js +21 -0
- package/src/plugins/bundled/fops-plugin-foundation/index.js +309 -44
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* azure-ops-ssh.js
|
|
3
|
+
* SSH and remote agent operations for Azure VMs.
|
|
4
|
+
* Extracted from azure-ops.js for maintainability.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import chalk from "chalk";
|
|
11
|
+
import { readState, saveState, listVms, requireVmState } from "./azure-state.js";
|
|
12
|
+
import {
|
|
13
|
+
DEFAULTS, DIM, OK, WARN, ERR,
|
|
14
|
+
banner, lazyExeca,
|
|
15
|
+
sshCmd, MUX_OPTS, waitForSsh, knockForVm,
|
|
16
|
+
ensureOpenAiNetworkAccess,
|
|
17
|
+
} from "./azure-helpers.js";
|
|
18
|
+
|
|
19
|
+
// ── ssh ─────────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export async function azureSsh(opts = {}) {
|
|
22
|
+
const { knockForVm, ensureKnockSequence } = await import("./azure-helpers.js");
|
|
23
|
+
// Sync IP + knock sequence from Azure before using them
|
|
24
|
+
const freshState = await ensureKnockSequence(requireVmState(opts.vmName));
|
|
25
|
+
const ip = freshState?.publicIp;
|
|
26
|
+
if (!ip) { console.error(chalk.red("\n No IP address. Is the VM running? Try: fops azure start\n")); process.exit(1); }
|
|
27
|
+
|
|
28
|
+
const sshUser = opts.user || DEFAULTS.adminUser;
|
|
29
|
+
await knockForVm(freshState);
|
|
30
|
+
|
|
31
|
+
const { execa } = await import("execa");
|
|
32
|
+
const result = await execa("ssh", [
|
|
33
|
+
"-o", "StrictHostKeyChecking=no",
|
|
34
|
+
"-o", "UserKnownHostsFile=/dev/null",
|
|
35
|
+
"-o", "ConnectTimeout=15",
|
|
36
|
+
`${sshUser}@${ip}`,
|
|
37
|
+
], { stdio: "inherit", reject: false });
|
|
38
|
+
if (result.exitCode !== 0 && result.exitCode !== null) {
|
|
39
|
+
console.error(chalk.red(`\n SSH failed (exit ${result.exitCode}). If port-knock is enabled, try: fops azure knock open ${freshState?.vmName}\n`));
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── port forward ─────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export async function azurePortForward(opts = {}) {
|
|
47
|
+
const { remotePort, localPort } = opts;
|
|
48
|
+
const state = requireVmState(opts.vmName);
|
|
49
|
+
const ip = state.publicIp;
|
|
50
|
+
if (!ip) { console.error(chalk.red("\n No IP address. Is the VM running? Try: fops azure start\n")); process.exit(1); }
|
|
51
|
+
|
|
52
|
+
await knockForVm(state);
|
|
53
|
+
|
|
54
|
+
const lp = localPort || remotePort;
|
|
55
|
+
console.log(chalk.cyan(`\n Forwarding localhost:${lp} → ${ip}:${remotePort}`));
|
|
56
|
+
console.log(DIM(" Press Ctrl+C to stop\n"));
|
|
57
|
+
|
|
58
|
+
const { execa } = await import("execa");
|
|
59
|
+
await execa("ssh", [
|
|
60
|
+
...MUX_OPTS(ip, DEFAULTS.adminUser),
|
|
61
|
+
"-N",
|
|
62
|
+
"-L", `${lp}:localhost:${remotePort}`,
|
|
63
|
+
`${DEFAULTS.adminUser}@${ip}`,
|
|
64
|
+
], { stdio: "inherit", reject: false });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── ssh admin ────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
export async function azureSshAdminAdd(opts = {}) {
|
|
70
|
+
const { pubKey } = opts;
|
|
71
|
+
if (!pubKey?.trim()) {
|
|
72
|
+
console.error(chalk.red("\n ✗ No public key provided\n"));
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { vms } = listVms();
|
|
77
|
+
const vmNames = Object.keys(vms);
|
|
78
|
+
if (vmNames.length === 0) {
|
|
79
|
+
console.error(chalk.red("\n ✗ No VMs tracked. Use: fops azure up <name>\n"));
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const execa = await lazyExeca();
|
|
84
|
+
const adminUser = DEFAULTS.adminUser;
|
|
85
|
+
const keyLine = pubKey.trim();
|
|
86
|
+
|
|
87
|
+
banner("Adding SSH key to all VMs");
|
|
88
|
+
console.log(DIM(` Key: ${keyLine.slice(0, 60)}...`));
|
|
89
|
+
console.log(DIM(` VMs: ${vmNames.join(", ")}\n`));
|
|
90
|
+
|
|
91
|
+
let success = 0, failed = 0;
|
|
92
|
+
|
|
93
|
+
for (const vmName of vmNames) {
|
|
94
|
+
const vm = vms[vmName];
|
|
95
|
+
const ip = vm.publicIp;
|
|
96
|
+
if (!ip) {
|
|
97
|
+
console.log(WARN(` ⚠ ${vmName}: no IP (VM stopped?) — skipped`));
|
|
98
|
+
failed++;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await knockForVm(vm);
|
|
103
|
+
const sshOk = await waitForSsh(execa, ip, adminUser, 15000);
|
|
104
|
+
if (!sshOk) {
|
|
105
|
+
console.log(ERR(` ✗ ${vmName}: SSH unreachable — skipped`));
|
|
106
|
+
failed++;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const addKeyCmd = `grep -qxF '${keyLine}' ~/.ssh/authorized_keys 2>/dev/null || echo '${keyLine}' >> ~/.ssh/authorized_keys`;
|
|
111
|
+
const { exitCode } = await sshCmd(execa, ip, adminUser, addKeyCmd, 30000);
|
|
112
|
+
if (exitCode === 0) {
|
|
113
|
+
console.log(OK(` ✓ ${vmName}: key added`));
|
|
114
|
+
success++;
|
|
115
|
+
} else {
|
|
116
|
+
console.log(ERR(` ✗ ${vmName}: failed to add key`));
|
|
117
|
+
failed++;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
console.log("");
|
|
122
|
+
if (failed === 0) {
|
|
123
|
+
console.log(OK(` ✓ SSH key added to all ${success} VM(s)\n`));
|
|
124
|
+
} else {
|
|
125
|
+
console.log(WARN(` Done: ${success} succeeded, ${failed} failed\n`));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── agent (remote TUI via SSH) ───────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/** Shell-escape a value for single-quoted export (e.g. foo -> 'foo', a'b -> 'a'\''b'). */
|
|
132
|
+
function shellExportValue(v) {
|
|
133
|
+
if (v == null || v === "") return "''";
|
|
134
|
+
const s = String(v);
|
|
135
|
+
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Resolve Foundation project root for reading .env. Uses findProjectRoot() first, then
|
|
140
|
+
* readState().projectRoot so that keys always come from project .env even when cwd is not the repo.
|
|
141
|
+
* When we find root via cwd/FOUNDATION_ROOT, we persist it to state so running from ~ later still works.
|
|
142
|
+
*/
|
|
143
|
+
async function resolveProjectRootForEnv() {
|
|
144
|
+
const { findProjectRoot } = await import("./azure-openai.js");
|
|
145
|
+
let root = findProjectRoot();
|
|
146
|
+
if (root) {
|
|
147
|
+
root = path.resolve(root);
|
|
148
|
+
try {
|
|
149
|
+
const state = readState();
|
|
150
|
+
const existing = state?.projectRoot?.trim();
|
|
151
|
+
if (existing !== root) saveState({ ...state, projectRoot: root });
|
|
152
|
+
} catch {}
|
|
153
|
+
return root;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const state = readState();
|
|
157
|
+
const candidate = state?.projectRoot?.trim();
|
|
158
|
+
if (candidate && fs.existsSync(candidate)) {
|
|
159
|
+
const compose = path.join(candidate, "docker-compose.yaml");
|
|
160
|
+
const composeAlt = path.join(candidate, "docker-compose.yml");
|
|
161
|
+
if (fs.existsSync(compose) || fs.existsSync(composeAlt)) return path.resolve(candidate);
|
|
162
|
+
}
|
|
163
|
+
} catch {}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Build Azure OpenAI env vars from local project .env for the remote session.
|
|
169
|
+
* Returns { exportPrefix, inlineEnv, envFileLines } where:
|
|
170
|
+
* - exportPrefix: "export VAR='val' ... ; " (for sourcing before other commands)
|
|
171
|
+
* - inlineEnv: "VAR='val' VAR2='val2' " for inline env (may be truncated by SSH argv limits)
|
|
172
|
+
* - envFileLines: array of "VAR=val" lines for writing to a file on the VM and sourcing (most reliable).
|
|
173
|
+
*/
|
|
174
|
+
async function buildAzureOpenAIEnvForRemote() {
|
|
175
|
+
const { readAzureOpenAIConfigFromEnv } = await import("./azure-openai.js");
|
|
176
|
+
const root = await resolveProjectRootForEnv();
|
|
177
|
+
if (!root) return { exportPrefix: "", inlineEnv: "", envFileLines: [] };
|
|
178
|
+
const envPath = path.join(root, ".env");
|
|
179
|
+
const cfg = readAzureOpenAIConfigFromEnv(envPath);
|
|
180
|
+
if (!cfg.endpoint || !cfg.key) return { exportPrefix: "", inlineEnv: "", envFileLines: [] };
|
|
181
|
+
const deployment = cfg.deployment || "gpt-4o";
|
|
182
|
+
const apiVersion = cfg.apiVersion || "2024-12-01-preview";
|
|
183
|
+
const lines = [
|
|
184
|
+
`AZURE_OPENAI_ENDPOINT=${cfg.endpoint}`,
|
|
185
|
+
`AZURE_OPENAI_API_KEY=${cfg.key}`,
|
|
186
|
+
`MX_OPENAI_API_KEY=${cfg.key}`,
|
|
187
|
+
`AZURE_OPENAI_DEPLOYMENT=${deployment}`,
|
|
188
|
+
`AZURE_OPENAI_API_VERSION=${apiVersion}`,
|
|
189
|
+
];
|
|
190
|
+
if (cfg.fastDeployment) lines.push(`AZURE_OPENAI_FAST_DEPLOYMENT=${cfg.fastDeployment}`);
|
|
191
|
+
const parts = lines.map((l) => {
|
|
192
|
+
const eq = l.indexOf("=");
|
|
193
|
+
const k = l.slice(0, eq);
|
|
194
|
+
const v = l.slice(eq + 1);
|
|
195
|
+
return `${k}=${shellExportValue(v)}`;
|
|
196
|
+
});
|
|
197
|
+
const assign = parts.join(" ");
|
|
198
|
+
return {
|
|
199
|
+
exportPrefix: "export " + assign + "; ",
|
|
200
|
+
inlineEnv: assign + " ",
|
|
201
|
+
envFileLines: lines,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** @deprecated Use buildAzureOpenAIEnvForRemote(). */
|
|
206
|
+
async function buildAzureOpenAIExportPrefix() {
|
|
207
|
+
const { exportPrefix } = await buildAzureOpenAIEnvForRemote();
|
|
208
|
+
return exportPrefix;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Sync Azure OpenAI config from the local project .env to the VM's /opt/foundation-compose/.env
|
|
213
|
+
* when they differ. Source of truth is the project .env; we only push if the VM's Azure OpenAI
|
|
214
|
+
* vars don't match local .env. No-op if no project root, endpoint/key missing in .env, or VM
|
|
215
|
+
* already matches.
|
|
216
|
+
*/
|
|
217
|
+
async function syncOpenAIKeysToVm(execa, ip, adminUser, state) {
|
|
218
|
+
const {
|
|
219
|
+
readAzureOpenAIConfigFromEnv,
|
|
220
|
+
parseAzureOpenAIConfigFromContent,
|
|
221
|
+
azureOpenAIConfigEqual,
|
|
222
|
+
} = await import("./azure-openai.js");
|
|
223
|
+
const root = await resolveProjectRootForEnv();
|
|
224
|
+
if (!root) return;
|
|
225
|
+
const envPath = path.join(root, ".env");
|
|
226
|
+
const fromFile = readAzureOpenAIConfigFromEnv(envPath);
|
|
227
|
+
const endpoint = fromFile.endpoint;
|
|
228
|
+
const key = fromFile.key;
|
|
229
|
+
if (!endpoint || !key) return;
|
|
230
|
+
|
|
231
|
+
const deployment = fromFile.deployment || "gpt-4o";
|
|
232
|
+
const apiVersion = fromFile.apiVersion || "2024-12-01-preview";
|
|
233
|
+
const fastDeployment = fromFile.fastDeployment || "";
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const { stdout: remoteEnv } = await sshCmd(execa, ip, adminUser, "cat /opt/foundation-compose/.env 2>/dev/null || true", 10000);
|
|
237
|
+
const onVm = parseAzureOpenAIConfigFromContent(remoteEnv || "");
|
|
238
|
+
if (azureOpenAIConfigEqual(fromFile, onVm)) return;
|
|
239
|
+
} catch {
|
|
240
|
+
// If we can't read VM .env, sync anyway so VM gets local config
|
|
241
|
+
}
|
|
242
|
+
console.log(chalk.dim(" VM .env differs from local project .env; syncing Azure OpenAI config…"));
|
|
243
|
+
|
|
244
|
+
const tmpFile = path.join(os.tmpdir(), `fops-openai-env-${Date.now()}`);
|
|
245
|
+
const lines = [
|
|
246
|
+
`AZURE_OPENAI_ENDPOINT=${endpoint}`,
|
|
247
|
+
`AZURE_OPENAI_API_KEY=${key}`,
|
|
248
|
+
`MX_OPENAI_API_KEY=${key}`,
|
|
249
|
+
`AZURE_OPENAI_DEPLOYMENT=${deployment}`,
|
|
250
|
+
`AZURE_OPENAI_API_VERSION=${apiVersion}`,
|
|
251
|
+
];
|
|
252
|
+
if (fastDeployment) lines.push(`AZURE_OPENAI_FAST_DEPLOYMENT=${fastDeployment}`);
|
|
253
|
+
fs.writeFileSync(tmpFile, lines.join("\n") + "\n", "utf8");
|
|
254
|
+
const remotePath = "/tmp/fops-openai-env-update";
|
|
255
|
+
try {
|
|
256
|
+
const { exitCode: scpCode } = await execa("scp", [
|
|
257
|
+
...MUX_OPTS(ip, adminUser),
|
|
258
|
+
tmpFile,
|
|
259
|
+
`${adminUser}@${ip}:${remotePath}`,
|
|
260
|
+
], { timeout: 15000, reject: false });
|
|
261
|
+
if (scpCode !== 0) {
|
|
262
|
+
console.log(chalk.yellow(" ⚠ SCP failed — could not upload env to VM"));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
// Loop body must use semicolons so "do" is not followed by "&&" (invalid on remote bash)
|
|
266
|
+
const mergeScript = [
|
|
267
|
+
"cd /opt/foundation-compose",
|
|
268
|
+
"touch .env",
|
|
269
|
+
"while IFS= read -r line; do [[ -z \"$line\" || \"$line\" =~ ^# ]] && continue; key=\"${line%%=*}\"; key=\"${key%% *}\"; [[ -z \"$key\" ]] && continue; (grep -v \"^$key=\" .env 2>/dev/null || true) > .env.tmp; echo \"$line\" >> .env.tmp; mv .env.tmp .env; done < " + remotePath,
|
|
270
|
+
"rm -f " + remotePath,
|
|
271
|
+
].join(" && ");
|
|
272
|
+
const { exitCode: mergeCode } = await sshCmd(execa, ip, adminUser, mergeScript, 15000);
|
|
273
|
+
if (mergeCode === 0) {
|
|
274
|
+
console.log(chalk.dim(" ✓ Azure OpenAI keys synced to VM .env"));
|
|
275
|
+
} else {
|
|
276
|
+
console.log(chalk.yellow(" ⚠ Merge script failed on VM (exit " + mergeCode + ") — .env may not be updated"));
|
|
277
|
+
}
|
|
278
|
+
} finally {
|
|
279
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function azureAgent(opts = {}) {
|
|
284
|
+
const execa = await lazyExeca();
|
|
285
|
+
const state = requireVmState(opts.vmName);
|
|
286
|
+
const ip = state.publicIp;
|
|
287
|
+
const adminUser = DEFAULTS.adminUser;
|
|
288
|
+
if (!ip) { console.error(chalk.red("\n No IP address. Is the VM running? Try: fops azure start\n")); process.exit(1); }
|
|
289
|
+
|
|
290
|
+
await knockForVm(state);
|
|
291
|
+
|
|
292
|
+
// Whitelist VM's public IP on Azure OpenAI (if resource has firewall) so agent on VM can call the API
|
|
293
|
+
await ensureOpenAiNetworkAccess(execa, ip, opts.profile);
|
|
294
|
+
|
|
295
|
+
await syncOpenAIKeysToVm(execa, ip, adminUser, state);
|
|
296
|
+
|
|
297
|
+
const webPort = 3099;
|
|
298
|
+
const agentName = opts.agent || "foundation";
|
|
299
|
+
const vmName = state.vmName;
|
|
300
|
+
console.log(chalk.dim(` VM: ${vmName} (${ip}) — agent: ${agentName}\n`));
|
|
301
|
+
|
|
302
|
+
// Sync local project root .env Azure OpenAI into the remote session (inline VAR=val before fops agent so the process gets them)
|
|
303
|
+
const { exportPrefix, inlineEnv } = await buildAzureOpenAIEnvForRemote();
|
|
304
|
+
if (!inlineEnv) {
|
|
305
|
+
const root = await resolveProjectRootForEnv();
|
|
306
|
+
const envPath = root ? path.join(root, ".env") : null;
|
|
307
|
+
console.log(chalk.yellow(" ⚠ Azure OpenAI keys are read from the project .env; none found."));
|
|
308
|
+
console.log(chalk.dim(" Agent on VM will show \"Connection error\" or \"No API key found\" unless keys are present."));
|
|
309
|
+
if (envPath) {
|
|
310
|
+
console.log(chalk.dim(` Add AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY to ${envPath} then run this again.`));
|
|
311
|
+
} else {
|
|
312
|
+
console.log(chalk.dim(` Project root not found (cwd: ${process.cwd()}). Set FOUNDATION_ROOT or projectRoot in ~/.fops.json to your foundation-compose directory, or run this from that directory. Then add AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY to that project's .env.`));
|
|
313
|
+
}
|
|
314
|
+
console.log(chalk.dim(""));
|
|
315
|
+
}
|
|
316
|
+
const envPrefix = "set -a && . /opt/foundation-compose/.env 2>/dev/null; set +a && cd /opt/foundation-compose && ";
|
|
317
|
+
const remotePrefix = exportPrefix + envPrefix;
|
|
318
|
+
const agentEnv = inlineEnv; // inline VAR=val before "fops agent" so the process inherits them (reliable with ssh sh -c)
|
|
319
|
+
const args = [
|
|
320
|
+
"-t",
|
|
321
|
+
"-L", `${webPort}:localhost:${webPort}`,
|
|
322
|
+
"-o", "StrictHostKeyChecking=no",
|
|
323
|
+
"-o", "ExitOnForwardFailure=no",
|
|
324
|
+
`${DEFAULTS.adminUser}@${ip}`,
|
|
325
|
+
remotePrefix + agentEnv + `fops agent ${agentName}`.trim(),
|
|
326
|
+
];
|
|
327
|
+
if (opts.message) {
|
|
328
|
+
args.pop();
|
|
329
|
+
args.push(remotePrefix + agentEnv + `fops agent ${agentName} -m ${JSON.stringify(opts.message)}`.trim());
|
|
330
|
+
}
|
|
331
|
+
if (opts.classic) {
|
|
332
|
+
args[args.length - 1] += " --classic";
|
|
333
|
+
}
|
|
334
|
+
if (opts.model) {
|
|
335
|
+
args[args.length - 1] += ` --model ${opts.model}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
console.log(chalk.cyan(` Agent web UI: http://localhost:${webPort}`));
|
|
339
|
+
console.log(chalk.dim(" Wait for the TUI to appear on the VM before opening the URL (avoids \"channel 3: open failed: Connection refused\")."));
|
|
340
|
+
console.log(chalk.dim(" If the agent shows a connection error to Azure OpenAI, run: fops azure openai debug-vm " + (vmName && vmName !== "alessio" ? vmName : "") + "\n"));
|
|
341
|
+
|
|
342
|
+
const result = await execa("ssh", args, { stdio: "inherit", reject: false });
|
|
343
|
+
if (result?.exitCode !== 0 && result?.exitCode != null) {
|
|
344
|
+
console.log(chalk.yellow("\n SSH exited with code " + result.exitCode + ". For Azure OpenAI connection errors run: fops azure openai debug-vm" + (vmName && vmName !== "alessio" ? " " + vmName : "") + "\n"));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Run a single agent request on the VM with DEBUG=1 and stream back stdout+stderr
|
|
350
|
+
* so we can see the real connection error (e.g. cause.message, cause.code).
|
|
351
|
+
*/
|
|
352
|
+
export async function azureOpenAiDebugVm(opts = {}) {
|
|
353
|
+
const execa = await lazyExeca();
|
|
354
|
+
const state = requireVmState(opts.vmName);
|
|
355
|
+
const ip = state.publicIp;
|
|
356
|
+
const adminUser = DEFAULTS.adminUser;
|
|
357
|
+
const remoteEnvPath = "/tmp/fops-azure-env." + process.pid;
|
|
358
|
+
if (!ip) {
|
|
359
|
+
console.error(chalk.red("\n No IP address. Is the VM running? Try: fops azure start\n"));
|
|
360
|
+
process.exit(1);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
await knockForVm(state);
|
|
364
|
+
await ensureOpenAiNetworkAccess(execa, ip, opts.profile);
|
|
365
|
+
await syncOpenAIKeysToVm(execa, ip, adminUser, state);
|
|
366
|
+
|
|
367
|
+
const agentName = opts.agent || "foundation";
|
|
368
|
+
const root = await resolveProjectRootForEnv();
|
|
369
|
+
const envPath = root ? path.join(root, ".env") : null;
|
|
370
|
+
const { envFileLines } = await buildAzureOpenAIEnvForRemote();
|
|
371
|
+
|
|
372
|
+
if (envFileLines.length) {
|
|
373
|
+
console.log(chalk.dim(" Pushing Azure OpenAI vars to VM .env, then running agent (sourcing .env so process gets keys)."));
|
|
374
|
+
const tmpFile = path.join(os.tmpdir(), `fops-azure-env-${process.pid}`);
|
|
375
|
+
fs.writeFileSync(tmpFile, envFileLines.join("\n") + "\n", "utf8");
|
|
376
|
+
try {
|
|
377
|
+
const { exitCode: scpCode } = await execa("scp", [
|
|
378
|
+
...MUX_OPTS(ip, adminUser),
|
|
379
|
+
tmpFile,
|
|
380
|
+
`${adminUser}@${ip}:${remoteEnvPath}`,
|
|
381
|
+
], { timeout: 15000, reject: false });
|
|
382
|
+
if (scpCode === 0) {
|
|
383
|
+
const mergeScript = [
|
|
384
|
+
"cd /opt/foundation-compose",
|
|
385
|
+
"touch .env",
|
|
386
|
+
"while IFS= read -r line; do [[ -z \"$line\" || \"$line\" =~ ^# ]] && continue; key=\"${line%%=*}\"; key=\"${key%% *}\"; [[ -z \"$key\" ]] && continue; (grep -v \"^$key=\" .env 2>/dev/null || true) > .env.tmp; echo \"$line\" >> .env.tmp; mv .env.tmp .env; done < " + remoteEnvPath,
|
|
387
|
+
"rm -f " + remoteEnvPath,
|
|
388
|
+
].join(" && ");
|
|
389
|
+
const { exitCode: mergeCode } = await sshCmd(execa, ip, adminUser, mergeScript, 15000);
|
|
390
|
+
if (mergeCode === 0) {
|
|
391
|
+
const { stdout: check } = await sshCmd(execa, ip, adminUser, "grep -E '^AZURE_OPENAI_ENDPOINT=|^AZURE_OPENAI_API_KEY=' /opt/foundation-compose/.env 2>/dev/null | sed 's/=.*/=***/' || true", 5000);
|
|
392
|
+
if (check?.trim()) console.log(chalk.dim(" VM .env: " + check.trim().replace(/\n/g, " ")));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
} finally {
|
|
396
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
397
|
+
}
|
|
398
|
+
} else {
|
|
399
|
+
console.log(chalk.yellow(" ⚠ Not injecting Azure OpenAI — remote session will have no keys from project .env."));
|
|
400
|
+
if (envPath) {
|
|
401
|
+
const { readAzureOpenAIConfigFromEnv } = await import("./azure-openai.js");
|
|
402
|
+
const cfg = fs.existsSync(envPath) ? readAzureOpenAIConfigFromEnv(envPath) : { endpoint: "", key: "" };
|
|
403
|
+
const missing = [];
|
|
404
|
+
if (!cfg.endpoint?.trim()) missing.push("AZURE_OPENAI_ENDPOINT");
|
|
405
|
+
if (!cfg.key?.trim()) missing.push("AZURE_OPENAI_API_KEY or MX_OPENAI_API_KEY");
|
|
406
|
+
if (missing.length) {
|
|
407
|
+
console.log(chalk.dim(` Tried ${envPath} — missing: ${missing.join(", ")}.`));
|
|
408
|
+
} else {
|
|
409
|
+
console.log(chalk.dim(` Project root not found. Tried cwd/FOUNDATION_ROOT/~/.fops.json projectRoot.`));
|
|
410
|
+
}
|
|
411
|
+
console.log(chalk.dim(` Add the missing var(s) to ${envPath}, then run again.\n`));
|
|
412
|
+
} else {
|
|
413
|
+
console.log(chalk.dim(` Project root not found (cwd: ${process.cwd()}). Set FOUNDATION_ROOT or projectRoot in ~/.fops.json, or run from foundation-compose. Then add AZURE_OPENAI_* to that project's .env.\n`));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Source VM .env so AZURE_OPENAI_* (and MX_OPENAI_API_KEY) are in the environment, then run fops
|
|
418
|
+
const cmd =
|
|
419
|
+
"bash -c 'cd /opt/foundation-compose && set -a && . ./.env 2>/dev/null; set +a; exec env DEBUG=1 fops agent " + agentName + " -m \"hi\" 2>&1'";
|
|
420
|
+
console.log(chalk.cyan("\n Running: DEBUG=1 fops agent " + agentName + " -m 'hi' (VM .env sourced so AZURE_OPENAI_* are exported)\n"));
|
|
421
|
+
const { stdout, stderr, exitCode } = await sshCmd(execa, ip, adminUser, cmd, 60000);
|
|
422
|
+
const out = [stdout, stderr].filter(Boolean).join("\n");
|
|
423
|
+
if (out) console.log(out);
|
|
424
|
+
if (exitCode !== 0) {
|
|
425
|
+
console.log(chalk.yellow("\n Exit code: " + exitCode + " — see output above. If 'No API key': ensure VM .env has AZURE_OPENAI_* or run 'fops azure deploy' to update VM fops."));
|
|
426
|
+
}
|
|
427
|
+
}
|