@lamalibre/install-portlama-e2e-mcp 0.1.0
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/bin/install-portlama-e2e-mcp.js +30 -0
- package/package.json +47 -0
- package/src/config.js +60 -0
- package/src/index.js +96 -0
- package/src/install.js +262 -0
- package/src/lib/deps.js +162 -0
- package/src/lib/logs.js +146 -0
- package/src/lib/multipass.js +161 -0
- package/src/lib/profiles.js +69 -0
- package/src/lib/state.js +81 -0
- package/src/tools/env.js +45 -0
- package/src/tools/provision.js +353 -0
- package/src/tools/snapshots.js +126 -0
- package/src/tools/status.js +161 -0
- package/src/tools/tests.js +489 -0
- package/src/tools/vm.js +186 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Test Execution Tools — test_run, test_run_all, test_list, test_reset, test_publish
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import { execa } from 'execa';
|
|
9
|
+
import * as mp from '../lib/multipass.js';
|
|
10
|
+
import {
|
|
11
|
+
resolveTestChain,
|
|
12
|
+
getSingleVmTests,
|
|
13
|
+
getThreeVmTests,
|
|
14
|
+
SINGLE_VM_DEPS,
|
|
15
|
+
THREE_VM_DEPS,
|
|
16
|
+
} from '../lib/deps.js';
|
|
17
|
+
import {
|
|
18
|
+
createRun,
|
|
19
|
+
writeTestResult,
|
|
20
|
+
writeTestLog,
|
|
21
|
+
writeSummary,
|
|
22
|
+
extractErrors,
|
|
23
|
+
buildCompactSummary,
|
|
24
|
+
} from '../lib/logs.js';
|
|
25
|
+
import { loadState, recordRun } from '../lib/state.js';
|
|
26
|
+
import {
|
|
27
|
+
VM_HOST,
|
|
28
|
+
VM_AGENT,
|
|
29
|
+
VM_VISITOR,
|
|
30
|
+
REPO_ROOT,
|
|
31
|
+
THREE_VM_DIR,
|
|
32
|
+
SINGLE_VM_DIR,
|
|
33
|
+
E2E_LOGS_DIR,
|
|
34
|
+
TEST_DOMAIN,
|
|
35
|
+
} from '../config.js';
|
|
36
|
+
|
|
37
|
+
/** Reset Authelia regulation state between tests. */
|
|
38
|
+
async function resetAuthelia() {
|
|
39
|
+
await mp.exec(VM_HOST, 'systemctl stop authelia', {
|
|
40
|
+
sudo: true,
|
|
41
|
+
allowFailure: true,
|
|
42
|
+
});
|
|
43
|
+
await mp.exec(
|
|
44
|
+
VM_HOST,
|
|
45
|
+
'sqlite3 /etc/authelia/db.sqlite3 "DELETE FROM authentication_logs; DELETE FROM totp_history;"',
|
|
46
|
+
{ sudo: true, allowFailure: true },
|
|
47
|
+
);
|
|
48
|
+
await mp.exec(VM_HOST, 'systemctl start authelia', {
|
|
49
|
+
sudo: true,
|
|
50
|
+
allowFailure: true,
|
|
51
|
+
});
|
|
52
|
+
// Authelia needs a moment to start
|
|
53
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Build the test environment object with VM IPs and credentials. */
|
|
57
|
+
async function buildTestEnv(state) {
|
|
58
|
+
const [hostIp, agentIp, visitorIp] = await Promise.all([
|
|
59
|
+
mp.getIp(VM_HOST),
|
|
60
|
+
mp.getIp(VM_AGENT),
|
|
61
|
+
mp.getIp(VM_VISITOR),
|
|
62
|
+
]);
|
|
63
|
+
const domain = state.domain || TEST_DOMAIN;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
HOST_IP: hostIp || '',
|
|
67
|
+
AGENT_IP: agentIp || '',
|
|
68
|
+
VISITOR_IP: visitorIp || '',
|
|
69
|
+
TEST_DOMAIN: domain,
|
|
70
|
+
ADMIN_PASSWORD: 'not-used-mTLS-only',
|
|
71
|
+
AGENT_P12_PASSWORD: state.credentials?.agentP12Password || '',
|
|
72
|
+
TEST_USER: 'testuser',
|
|
73
|
+
TEST_USER_PASSWORD: 'TestPassword-E2E-123',
|
|
74
|
+
LOG_LEVEL: '1',
|
|
75
|
+
LOG_DIR: '/tmp',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Finalize a test run: write summary, record in state, return MCP response. */
|
|
80
|
+
function finishRun(run, suite, target, testResults, startMs) {
|
|
81
|
+
const summary = {
|
|
82
|
+
runId: run.id,
|
|
83
|
+
suite,
|
|
84
|
+
target,
|
|
85
|
+
passed: testResults.filter((t) => t.status === 'passed').length,
|
|
86
|
+
failed: testResults.filter((t) => t.status === 'failed').length,
|
|
87
|
+
skipped: 0,
|
|
88
|
+
durationMs: Date.now() - startMs,
|
|
89
|
+
tests: testResults,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
writeSummary(run.runDir, summary);
|
|
93
|
+
recordRun({ id: run.id, suite, target, timestamp: new Date().toISOString() });
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: 'text', text: buildCompactSummary(summary) }],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Run a single three-VM test script and capture results. */
|
|
101
|
+
async function runThreeVmTest(testFile, env) {
|
|
102
|
+
const scriptPath = path.join(THREE_VM_DIR, testFile);
|
|
103
|
+
const startMs = Date.now();
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const result = await execa('bash', [scriptPath], {
|
|
107
|
+
env,
|
|
108
|
+
timeout: 300_000,
|
|
109
|
+
all: true,
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
status: 'passed',
|
|
113
|
+
durationMs: Date.now() - startMs,
|
|
114
|
+
output: result.all || result.stdout,
|
|
115
|
+
errors: [],
|
|
116
|
+
};
|
|
117
|
+
} catch (err) {
|
|
118
|
+
const output = err.all || err.stderr || err.message;
|
|
119
|
+
return {
|
|
120
|
+
status: 'failed',
|
|
121
|
+
durationMs: Date.now() - startMs,
|
|
122
|
+
output,
|
|
123
|
+
errors: extractErrors(output),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Run a single-VM test script on the host VM and capture results. */
|
|
129
|
+
async function runSingleVmTest(testFile) {
|
|
130
|
+
const startMs = Date.now();
|
|
131
|
+
const logFile = `/tmp/test-${testFile.replace('.sh', '')}.md`;
|
|
132
|
+
|
|
133
|
+
const result = await mp.exec(
|
|
134
|
+
VM_HOST,
|
|
135
|
+
`LOG_DIR=/tmp SKIP_DNS_TESTS=1 _LOG_FILE=${logFile} bash /tmp/e2e-single/${testFile}`,
|
|
136
|
+
{ sudo: true, timeout: 300_000, allowFailure: true },
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const output = result.stdout + '\n' + result.stderr;
|
|
140
|
+
return {
|
|
141
|
+
status: result.exitCode === 0 ? 'passed' : 'failed',
|
|
142
|
+
durationMs: Date.now() - startMs,
|
|
143
|
+
output,
|
|
144
|
+
errors: result.exitCode !== 0 ? extractErrors(output) : [],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const testRunTool = {
|
|
149
|
+
name: 'test_run',
|
|
150
|
+
description:
|
|
151
|
+
'Run a specific test by number, automatically resolving its dependencies. ' +
|
|
152
|
+
'Returns a compact summary with pass/fail and error lines only — no full logs. ' +
|
|
153
|
+
'Use test_log to fetch full output for a specific test if needed.',
|
|
154
|
+
inputSchema: z.object({
|
|
155
|
+
test: z.coerce.number().int().min(1).describe('Test number to run (e.g. 11 for plugin-lifecycle)'),
|
|
156
|
+
suite: z
|
|
157
|
+
.enum(['single-vm', 'three-vm'])
|
|
158
|
+
.default('three-vm')
|
|
159
|
+
.describe('Which test suite'),
|
|
160
|
+
skipDeps: z
|
|
161
|
+
.coerce.boolean()
|
|
162
|
+
.default(false)
|
|
163
|
+
.describe(
|
|
164
|
+
'Skip dependency tests (use if you know prerequisites are met, e.g. from a snapshot)',
|
|
165
|
+
),
|
|
166
|
+
}),
|
|
167
|
+
async handler({ test, suite, skipDeps } = {}) {
|
|
168
|
+
suite = suite || 'three-vm';
|
|
169
|
+
skipDeps = skipDeps ?? false;
|
|
170
|
+
const state = loadState();
|
|
171
|
+
|
|
172
|
+
// Resolve test chain
|
|
173
|
+
const chain = resolveTestChain(test, suite);
|
|
174
|
+
const testsToRun = skipDeps ? chain.filter((t) => t.number === test) : chain;
|
|
175
|
+
|
|
176
|
+
const env = await buildTestEnv(state);
|
|
177
|
+
|
|
178
|
+
// Create run
|
|
179
|
+
const run = createRun();
|
|
180
|
+
const startMs = Date.now();
|
|
181
|
+
const testResults = [];
|
|
182
|
+
|
|
183
|
+
for (const { number, file } of testsToRun) {
|
|
184
|
+
const testName = file.replace('.sh', '');
|
|
185
|
+
|
|
186
|
+
// Reset Authelia between tests
|
|
187
|
+
if (suite === 'three-vm') {
|
|
188
|
+
await resetAuthelia();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const result =
|
|
192
|
+
suite === 'three-vm'
|
|
193
|
+
? await runThreeVmTest(file, env)
|
|
194
|
+
: await runSingleVmTest(file);
|
|
195
|
+
|
|
196
|
+
const testEntry = {
|
|
197
|
+
number,
|
|
198
|
+
name: testName,
|
|
199
|
+
status: result.status,
|
|
200
|
+
durationMs: result.durationMs,
|
|
201
|
+
errors: result.errors,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
testResults.push(testEntry);
|
|
205
|
+
writeTestResult(run.testsDir, testName, testEntry);
|
|
206
|
+
writeTestLog(run.logsDir, testName, result.output);
|
|
207
|
+
|
|
208
|
+
// Stop on failure
|
|
209
|
+
if (result.status === 'failed') break;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return finishRun(run, suite, test, testResults, startMs);
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
export const testRunAllTool = {
|
|
217
|
+
name: 'test_run_all',
|
|
218
|
+
description:
|
|
219
|
+
'Run all tests in a suite (single-vm, three-vm, or both). ' +
|
|
220
|
+
'Returns a compact summary — errors only for failed tests.',
|
|
221
|
+
inputSchema: z.object({
|
|
222
|
+
suite: z
|
|
223
|
+
.enum(['single-vm', 'three-vm', 'both'])
|
|
224
|
+
.default('both')
|
|
225
|
+
.describe('Which suite(s) to run'),
|
|
226
|
+
}),
|
|
227
|
+
async handler({ suite } = {}) {
|
|
228
|
+
suite = suite || 'both';
|
|
229
|
+
const state = loadState();
|
|
230
|
+
const run = createRun();
|
|
231
|
+
const startMs = Date.now();
|
|
232
|
+
const allResults = [];
|
|
233
|
+
|
|
234
|
+
const env = await buildTestEnv(state);
|
|
235
|
+
|
|
236
|
+
// Single-VM tests
|
|
237
|
+
if (suite === 'single-vm' || suite === 'both') {
|
|
238
|
+
// Transfer single-VM test scripts to host
|
|
239
|
+
await mp.exec(VM_HOST, 'mkdir -p /tmp/e2e-single', { sudo: true });
|
|
240
|
+
const files = fs.readdirSync(SINGLE_VM_DIR).filter((f) => f.endsWith('.sh'));
|
|
241
|
+
await Promise.all(
|
|
242
|
+
files.map((file) =>
|
|
243
|
+
mp.transfer(
|
|
244
|
+
path.join(SINGLE_VM_DIR, file),
|
|
245
|
+
`${VM_HOST}:/tmp/e2e-single/${file}`,
|
|
246
|
+
),
|
|
247
|
+
),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
for (const [, file] of Object.entries(getSingleVmTests()).sort(
|
|
251
|
+
([a], [b]) => Number(a) - Number(b),
|
|
252
|
+
)) {
|
|
253
|
+
const testName = `single-${file.replace('.sh', '')}`;
|
|
254
|
+
const result = await runSingleVmTest(file);
|
|
255
|
+
const entry = {
|
|
256
|
+
name: testName,
|
|
257
|
+
status: result.status,
|
|
258
|
+
durationMs: result.durationMs,
|
|
259
|
+
errors: result.errors,
|
|
260
|
+
};
|
|
261
|
+
allResults.push(entry);
|
|
262
|
+
writeTestResult(run.testsDir, testName, entry);
|
|
263
|
+
writeTestLog(run.logsDir, testName, result.output);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Three-VM tests
|
|
268
|
+
if (suite === 'three-vm' || suite === 'both') {
|
|
269
|
+
for (const [, file] of Object.entries(getThreeVmTests()).sort(
|
|
270
|
+
([a], [b]) => Number(a) - Number(b),
|
|
271
|
+
)) {
|
|
272
|
+
const testName = `three-${file.replace('.sh', '')}`;
|
|
273
|
+
await resetAuthelia();
|
|
274
|
+
const result = await runThreeVmTest(file, env);
|
|
275
|
+
const entry = {
|
|
276
|
+
name: testName,
|
|
277
|
+
status: result.status,
|
|
278
|
+
durationMs: result.durationMs,
|
|
279
|
+
errors: result.errors,
|
|
280
|
+
};
|
|
281
|
+
allResults.push(entry);
|
|
282
|
+
writeTestResult(run.testsDir, testName, entry);
|
|
283
|
+
writeTestLog(run.logsDir, testName, result.output);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return finishRun(run, suite, 'all', allResults, startMs);
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
export const testListTool = {
|
|
292
|
+
name: 'test_list',
|
|
293
|
+
description:
|
|
294
|
+
'List all available tests with their dependency graph and filenames.',
|
|
295
|
+
inputSchema: z.object({
|
|
296
|
+
suite: z
|
|
297
|
+
.enum(['single-vm', 'three-vm', 'both'])
|
|
298
|
+
.default('both')
|
|
299
|
+
.describe('Which suite(s) to list'),
|
|
300
|
+
}),
|
|
301
|
+
async handler({ suite } = {}) {
|
|
302
|
+
const s = suite || 'both';
|
|
303
|
+
const result = {};
|
|
304
|
+
|
|
305
|
+
if (s === 'single-vm' || s === 'both') {
|
|
306
|
+
result.singleVm = Object.entries(getSingleVmTests())
|
|
307
|
+
.sort(([a], [b]) => Number(a) - Number(b))
|
|
308
|
+
.map(([num, file]) => ({
|
|
309
|
+
number: Number(num),
|
|
310
|
+
file,
|
|
311
|
+
deps: SINGLE_VM_DEPS[Number(num)] || [],
|
|
312
|
+
}));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (s === 'three-vm' || s === 'both') {
|
|
316
|
+
result.threeVm = Object.entries(getThreeVmTests())
|
|
317
|
+
.sort(([a], [b]) => Number(a) - Number(b))
|
|
318
|
+
.map(([num, file]) => ({
|
|
319
|
+
number: Number(num),
|
|
320
|
+
file,
|
|
321
|
+
deps: THREE_VM_DEPS[Number(num)] || [],
|
|
322
|
+
}));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
content: [
|
|
327
|
+
{ type: 'text', text: JSON.stringify(result, null, 2) },
|
|
328
|
+
],
|
|
329
|
+
};
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
export const testResetTool = {
|
|
334
|
+
name: 'test_reset',
|
|
335
|
+
description:
|
|
336
|
+
'Reset shared state between tests without reprovisioning. ' +
|
|
337
|
+
'Clears Authelia regulation state, kills stray processes, deletes test tunnels.',
|
|
338
|
+
inputSchema: z.object({}),
|
|
339
|
+
async handler() {
|
|
340
|
+
const steps = [];
|
|
341
|
+
|
|
342
|
+
// Reset Authelia
|
|
343
|
+
await resetAuthelia();
|
|
344
|
+
steps.push('Authelia regulation state cleared');
|
|
345
|
+
|
|
346
|
+
// Kill stray processes on agent in parallel
|
|
347
|
+
await Promise.all([
|
|
348
|
+
mp.exec(VM_AGENT, 'pkill -f "python3 -m http.server" || true', {
|
|
349
|
+
sudo: true,
|
|
350
|
+
allowFailure: true,
|
|
351
|
+
}),
|
|
352
|
+
mp.exec(VM_AGENT, 'pkill -f chisel || true', {
|
|
353
|
+
sudo: true,
|
|
354
|
+
allowFailure: true,
|
|
355
|
+
}),
|
|
356
|
+
]);
|
|
357
|
+
steps.push('Killed stray HTTP servers and chisel clients on agent');
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
content: [
|
|
361
|
+
{
|
|
362
|
+
type: 'text',
|
|
363
|
+
text: JSON.stringify({ ok: true, steps }, null, 2),
|
|
364
|
+
},
|
|
365
|
+
],
|
|
366
|
+
};
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
export const testPublishTool = {
|
|
371
|
+
name: 'test_publish',
|
|
372
|
+
description:
|
|
373
|
+
'Run the full E2E suite with production-profile VMs and write rich Markdown logs ' +
|
|
374
|
+
'to e2e-logs/ for committing. This is the final gate before shipping — enforces ' +
|
|
375
|
+
'512MB/1CPU production profile. VMs must be created with production profile and ' +
|
|
376
|
+
'provisioned first, then call with skipRecreate=true.',
|
|
377
|
+
inputSchema: z.object({
|
|
378
|
+
domain: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9.-]+$/).default(TEST_DOMAIN).describe('Test domain'),
|
|
379
|
+
skipRecreate: z
|
|
380
|
+
.coerce.boolean()
|
|
381
|
+
.default(false)
|
|
382
|
+
.describe(
|
|
383
|
+
'Skip VM recreation (use if VMs are already running with production profile)',
|
|
384
|
+
),
|
|
385
|
+
}),
|
|
386
|
+
async handler({ domain, skipRecreate } = {}) {
|
|
387
|
+
domain = domain || TEST_DOMAIN;
|
|
388
|
+
skipRecreate = skipRecreate ?? false;
|
|
389
|
+
const steps = [];
|
|
390
|
+
|
|
391
|
+
if (!skipRecreate) {
|
|
392
|
+
steps.push(
|
|
393
|
+
'Note: test_publish enforces production profile (512M/1CPU). ' +
|
|
394
|
+
'VMs should be created with vm_create({ profile: "production" }) first, ' +
|
|
395
|
+
'then fully provisioned. Pass skipRecreate=true once ready.',
|
|
396
|
+
);
|
|
397
|
+
return {
|
|
398
|
+
content: [
|
|
399
|
+
{
|
|
400
|
+
type: 'text',
|
|
401
|
+
text: JSON.stringify(
|
|
402
|
+
{
|
|
403
|
+
ok: false,
|
|
404
|
+
error:
|
|
405
|
+
'Production VMs not confirmed. Create VMs with production profile, ' +
|
|
406
|
+
'provision them, then call test_publish with skipRecreate=true.',
|
|
407
|
+
steps,
|
|
408
|
+
},
|
|
409
|
+
null,
|
|
410
|
+
2,
|
|
411
|
+
),
|
|
412
|
+
},
|
|
413
|
+
],
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Run the full orchestrator and capture markdown output
|
|
418
|
+
steps.push('Running full E2E suite via orchestrate.sh...');
|
|
419
|
+
|
|
420
|
+
const state = loadState();
|
|
421
|
+
const env = await buildTestEnv(state);
|
|
422
|
+
|
|
423
|
+
// Use the existing orchestrate.sh with --skip-create --skip-setup
|
|
424
|
+
// since VMs are already provisioned
|
|
425
|
+
try {
|
|
426
|
+
const result = await execa(
|
|
427
|
+
'bash',
|
|
428
|
+
[
|
|
429
|
+
path.join(THREE_VM_DIR, 'orchestrate.sh'),
|
|
430
|
+
'--skip-create',
|
|
431
|
+
'--skip-setup',
|
|
432
|
+
'--domain',
|
|
433
|
+
domain,
|
|
434
|
+
],
|
|
435
|
+
{
|
|
436
|
+
cwd: REPO_ROOT,
|
|
437
|
+
timeout: 600_000,
|
|
438
|
+
env: {
|
|
439
|
+
PATH: process.env.PATH,
|
|
440
|
+
HOME: process.env.HOME,
|
|
441
|
+
HOST_IP: env.HOST_IP,
|
|
442
|
+
AGENT_IP: env.AGENT_IP,
|
|
443
|
+
VISITOR_IP: env.VISITOR_IP,
|
|
444
|
+
TEST_DOMAIN: env.TEST_DOMAIN,
|
|
445
|
+
AGENT_P12_PASSWORD: state.credentials?.agentP12Password || '',
|
|
446
|
+
},
|
|
447
|
+
all: true,
|
|
448
|
+
},
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
steps.push('Full suite completed');
|
|
452
|
+
steps.push(`Logs written to ${E2E_LOGS_DIR}/`);
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
content: [
|
|
456
|
+
{
|
|
457
|
+
type: 'text',
|
|
458
|
+
text: JSON.stringify(
|
|
459
|
+
{ ok: true, logsDir: E2E_LOGS_DIR, steps },
|
|
460
|
+
null,
|
|
461
|
+
2,
|
|
462
|
+
),
|
|
463
|
+
},
|
|
464
|
+
],
|
|
465
|
+
};
|
|
466
|
+
} catch (err) {
|
|
467
|
+
const output = err.all || err.stderr || err.message;
|
|
468
|
+
steps.push('Suite had failures');
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
content: [
|
|
472
|
+
{
|
|
473
|
+
type: 'text',
|
|
474
|
+
text: JSON.stringify(
|
|
475
|
+
{
|
|
476
|
+
ok: false,
|
|
477
|
+
steps,
|
|
478
|
+
errors: extractErrors(output),
|
|
479
|
+
logsDir: E2E_LOGS_DIR,
|
|
480
|
+
},
|
|
481
|
+
null,
|
|
482
|
+
2,
|
|
483
|
+
),
|
|
484
|
+
},
|
|
485
|
+
],
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
};
|
package/src/tools/vm.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// VM Lifecycle Tools — vm_create, vm_list, vm_delete, vm_exec
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import * as mp from '../lib/multipass.js';
|
|
7
|
+
import { PROFILES, ALL_VMS, VM_NAME_MAP } from '../config.js';
|
|
8
|
+
import { setVmState, removeVmState, updateState } from '../lib/state.js';
|
|
9
|
+
|
|
10
|
+
export const vmCreateTool = {
|
|
11
|
+
name: 'vm_create',
|
|
12
|
+
description:
|
|
13
|
+
'Create E2E test VMs (host, agent, visitor). ' +
|
|
14
|
+
'Specify a profile (production/development/performance) or let env_detect recommend one. ' +
|
|
15
|
+
'Optionally create only specific VMs with the "vms" parameter.',
|
|
16
|
+
inputSchema: z.object({
|
|
17
|
+
profile: z
|
|
18
|
+
.enum(['production', 'development', 'performance'])
|
|
19
|
+
.default('development')
|
|
20
|
+
.describe('Resource profile for the VMs'),
|
|
21
|
+
vms: z
|
|
22
|
+
.array(z.enum(['host', 'agent', 'visitor']))
|
|
23
|
+
.optional()
|
|
24
|
+
.describe('Which VMs to create (default: all three)'),
|
|
25
|
+
}),
|
|
26
|
+
async handler({ profile, vms } = {}) {
|
|
27
|
+
const p = profile || 'development';
|
|
28
|
+
const specs = PROFILES[p];
|
|
29
|
+
const targets = vms ? vms.map((v) => VM_NAME_MAP[v]) : ALL_VMS;
|
|
30
|
+
|
|
31
|
+
const results = [];
|
|
32
|
+
|
|
33
|
+
// Delete existing VMs in parallel, single purge at the end
|
|
34
|
+
let needsPurge = false;
|
|
35
|
+
await Promise.all(
|
|
36
|
+
targets.map(async (name) => {
|
|
37
|
+
const existing = await mp.info(name);
|
|
38
|
+
if (existing) {
|
|
39
|
+
await mp.deleteVmNoPurge(name);
|
|
40
|
+
needsPurge = true;
|
|
41
|
+
results.push(`Deleted existing ${name}`);
|
|
42
|
+
}
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
if (needsPurge) {
|
|
46
|
+
await mp.run(['purge'], { allowFailure: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Create VMs in parallel
|
|
50
|
+
await Promise.all(
|
|
51
|
+
targets.map(async (name) => {
|
|
52
|
+
await mp.launch(name, specs);
|
|
53
|
+
const ip = await mp.getIp(name);
|
|
54
|
+
setVmState(name, { ip, profile: p, state: 'running' });
|
|
55
|
+
results.push(`Created ${name} (${ip}) — ${specs.cpus} CPU, ${specs.memory} RAM`);
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
updateState({ profile: p });
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
content: [
|
|
63
|
+
{
|
|
64
|
+
type: 'text',
|
|
65
|
+
text: JSON.stringify(
|
|
66
|
+
{ ok: true, profile: p, specs, created: results },
|
|
67
|
+
null,
|
|
68
|
+
2,
|
|
69
|
+
),
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const vmListTool = {
|
|
77
|
+
name: 'vm_list',
|
|
78
|
+
description: 'List all Multipass VMs with their state, IP, and resource profile.',
|
|
79
|
+
inputSchema: z.object({}),
|
|
80
|
+
async handler() {
|
|
81
|
+
const allVms = await mp.list();
|
|
82
|
+
const e2eVms = allVms.filter((vm) => ALL_VMS.includes(vm.name));
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
content: [
|
|
86
|
+
{
|
|
87
|
+
type: 'text',
|
|
88
|
+
text: JSON.stringify({ vms: e2eVms, total: allVms.length }, null, 2),
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const vmDeleteTool = {
|
|
96
|
+
name: 'vm_delete',
|
|
97
|
+
description:
|
|
98
|
+
'Delete E2E test VMs. Specify which VMs or delete all three.',
|
|
99
|
+
inputSchema: z.object({
|
|
100
|
+
vms: z
|
|
101
|
+
.array(z.enum(['host', 'agent', 'visitor']))
|
|
102
|
+
.optional()
|
|
103
|
+
.describe('Which VMs to delete (default: all three)'),
|
|
104
|
+
}),
|
|
105
|
+
async handler({ vms } = {}) {
|
|
106
|
+
const targets = vms ? vms.map((v) => VM_NAME_MAP[v]) : ALL_VMS;
|
|
107
|
+
|
|
108
|
+
// Delete VMs in parallel (purge runs once at the end)
|
|
109
|
+
await Promise.all(
|
|
110
|
+
targets.map(async (name) => {
|
|
111
|
+
await mp.run(['delete', name], { allowFailure: true });
|
|
112
|
+
removeVmState(name);
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
await mp.run(['purge'], { allowFailure: true });
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
content: [
|
|
119
|
+
{
|
|
120
|
+
type: 'text',
|
|
121
|
+
text: JSON.stringify(
|
|
122
|
+
{ ok: true, deleted: targets },
|
|
123
|
+
null,
|
|
124
|
+
2,
|
|
125
|
+
),
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export const vmExecTool = {
|
|
133
|
+
name: 'vm_exec',
|
|
134
|
+
description:
|
|
135
|
+
'Execute a command on a specific VM. Returns stdout, stderr, and exit code. ' +
|
|
136
|
+
'Use for debugging or ad-hoc inspection.',
|
|
137
|
+
inputSchema: z.object({
|
|
138
|
+
vm: z.enum(['host', 'agent', 'visitor']).describe('Which VM to run on'),
|
|
139
|
+
command: z.string().min(1).describe('Shell command to execute'),
|
|
140
|
+
sudo: z.coerce.boolean().default(false).describe('Run with sudo'),
|
|
141
|
+
timeout: z
|
|
142
|
+
.coerce.number()
|
|
143
|
+
.default(30000)
|
|
144
|
+
.describe('Timeout in milliseconds (default: 30s)'),
|
|
145
|
+
}),
|
|
146
|
+
async handler(params = {}) {
|
|
147
|
+
const vm = params.vm;
|
|
148
|
+
const command = String(params.command || '');
|
|
149
|
+
const sudo = params.sudo ?? false;
|
|
150
|
+
const timeout = params.timeout || 30000;
|
|
151
|
+
const vmName = VM_NAME_MAP[vm];
|
|
152
|
+
|
|
153
|
+
if (!vmName) {
|
|
154
|
+
return {
|
|
155
|
+
content: [{
|
|
156
|
+
type: 'text',
|
|
157
|
+
text: JSON.stringify({ error: `Unknown VM "${vm}"` }, null, 2),
|
|
158
|
+
}],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const result = await mp.exec(vmName, command, {
|
|
163
|
+
sudo,
|
|
164
|
+
timeout,
|
|
165
|
+
allowFailure: true,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
content: [
|
|
170
|
+
{
|
|
171
|
+
type: 'text',
|
|
172
|
+
text: JSON.stringify(
|
|
173
|
+
{
|
|
174
|
+
vm: vmName,
|
|
175
|
+
exitCode: result.exitCode,
|
|
176
|
+
stdout: result.stdout,
|
|
177
|
+
stderr: result.stderr,
|
|
178
|
+
},
|
|
179
|
+
null,
|
|
180
|
+
2,
|
|
181
|
+
),
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
};
|