neoagent 2.3.1-beta.84 → 2.3.1-beta.85
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/package.json +1 -1
- package/runtime/paths.js +6 -6
- package/server/guest-agent.package.json +2 -3
- package/server/guest_agent.js +0 -7
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +4 -4
- package/server/services/android/controller.js +2 -2
- package/server/services/browser/controller.js +207 -45
- package/server/services/runtime/backends/local-vm.js +46 -30
- package/server/services/runtime/guest_bootstrap.js +109 -103
- package/server/services/runtime/qemu.js +343 -77
- package/server/services/runtime/validation.js +1 -27
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
|
+
const crypto = require('crypto');
|
|
2
3
|
const http = require('http');
|
|
3
4
|
const https = require('https');
|
|
4
5
|
const net = require('net');
|
|
@@ -10,21 +11,16 @@ const { ensureGuestBootstrapSeed } = require('./guest_bootstrap');
|
|
|
10
11
|
|
|
11
12
|
const VM_ROOT = path.join(DATA_DIR, 'runtime-vms');
|
|
12
13
|
const BASE_IMAGE_CACHE_ROOT = path.join(VM_ROOT, 'base-images');
|
|
13
|
-
const
|
|
14
|
-
const HOST_SHARE_ROOT = path.join(VM_ROOT, 'host-share');
|
|
14
|
+
const TEMPLATE_ROOT = path.join(VM_ROOT, 'templates');
|
|
15
15
|
fs.mkdirSync(VM_ROOT, { recursive: true });
|
|
16
16
|
fs.mkdirSync(BASE_IMAGE_CACHE_ROOT, { recursive: true });
|
|
17
|
+
fs.mkdirSync(TEMPLATE_ROOT, { recursive: true });
|
|
17
18
|
|
|
18
19
|
const DEFAULT_UBUNTU_BASE_IMAGE_URLS = Object.freeze({
|
|
19
20
|
x64: 'https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img',
|
|
20
21
|
arm64: 'https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-arm64.img',
|
|
21
22
|
});
|
|
22
23
|
|
|
23
|
-
const HOST_SHARE_LINKS = [
|
|
24
|
-
{ name: 'server', source: path.join(REPO_ROOT, 'server') },
|
|
25
|
-
{ name: 'runtime', source: path.join(REPO_ROOT, 'runtime') },
|
|
26
|
-
];
|
|
27
|
-
|
|
28
24
|
const QEMU_SHARE_ROOT_CANDIDATES = [
|
|
29
25
|
path.resolve(process.execPath, '..', '..', 'share', 'qemu'),
|
|
30
26
|
path.resolve(process.execPath, '..', '..', '..', 'share', 'qemu'),
|
|
@@ -34,10 +30,7 @@ const QEMU_SHARE_ROOT_CANDIDATES = [
|
|
|
34
30
|
];
|
|
35
31
|
|
|
36
32
|
function guestArchForHost() {
|
|
37
|
-
|
|
38
|
-
return process.arch;
|
|
39
|
-
}
|
|
40
|
-
return 'x64';
|
|
33
|
+
return process.arch === 'arm64' ? 'arm64' : 'x64';
|
|
41
34
|
}
|
|
42
35
|
|
|
43
36
|
function resolveQemuBinary({ arch = guestArchForHost(), platform = process.platform } = {}) {
|
|
@@ -70,6 +63,26 @@ function isHttpUrl(value) {
|
|
|
70
63
|
return /^https?:\/\//i.test(candidate);
|
|
71
64
|
}
|
|
72
65
|
|
|
66
|
+
function generateGuestToken() {
|
|
67
|
+
return crypto.randomBytes(32).toString('hex');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolveGuestToken(userRoot) {
|
|
71
|
+
const tokenPath = path.join(userRoot, 'guest-token.txt');
|
|
72
|
+
try {
|
|
73
|
+
const existing = String(fs.readFileSync(tokenPath, 'utf8') || '').trim();
|
|
74
|
+
if (existing.length >= 32) {
|
|
75
|
+
return existing;
|
|
76
|
+
}
|
|
77
|
+
} catch {}
|
|
78
|
+
|
|
79
|
+
const candidate = String(process.env.NEOAGENT_VM_GUEST_TOKEN || '').trim();
|
|
80
|
+
const token = candidate.length >= 32 ? candidate : generateGuestToken();
|
|
81
|
+
fs.mkdirSync(path.dirname(tokenPath), { recursive: true });
|
|
82
|
+
fs.writeFileSync(tokenPath, `${token}\n`, { mode: 0o600 });
|
|
83
|
+
return token;
|
|
84
|
+
}
|
|
85
|
+
|
|
73
86
|
function downloadFile(sourceUrl, destinationPath, redirectCount = 0) {
|
|
74
87
|
return new Promise((resolve, reject) => {
|
|
75
88
|
if (redirectCount > 5) {
|
|
@@ -167,37 +180,6 @@ function resolveQemuShareRoot() {
|
|
|
167
180
|
return null;
|
|
168
181
|
}
|
|
169
182
|
|
|
170
|
-
function ensureHostShareRoot() {
|
|
171
|
-
fs.mkdirSync(HOST_SHARE_ROOT, { recursive: true });
|
|
172
|
-
|
|
173
|
-
for (const entry of HOST_SHARE_LINKS) {
|
|
174
|
-
const sourcePath = path.resolve(entry.source);
|
|
175
|
-
if (!fs.existsSync(sourcePath)) {
|
|
176
|
-
throw new Error(`Host share source is missing: ${sourcePath}`);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const linkPath = path.join(HOST_SHARE_ROOT, entry.name);
|
|
180
|
-
let needsLink = true;
|
|
181
|
-
if (fs.existsSync(linkPath)) {
|
|
182
|
-
try {
|
|
183
|
-
const resolved = fs.realpathSync.native ? fs.realpathSync.native(linkPath) : fs.realpathSync(linkPath);
|
|
184
|
-
needsLink = resolved !== sourcePath;
|
|
185
|
-
} catch {
|
|
186
|
-
needsLink = true;
|
|
187
|
-
}
|
|
188
|
-
if (needsLink) {
|
|
189
|
-
fs.rmSync(linkPath, { recursive: true, force: true });
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (needsLink) {
|
|
194
|
-
fs.symlinkSync(sourcePath, linkPath, process.platform === 'win32' ? 'junction' : 'dir');
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
return HOST_SHARE_ROOT;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
183
|
function resolveAarch64FirmwarePaths() {
|
|
202
184
|
const shareRoot = resolveQemuShareRoot();
|
|
203
185
|
if (!shareRoot) {
|
|
@@ -261,8 +243,6 @@ function buildQemuArgs({
|
|
|
261
243
|
cpus = 2,
|
|
262
244
|
arch = guestArchForHost(),
|
|
263
245
|
platform = process.platform,
|
|
264
|
-
hostShareRoot = null,
|
|
265
|
-
hostDataRoot = null,
|
|
266
246
|
seedPath = null,
|
|
267
247
|
seedIsRaw = false,
|
|
268
248
|
consoleLogPath = null,
|
|
@@ -281,42 +261,29 @@ function buildQemuArgs({
|
|
|
281
261
|
} else {
|
|
282
262
|
args.push('-machine', `q35,accel=${accel}`);
|
|
283
263
|
if (platform !== 'win32') {
|
|
284
|
-
args.push('-cpu', process.arch === arch ? 'host' : '
|
|
264
|
+
args.push('-cpu', process.arch === arch ? 'host' : 'qemu64');
|
|
285
265
|
}
|
|
286
266
|
}
|
|
287
267
|
|
|
268
|
+
const isMmio = arch === 'arm64';
|
|
269
|
+
const blkDev = isMmio ? 'virtio-blk-device' : 'virtio-blk-pci';
|
|
270
|
+
const netDev = isMmio ? 'virtio-net-device' : 'virtio-net-pci';
|
|
271
|
+
const p9Dev = isMmio ? 'virtio-9p-device' : 'virtio-9p-pci';
|
|
272
|
+
|
|
288
273
|
// OS disk — always first boot candidate
|
|
289
274
|
args.push(
|
|
290
275
|
'-drive', `if=none,id=os,file=${imagePath},format=qcow2`,
|
|
291
|
-
'-device',
|
|
276
|
+
'-device', `${blkDev},drive=os,bootindex=1`,
|
|
292
277
|
'-netdev', `user,id=net0,hostfwd=tcp:127.0.0.1:${sshPort}-:22,hostfwd=tcp:127.0.0.1:${agentPort}-:8421`,
|
|
293
|
-
'-device',
|
|
278
|
+
'-device', `${netDev},netdev=net0`,
|
|
294
279
|
);
|
|
295
280
|
|
|
296
|
-
if (hostShareRoot) {
|
|
297
|
-
const id = 'fsdev-host';
|
|
298
|
-
const deviceType = arch === 'arm64' ? 'virtio-9p-pci,disable-legacy=on,disable-modern=off,romfile=' : 'virtio-9p-pci';
|
|
299
|
-
args.push(
|
|
300
|
-
'-fsdev', `local,path=${hostShareRoot},id=${id},security_model=none,readonly=on`,
|
|
301
|
-
'-device', `${deviceType},fsdev=${id},mount_tag=neoagent-host`,
|
|
302
|
-
);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
if (hostDataRoot) {
|
|
306
|
-
const id = 'fsdev-data';
|
|
307
|
-
const deviceType = arch === 'arm64' ? 'virtio-9p-pci,disable-legacy=on,disable-modern=off,romfile=' : 'virtio-9p-pci';
|
|
308
|
-
args.push(
|
|
309
|
-
'-fsdev', `local,path=${hostDataRoot},id=${id},security_model=none`,
|
|
310
|
-
'-device', `${deviceType},fsdev=${id},mount_tag=neoagent-data`,
|
|
311
|
-
);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
281
|
if (seedPath) {
|
|
315
282
|
if (seedIsRaw) {
|
|
316
283
|
// Raw FAT image — attach as a plain virtio block device
|
|
317
284
|
args.push(
|
|
318
285
|
'-drive', `if=none,id=cidata,file=${seedPath},format=raw,readonly=on`,
|
|
319
|
-
'-device',
|
|
286
|
+
'-device', `${blkDev},drive=cidata`,
|
|
320
287
|
);
|
|
321
288
|
} else if (arch === 'arm64') {
|
|
322
289
|
// ARM virt machine has no IDE controller; use virtio-scsi for the seed ISO
|
|
@@ -388,6 +355,135 @@ function allocatePort() {
|
|
|
388
355
|
});
|
|
389
356
|
}
|
|
390
357
|
|
|
358
|
+
function sleep(ms) {
|
|
359
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function waitForPath(targetPath, timeoutMs, intervalMs = 1000) {
|
|
363
|
+
const startedAt = Date.now();
|
|
364
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
365
|
+
if (fs.existsSync(targetPath)) {
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
await sleep(intervalMs);
|
|
369
|
+
}
|
|
370
|
+
return fs.existsSync(targetPath);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function writeLockMetadata(lockDir) {
|
|
374
|
+
try {
|
|
375
|
+
fs.writeFileSync(
|
|
376
|
+
path.join(lockDir, 'owner.json'),
|
|
377
|
+
JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2),
|
|
378
|
+
'utf8',
|
|
379
|
+
);
|
|
380
|
+
} catch {}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function readLockMetadata(lockDir) {
|
|
384
|
+
try {
|
|
385
|
+
return JSON.parse(fs.readFileSync(path.join(lockDir, 'owner.json'), 'utf8'));
|
|
386
|
+
} catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function isPidAlive(pid) {
|
|
392
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
process.kill(pid, 0);
|
|
397
|
+
return true;
|
|
398
|
+
} catch {
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function requestGuestAgent(baseUrl, token, pathname, body, options = {}) {
|
|
404
|
+
const controller = new AbortController();
|
|
405
|
+
const timeoutMs = Math.max(1000, Number(options.timeoutMs || 5000));
|
|
406
|
+
const timer = setTimeout(() => {
|
|
407
|
+
controller.abort(new Error(`Request timed out after ${timeoutMs} ms.`));
|
|
408
|
+
}, timeoutMs);
|
|
409
|
+
try {
|
|
410
|
+
const response = await fetch(`${String(baseUrl || '').replace(/\/+$/, '')}${pathname}`, {
|
|
411
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
412
|
+
headers: {
|
|
413
|
+
'content-type': 'application/json',
|
|
414
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
415
|
+
},
|
|
416
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
417
|
+
signal: controller.signal,
|
|
418
|
+
});
|
|
419
|
+
const contentType = response.headers.get('content-type') || '';
|
|
420
|
+
const payload = contentType.includes('application/json')
|
|
421
|
+
? await response.json().catch(() => ({}))
|
|
422
|
+
: { text: await response.text().catch(() => '') };
|
|
423
|
+
if (!response.ok) {
|
|
424
|
+
const detail = payload?.error || payload?.text || `Runtime request failed: ${response.status}`;
|
|
425
|
+
throw new Error(detail);
|
|
426
|
+
}
|
|
427
|
+
return payload;
|
|
428
|
+
} finally {
|
|
429
|
+
clearTimeout(timer);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function waitForGuestAgentHealth(baseUrl, token, options = {}) {
|
|
434
|
+
const timeoutMs = Math.max(1000, Number(options.timeoutMs || 5 * 60 * 1000));
|
|
435
|
+
const intervalMs = Math.max(250, Number(options.intervalMs || 1000));
|
|
436
|
+
const checkLiveness = options.checkLiveness || (() => true);
|
|
437
|
+
const startedAt = Date.now();
|
|
438
|
+
let lastError = null;
|
|
439
|
+
|
|
440
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
441
|
+
if (!checkLiveness()) {
|
|
442
|
+
throw new Error('Guest runtime process exited unexpectedly during bootstrap.');
|
|
443
|
+
}
|
|
444
|
+
try {
|
|
445
|
+
const health = await requestGuestAgent(baseUrl, token, '/health', undefined, { timeoutMs: 2000 });
|
|
446
|
+
if (health?.status === 'ok') {
|
|
447
|
+
return health;
|
|
448
|
+
}
|
|
449
|
+
lastError = new Error('Guest agent health check returned a non-ok status.');
|
|
450
|
+
} catch (error) {
|
|
451
|
+
lastError = error;
|
|
452
|
+
}
|
|
453
|
+
await sleep(intervalMs);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
throw new Error(`Timed out waiting for guest agent health: ${lastError?.message || 'unknown error'}`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function waitForGuestMarker(baseUrl, token, markerPath, options = {}) {
|
|
460
|
+
const timeoutMs = Math.max(1000, Number(options.timeoutMs || 15 * 60 * 1000));
|
|
461
|
+
const intervalMs = Math.max(250, Number(options.intervalMs || 2000));
|
|
462
|
+
const checkLiveness = options.checkLiveness || (() => true);
|
|
463
|
+
const startedAt = Date.now();
|
|
464
|
+
let lastError = null;
|
|
465
|
+
|
|
466
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
467
|
+
if (!checkLiveness()) {
|
|
468
|
+
throw new Error('Guest runtime process exited unexpectedly while waiting for guest marker.');
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
const result = await requestGuestAgent(baseUrl, token, '/exec', {
|
|
472
|
+
command: `test -f ${JSON.stringify(String(markerPath || ''))} && printf ready || printf pending`,
|
|
473
|
+
timeout: 15000,
|
|
474
|
+
}, { timeoutMs: 20000 });
|
|
475
|
+
if (String(result?.stdout || '').trim() === 'ready') {
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
} catch (error) {
|
|
479
|
+
lastError = error;
|
|
480
|
+
}
|
|
481
|
+
await sleep(intervalMs);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
throw new Error(`Timed out waiting for guest marker ${markerPath}: ${lastError?.message || 'unknown error'}`);
|
|
485
|
+
}
|
|
486
|
+
|
|
391
487
|
function ensureUserVmDisk(userRoot, baseImagePath) {
|
|
392
488
|
fs.mkdirSync(userRoot, { recursive: true });
|
|
393
489
|
const diskPath = path.join(userRoot, 'disk.qcow2');
|
|
@@ -468,7 +564,13 @@ class QemuVmManager {
|
|
|
468
564
|
this.cpus = Number(options.cpus || process.env.NEOAGENT_VM_CPUS || 2);
|
|
469
565
|
this.instances = new Map();
|
|
470
566
|
this.baseImagePromise = null;
|
|
567
|
+
this.runtimeTemplatePromise = null;
|
|
471
568
|
fs.mkdirSync(this.rootDir, { recursive: true });
|
|
569
|
+
setTimeout(() => {
|
|
570
|
+
this.ensureRuntimeTemplateAvailable().catch((error) => {
|
|
571
|
+
console.warn(`[VM] Background runtime template warmup failed: ${error.message}`);
|
|
572
|
+
});
|
|
573
|
+
}, 0);
|
|
472
574
|
}
|
|
473
575
|
|
|
474
576
|
getBaseImageCachePath() {
|
|
@@ -516,6 +618,174 @@ class QemuVmManager {
|
|
|
516
618
|
return this.baseImagePromise;
|
|
517
619
|
}
|
|
518
620
|
|
|
621
|
+
getRuntimeTemplateRoot() {
|
|
622
|
+
return path.join(TEMPLATE_ROOT, this.guestArch);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
getRuntimeTemplateDiskPath() {
|
|
626
|
+
return path.join(this.getRuntimeTemplateRoot(), 'disk.qcow2');
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
getRuntimeTemplateLockDir() {
|
|
630
|
+
return `${this.getRuntimeTemplateRoot()}.lock`;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
getRuntimeTemplateReadyMarker() {
|
|
634
|
+
return '/var/lib/neoagent/browser-runtime-ready';
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async ensureRuntimeTemplateAvailable() {
|
|
638
|
+
const readyDiskPath = this.getRuntimeTemplateDiskPath();
|
|
639
|
+
const readySentinelPath = path.join(this.getRuntimeTemplateRoot(), '.runtime-template-ready');
|
|
640
|
+
if (fs.existsSync(readyDiskPath) && fs.existsSync(readySentinelPath)) {
|
|
641
|
+
return readyDiskPath;
|
|
642
|
+
}
|
|
643
|
+
if (!this.runtimeTemplatePromise) {
|
|
644
|
+
this.runtimeTemplatePromise = this.#ensureRuntimeTemplateAvailableWithLock().finally(() => {
|
|
645
|
+
this.runtimeTemplatePromise = null;
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
return this.runtimeTemplatePromise;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async #ensureRuntimeTemplateAvailableWithLock() {
|
|
652
|
+
const readyDiskPath = this.getRuntimeTemplateDiskPath();
|
|
653
|
+
const readySentinelPath = path.join(this.getRuntimeTemplateRoot(), '.runtime-template-ready');
|
|
654
|
+
const lockDir = this.getRuntimeTemplateLockDir();
|
|
655
|
+
const acquireStartedAt = Date.now();
|
|
656
|
+
|
|
657
|
+
while (true) {
|
|
658
|
+
if (fs.existsSync(readyDiskPath) && fs.existsSync(readySentinelPath)) {
|
|
659
|
+
return readyDiskPath;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
try {
|
|
663
|
+
fs.mkdirSync(lockDir, { recursive: false });
|
|
664
|
+
writeLockMetadata(lockDir);
|
|
665
|
+
try {
|
|
666
|
+
if (fs.existsSync(readyDiskPath) && fs.existsSync(readySentinelPath)) {
|
|
667
|
+
return readyDiskPath;
|
|
668
|
+
}
|
|
669
|
+
return await this.#buildRuntimeTemplate();
|
|
670
|
+
} finally {
|
|
671
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
672
|
+
}
|
|
673
|
+
} catch (error) {
|
|
674
|
+
if (error?.code !== 'EEXIST') {
|
|
675
|
+
throw error;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const lockStats = fs.existsSync(lockDir) ? fs.statSync(lockDir) : null;
|
|
680
|
+
const lockMetadata = readLockMetadata(lockDir);
|
|
681
|
+
const lockAgeMs = lockStats ? Date.now() - lockStats.mtimeMs : 0;
|
|
682
|
+
const staleLock = lockAgeMs > 45 * 60 * 1000 || (lockMetadata?.pid && !isPidAlive(lockMetadata.pid));
|
|
683
|
+
if (staleLock) {
|
|
684
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (Date.now() - acquireStartedAt > 30 * 60 * 1000) {
|
|
689
|
+
throw new Error('Timed out waiting for the shared runtime template build lock.');
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
await sleep(2000);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async #buildRuntimeTemplate() {
|
|
697
|
+
const templateRoot = this.getRuntimeTemplateRoot();
|
|
698
|
+
const templateDiskPath = this.getRuntimeTemplateDiskPath();
|
|
699
|
+
const readyMarkerPath = this.getRuntimeTemplateReadyMarker();
|
|
700
|
+
const readySentinelPath = path.join(templateRoot, '.runtime-template-ready');
|
|
701
|
+
|
|
702
|
+
fs.rmSync(templateRoot, { recursive: true, force: true });
|
|
703
|
+
fs.mkdirSync(templateRoot, { recursive: true });
|
|
704
|
+
|
|
705
|
+
const baseImagePath = await this.ensureBaseImageAvailable();
|
|
706
|
+
const diskPath = ensureUserVmDisk(templateRoot, baseImagePath);
|
|
707
|
+
const guestToken = resolveGuestToken(templateRoot);
|
|
708
|
+
const bootstrap = ensureGuestBootstrapSeed({
|
|
709
|
+
userRoot: templateRoot,
|
|
710
|
+
guestToken,
|
|
711
|
+
guestArch: this.guestArch,
|
|
712
|
+
});
|
|
713
|
+
const consoleLogPath = path.join(templateRoot, 'console.log');
|
|
714
|
+
const firmware = this.guestArch === 'arm64'
|
|
715
|
+
? resolveAarch64FirmwarePaths()
|
|
716
|
+
: resolveX86_64FirmwarePaths();
|
|
717
|
+
const firmwareVarsPath = firmware ? path.join(templateRoot, 'uefi-vars.fd') : null;
|
|
718
|
+
if (firmware && !fs.existsSync(firmwareVarsPath)) {
|
|
719
|
+
fs.copyFileSync(firmware.varsTemplatePath, firmwareVarsPath);
|
|
720
|
+
}
|
|
721
|
+
const agentPort = await allocatePort();
|
|
722
|
+
const sshPort = await allocatePort();
|
|
723
|
+
const qemuBinary = resolveQemuBinary({ arch: this.guestArch });
|
|
724
|
+
const qemuBinaryPath = resolveCommandPath(qemuBinary) || qemuBinary;
|
|
725
|
+
const args = buildQemuArgs({
|
|
726
|
+
imagePath: diskPath,
|
|
727
|
+
sshPort,
|
|
728
|
+
agentPort,
|
|
729
|
+
memoryMb: this.memoryMb,
|
|
730
|
+
cpus: this.cpus,
|
|
731
|
+
arch: this.guestArch,
|
|
732
|
+
seedPath: bootstrap.seedImagePath || bootstrap.isoPath,
|
|
733
|
+
seedIsRaw: Boolean(bootstrap.seedImagePath && bootstrap.seedImagePath.endsWith('.img')),
|
|
734
|
+
consoleLogPath,
|
|
735
|
+
firmwareCodePath: firmware?.codePath || null,
|
|
736
|
+
firmwareVarsPath,
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
console.log(`[VM] Building runtime template for ${this.guestArch}: ${qemuBinaryPath} ${args.join(' ')}`);
|
|
740
|
+
const child = spawn(qemuBinaryPath, args, {
|
|
741
|
+
cwd: templateRoot,
|
|
742
|
+
detached: process.platform !== 'win32',
|
|
743
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
let stderrText = '';
|
|
747
|
+
child.stderr.on('data', (chunk) => {
|
|
748
|
+
stderrText += chunk.toString('utf8');
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
const baseUrl = `http://127.0.0.1:${agentPort}`;
|
|
752
|
+
const checkLiveness = () => !child.killed && child.exitCode === null;
|
|
753
|
+
try {
|
|
754
|
+
await waitForGuestAgentHealth(baseUrl, guestToken, {
|
|
755
|
+
timeoutMs: 30 * 60 * 1000,
|
|
756
|
+
intervalMs: 1000,
|
|
757
|
+
checkLiveness,
|
|
758
|
+
});
|
|
759
|
+
await waitForGuestMarker(baseUrl, guestToken, readyMarkerPath, {
|
|
760
|
+
timeoutMs: 45 * 60 * 1000,
|
|
761
|
+
intervalMs: 2000,
|
|
762
|
+
checkLiveness,
|
|
763
|
+
});
|
|
764
|
+
fs.writeFileSync(readySentinelPath, `${new Date().toISOString()}\n`, 'utf8');
|
|
765
|
+
} finally {
|
|
766
|
+
try {
|
|
767
|
+
if (process.platform === 'win32') {
|
|
768
|
+
spawnSync('taskkill', ['/F', '/T', '/PID', child.pid]);
|
|
769
|
+
} else {
|
|
770
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
771
|
+
}
|
|
772
|
+
} catch {
|
|
773
|
+
try {
|
|
774
|
+
child.kill('SIGKILL');
|
|
775
|
+
} catch {}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (!fs.existsSync(templateDiskPath)) {
|
|
780
|
+
throw new Error('Runtime template disk was not created.');
|
|
781
|
+
}
|
|
782
|
+
return templateDiskPath;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async ensureRuntimeImageAvailable() {
|
|
786
|
+
return this.ensureRuntimeTemplateAvailable();
|
|
787
|
+
}
|
|
788
|
+
|
|
519
789
|
isConfigured() {
|
|
520
790
|
return this.getReadiness().ready;
|
|
521
791
|
}
|
|
@@ -564,18 +834,14 @@ class QemuVmManager {
|
|
|
564
834
|
}
|
|
565
835
|
|
|
566
836
|
const userRoot = path.join(this.rootDir, key, this.guestArch);
|
|
567
|
-
const baseImagePath = await this.
|
|
837
|
+
const baseImagePath = await this.ensureRuntimeImageAvailable();
|
|
568
838
|
const diskPath = ensureUserVmDisk(userRoot, baseImagePath);
|
|
569
|
-
const guestToken =
|
|
570
|
-
if (!guestToken) {
|
|
571
|
-
throw new Error('NEOAGENT_VM_GUEST_TOKEN is required to bootstrap the guest runtime.');
|
|
572
|
-
}
|
|
839
|
+
const guestToken = resolveGuestToken(userRoot);
|
|
573
840
|
const bootstrap = ensureGuestBootstrapSeed({
|
|
574
841
|
userRoot,
|
|
575
842
|
guestToken,
|
|
576
843
|
guestArch: this.guestArch,
|
|
577
844
|
});
|
|
578
|
-
const guestDataRoot = path.join(userRoot, 'guest-data');
|
|
579
845
|
const consoleLogPath = path.join(userRoot, 'console.log');
|
|
580
846
|
const firmware = this.guestArch === 'arm64'
|
|
581
847
|
? resolveAarch64FirmwarePaths()
|
|
@@ -597,12 +863,10 @@ class QemuVmManager {
|
|
|
597
863
|
throw new Error(`Failed to copy firmware vars template: ${detail}`);
|
|
598
864
|
}
|
|
599
865
|
}
|
|
600
|
-
fs.mkdirSync(guestDataRoot, { recursive: true });
|
|
601
866
|
const agentPort = await allocatePort();
|
|
602
867
|
const sshPort = await allocatePort();
|
|
603
868
|
const qemuBinary = resolveQemuBinary({ arch: this.guestArch });
|
|
604
869
|
const qemuBinaryPath = resolveCommandPath(qemuBinary) || qemuBinary;
|
|
605
|
-
const hostShareRoot = ensureHostShareRoot();
|
|
606
870
|
const args = buildQemuArgs({
|
|
607
871
|
imagePath: diskPath,
|
|
608
872
|
sshPort,
|
|
@@ -610,8 +874,6 @@ class QemuVmManager {
|
|
|
610
874
|
memoryMb: this.memoryMb,
|
|
611
875
|
cpus: this.cpus,
|
|
612
876
|
arch: this.guestArch,
|
|
613
|
-
hostShareRoot,
|
|
614
|
-
hostDataRoot: guestDataRoot,
|
|
615
877
|
seedPath: bootstrap.seedImagePath || bootstrap.isoPath,
|
|
616
878
|
seedIsRaw: Boolean(bootstrap.seedImagePath && bootstrap.seedImagePath.endsWith('.img')),
|
|
617
879
|
consoleLogPath,
|
|
@@ -656,7 +918,10 @@ class QemuVmManager {
|
|
|
656
918
|
}
|
|
657
919
|
});
|
|
658
920
|
child.on('exit', () => {
|
|
659
|
-
this.instances.
|
|
921
|
+
const current = this.instances.get(key);
|
|
922
|
+
if (current?.process === child) {
|
|
923
|
+
this.instances.delete(key);
|
|
924
|
+
}
|
|
660
925
|
});
|
|
661
926
|
|
|
662
927
|
const session = {
|
|
@@ -667,6 +932,7 @@ class QemuVmManager {
|
|
|
667
932
|
guestArch: this.guestArch,
|
|
668
933
|
userRoot,
|
|
669
934
|
diskPath,
|
|
935
|
+
guestToken,
|
|
670
936
|
agentPort,
|
|
671
937
|
sshPort,
|
|
672
938
|
baseUrl: `http://127.0.0.1:${agentPort}`,
|
|
@@ -2,32 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
const { getDeploymentPolicy } = require('../../utils/deployment');
|
|
4
4
|
|
|
5
|
-
function validateGuestToken(token) {
|
|
6
|
-
const value = String(token || '').trim();
|
|
7
|
-
if (!value) {
|
|
8
|
-
return { valid: false, reason: 'NEOAGENT_VM_GUEST_TOKEN is missing.' };
|
|
9
|
-
}
|
|
10
|
-
if (value.length < 32) {
|
|
11
|
-
return { valid: false, reason: 'NEOAGENT_VM_GUEST_TOKEN must be at least 32 characters long.' };
|
|
12
|
-
}
|
|
13
|
-
if (/^(change|replace|set|your|example|sample|placeholder|token|secret)[-_a-z0-9]*$/i.test(value)) {
|
|
14
|
-
return { valid: false, reason: 'NEOAGENT_VM_GUEST_TOKEN looks like a placeholder value.' };
|
|
15
|
-
}
|
|
16
|
-
if (/change-this-guest-token-before-prod/i.test(value)) {
|
|
17
|
-
return { valid: false, reason: 'NEOAGENT_VM_GUEST_TOKEN is using the insecure example placeholder.' };
|
|
18
|
-
}
|
|
19
|
-
if (/^(.)\1+$/.test(value)) {
|
|
20
|
-
return { valid: false, reason: 'NEOAGENT_VM_GUEST_TOKEN must not be a repeated single character.' };
|
|
21
|
-
}
|
|
22
|
-
return { valid: true, reason: null };
|
|
23
|
-
}
|
|
24
|
-
|
|
25
5
|
function getRuntimeValidation(runtimeManager) {
|
|
26
6
|
const policy = getDeploymentPolicy();
|
|
27
7
|
const nodeEnvIsProd = String(process.env.NODE_ENV || '').trim().toLowerCase() === 'prod';
|
|
28
8
|
const vmReadiness = runtimeManager?.vmBackend?.vmManager?.getReadiness?.() || null;
|
|
29
|
-
const guestToken = String(runtimeManager?.vmBackend?.token || process.env.NEOAGENT_VM_GUEST_TOKEN || '').trim();
|
|
30
|
-
const guestTokenValidation = validateGuestToken(guestToken);
|
|
31
9
|
const issues = [];
|
|
32
10
|
|
|
33
11
|
if (policy.profile === 'prod' || nodeEnvIsProd) {
|
|
@@ -43,16 +21,13 @@ function getRuntimeValidation(runtimeManager) {
|
|
|
43
21
|
}
|
|
44
22
|
}
|
|
45
23
|
}
|
|
46
|
-
if (!guestTokenValidation.valid) {
|
|
47
|
-
issues.push(`prod profile requires a secure NEOAGENT_VM_GUEST_TOKEN. ${guestTokenValidation.reason}`);
|
|
48
|
-
}
|
|
49
24
|
}
|
|
50
25
|
|
|
51
26
|
return {
|
|
52
27
|
ready: issues.length === 0,
|
|
53
28
|
issues,
|
|
54
29
|
vm: vmReadiness,
|
|
55
|
-
guestTokenConfigured:
|
|
30
|
+
guestTokenConfigured: true,
|
|
56
31
|
policy,
|
|
57
32
|
};
|
|
58
33
|
}
|
|
@@ -68,5 +43,4 @@ function assertRuntimeValidation(runtimeManager) {
|
|
|
68
43
|
module.exports = {
|
|
69
44
|
assertRuntimeValidation,
|
|
70
45
|
getRuntimeValidation,
|
|
71
|
-
validateGuestToken,
|
|
72
46
|
};
|