neoagent 2.3.1-beta.70 → 2.3.1-beta.72

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.
@@ -6,9 +6,12 @@ const path = require('path');
6
6
  const { StringDecoder } = require('string_decoder');
7
7
  const { spawn, spawnSync } = require('child_process');
8
8
  const { DATA_DIR } = require('../../../runtime/paths');
9
+ const { ensureGuestBootstrapSeed } = require('./guest_bootstrap');
9
10
 
10
11
  const VM_ROOT = path.join(DATA_DIR, 'runtime-vms');
11
12
  const BASE_IMAGE_CACHE_ROOT = path.join(VM_ROOT, 'base-images');
13
+ const REPO_ROOT = path.resolve(__dirname, '../../../');
14
+ const HOST_SHARE_ROOT = path.join(VM_ROOT, 'host-share');
12
15
  fs.mkdirSync(VM_ROOT, { recursive: true });
13
16
  fs.mkdirSync(BASE_IMAGE_CACHE_ROOT, { recursive: true });
14
17
 
@@ -17,8 +20,21 @@ const DEFAULT_UBUNTU_BASE_IMAGE_URLS = Object.freeze({
17
20
  arm64: 'https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-arm64.img',
18
21
  });
19
22
 
20
- function guestArchForHost(hostArch = process.arch) {
21
- return hostArch === 'arm64' ? 'arm64' : 'x64';
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
+ const QEMU_SHARE_ROOT_CANDIDATES = [
29
+ path.resolve(process.execPath, '..', '..', 'share', 'qemu'),
30
+ path.resolve(process.execPath, '..', '..', '..', 'share', 'qemu'),
31
+ '/opt/homebrew/share/qemu',
32
+ '/usr/local/share/qemu',
33
+ '/usr/share/qemu',
34
+ ];
35
+
36
+ function guestArchForHost() {
37
+ return 'x64';
22
38
  }
23
39
 
24
40
  function resolveQemuBinary({ arch = guestArchForHost(), platform = process.platform } = {}) {
@@ -32,6 +48,20 @@ function defaultBaseImageUrlForArch(arch = guestArchForHost()) {
32
48
  return DEFAULT_UBUNTU_BASE_IMAGE_URLS[arch] || DEFAULT_UBUNTU_BASE_IMAGE_URLS.x64;
33
49
  }
34
50
 
51
+ function normalizeBaseImageUrlForArch(baseImageUrl, arch = guestArchForHost()) {
52
+ const candidate = String(baseImageUrl || '').trim();
53
+ if (!candidate) {
54
+ return defaultBaseImageUrlForArch(arch);
55
+ }
56
+ if (arch === 'x64' && /arm64|aarch64/i.test(candidate)) {
57
+ return defaultBaseImageUrlForArch('x64');
58
+ }
59
+ if (arch === 'arm64' && /amd64|x86_64/i.test(candidate)) {
60
+ return defaultBaseImageUrlForArch('arm64');
61
+ }
62
+ return candidate;
63
+ }
64
+
35
65
  function isHttpUrl(value) {
36
66
  const candidate = String(value || '').trim();
37
67
  return /^https?:\/\//i.test(candidate);
@@ -103,7 +133,7 @@ function downloadFile(sourceUrl, destinationPath, redirectCount = 0) {
103
133
  });
104
134
  });
105
135
 
106
- request.setTimeout(30000, () => {
136
+ request.setTimeout(15 * 60 * 1000, () => {
107
137
  request.destroy(new Error('Timed out while downloading VM base image.'));
108
138
  });
109
139
  request.on('timeout', () => {
@@ -118,13 +148,108 @@ function downloadFile(sourceUrl, destinationPath, redirectCount = 0) {
118
148
  });
119
149
  }
120
150
 
121
- function resolveAcceleration({ platform = process.platform } = {}) {
122
- if (platform === 'linux') return 'kvm';
123
- if (platform === 'darwin') return 'hvf';
124
- if (platform === 'win32') return 'whpx';
151
+ function resolveAcceleration({ platform = process.platform, arch = guestArchForHost() } = {}) {
152
+ if (platform === 'linux') return arch === process.arch ? 'kvm' : 'tcg';
153
+ if (platform === 'darwin') return arch === process.arch ? 'hvf' : 'tcg';
154
+ if (platform === 'win32') return arch === process.arch ? 'whpx' : 'tcg';
125
155
  return 'tcg';
126
156
  }
127
157
 
158
+ function resolveQemuShareRoot() {
159
+ for (const candidate of QEMU_SHARE_ROOT_CANDIDATES) {
160
+ if (candidate && fs.existsSync(candidate)) {
161
+ return candidate;
162
+ }
163
+ }
164
+ return null;
165
+ }
166
+
167
+ function ensureHostShareRoot() {
168
+ fs.mkdirSync(HOST_SHARE_ROOT, { recursive: true });
169
+
170
+ for (const entry of HOST_SHARE_LINKS) {
171
+ const sourcePath = path.resolve(entry.source);
172
+ if (!fs.existsSync(sourcePath)) {
173
+ throw new Error(`Host share source is missing: ${sourcePath}`);
174
+ }
175
+
176
+ const linkPath = path.join(HOST_SHARE_ROOT, entry.name);
177
+ let needsLink = true;
178
+ if (fs.existsSync(linkPath)) {
179
+ try {
180
+ const resolved = fs.realpathSync.native ? fs.realpathSync.native(linkPath) : fs.realpathSync(linkPath);
181
+ needsLink = resolved !== sourcePath;
182
+ } catch {
183
+ needsLink = true;
184
+ }
185
+ if (needsLink) {
186
+ fs.rmSync(linkPath, { recursive: true, force: true });
187
+ }
188
+ }
189
+
190
+ if (needsLink) {
191
+ fs.symlinkSync(sourcePath, linkPath, process.platform === 'win32' ? 'junction' : 'dir');
192
+ }
193
+ }
194
+
195
+ return HOST_SHARE_ROOT;
196
+ }
197
+
198
+ function resolveAarch64FirmwarePaths() {
199
+ const shareRoot = resolveQemuShareRoot();
200
+ if (!shareRoot) {
201
+ return null;
202
+ }
203
+
204
+ const codePath = path.join(shareRoot, 'edk2-aarch64-code.fd');
205
+ const varsTemplatePathCandidates = [
206
+ path.join(shareRoot, 'edk2-aarch64-vars.fd'),
207
+ path.join(shareRoot, 'edk2-arm-vars.fd'),
208
+ ];
209
+ const varsTemplatePath = varsTemplatePathCandidates.find((candidate) => fs.existsSync(candidate)) || null;
210
+
211
+ if (!fs.existsSync(codePath) || !varsTemplatePath) {
212
+ return null;
213
+ }
214
+
215
+ return {
216
+ shareRoot,
217
+ codePath,
218
+ varsTemplatePath,
219
+ };
220
+ }
221
+
222
+ function resolveX86_64FirmwarePaths() {
223
+ const shareRoot = resolveQemuShareRoot();
224
+ if (!shareRoot) {
225
+ return null;
226
+ }
227
+
228
+ // edk2-x86_64-code.fd is the UEFI code ROM; edk2-i386-vars.fd is the
229
+ // correct writable vars companion (Homebrew QEMU does not ship an
230
+ // edk2-x86_64-vars.fd — they share the i386 vars image).
231
+ const codePathCandidates = [
232
+ path.join(shareRoot, 'edk2-x86_64-code.fd'),
233
+ path.join(shareRoot, 'edk2-x86_64-secure-code.fd'),
234
+ ];
235
+ const codePath = codePathCandidates.find((candidate) => fs.existsSync(candidate)) || null;
236
+ const varsTemplatePathCandidates = [
237
+ path.join(shareRoot, 'edk2-i386-vars.fd'),
238
+ path.join(shareRoot, 'edk2-x86_64-vars.fd'),
239
+ ];
240
+ const varsTemplatePath = varsTemplatePathCandidates.find((candidate) => fs.existsSync(candidate)) || null;
241
+
242
+ if (!codePath || !varsTemplatePath) {
243
+ return null;
244
+ }
245
+
246
+ return {
247
+ shareRoot,
248
+ codePath,
249
+ varsTemplatePath,
250
+ };
251
+ }
252
+
128
253
  function buildQemuArgs({
129
254
  imagePath,
130
255
  sshPort,
@@ -133,8 +258,15 @@ function buildQemuArgs({
133
258
  cpus = 2,
134
259
  arch = guestArchForHost(),
135
260
  platform = process.platform,
261
+ hostShareRoot = null,
262
+ hostDataRoot = null,
263
+ seedPath = null,
264
+ seedIsRaw = false,
265
+ consoleLogPath = null,
266
+ firmwareCodePath = null,
267
+ firmwareVarsPath = null,
136
268
  }) {
137
- const accel = resolveAcceleration({ platform });
269
+ const accel = resolveAcceleration({ platform, arch });
138
270
  const args = ['-display', 'none', '-m', String(memoryMb), '-smp', String(cpus)];
139
271
 
140
272
  if (arch === 'arm64') {
@@ -145,16 +277,67 @@ function buildQemuArgs({
145
277
  } else {
146
278
  args.push('-machine', `q35,accel=${accel}`);
147
279
  if (platform !== 'win32') {
148
- args.push('-cpu', 'host');
280
+ args.push('-cpu', process.arch === arch ? 'host' : 'max');
149
281
  }
150
282
  }
151
283
 
284
+ // OS disk — always first boot candidate
152
285
  args.push(
153
- '-drive', `if=virtio,file=${imagePath},format=qcow2`,
286
+ '-drive', `if=none,id=os,file=${imagePath},format=qcow2`,
287
+ '-device', 'virtio-blk-pci,drive=os,bootindex=1',
154
288
  '-netdev', `user,id=net0,hostfwd=tcp:127.0.0.1:${sshPort}-:22,hostfwd=tcp:127.0.0.1:${agentPort}-:8421`,
155
289
  '-device', 'virtio-net-pci,netdev=net0',
156
290
  );
157
291
 
292
+ if (hostShareRoot) {
293
+ args.push(
294
+ '-virtfs',
295
+ `local,path=${hostShareRoot},mount_tag=neoagent-host,security_model=none,readonly=on`,
296
+ );
297
+ }
298
+
299
+ if (hostDataRoot) {
300
+ args.push(
301
+ '-virtfs',
302
+ `local,path=${hostDataRoot},mount_tag=neoagent-data,security_model=none`,
303
+ );
304
+ }
305
+
306
+ if (seedPath) {
307
+ if (seedIsRaw) {
308
+ // Raw FAT image — attach as a plain virtio block device
309
+ args.push(
310
+ '-drive', `if=none,id=cidata,file=${seedPath},format=raw,readonly=on`,
311
+ '-device', 'virtio-blk-pci,drive=cidata',
312
+ );
313
+ } else if (arch === 'arm64') {
314
+ // ARM virt machine has no IDE controller; use virtio-scsi for the seed ISO
315
+ args.push(
316
+ '-device', 'virtio-scsi-pci,id=scsi0',
317
+ '-drive', `if=none,id=cidata,media=cdrom,readonly=on,file=${seedPath}`,
318
+ '-device', 'scsi-cd,drive=cidata,bus=scsi0.0',
319
+ );
320
+ } else {
321
+ // x86_64 q35 machine: plain IDE CD-ROM is the most reliably detected
322
+ // path for cloud-init NoCloud discovery without extra guest drivers.
323
+ args.push(
324
+ '-drive', `if=none,id=cidata,media=cdrom,readonly=on,file=${seedPath}`,
325
+ '-device', 'ide-cd,drive=cidata,bus=ide.0',
326
+ );
327
+ }
328
+ }
329
+
330
+ if (firmwareCodePath && firmwareVarsPath) {
331
+ args.push(
332
+ '-drive', `if=pflash,format=raw,readonly=on,file=${firmwareCodePath}`,
333
+ '-drive', `if=pflash,format=raw,file=${firmwareVarsPath}`,
334
+ );
335
+ }
336
+
337
+ if (consoleLogPath) {
338
+ args.push('-serial', `file:${consoleLogPath}`);
339
+ }
340
+
158
341
  return args;
159
342
  }
160
343
 
@@ -165,6 +348,22 @@ function commandExists(command) {
165
348
  return probe.status === 0;
166
349
  }
167
350
 
351
+ function resolveCommandPath(command) {
352
+ const probe = spawnSync(
353
+ process.platform === 'win32' ? 'where' : 'bash',
354
+ process.platform === 'win32' ? [command] : ['-lc', `command -v "${command}"`],
355
+ {
356
+ encoding: 'utf8',
357
+ stdio: ['ignore', 'pipe', 'pipe'],
358
+ },
359
+ );
360
+ if (probe.status !== 0) {
361
+ return null;
362
+ }
363
+ const resolved = String(probe.stdout || '').trim().split('\n').find(Boolean);
364
+ return resolved || null;
365
+ }
366
+
168
367
  function allocatePort() {
169
368
  return new Promise((resolve, reject) => {
170
369
  const server = net.createServer();
@@ -192,29 +391,46 @@ function ensureUserVmDisk(userRoot, baseImagePath) {
192
391
  }
193
392
 
194
393
  const qemuImg = process.platform === 'win32' ? 'qemu-img.exe' : 'qemu-img';
195
- if (!commandExists(qemuImg)) {
196
- throw new Error('qemu-img is required to create per-user VM overlays.');
394
+ const qemuImgPath = resolveCommandPath(qemuImg);
395
+ if (!qemuImgPath) {
396
+ try {
397
+ fs.copyFileSync(baseImagePath, diskPath);
398
+ return diskPath;
399
+ } catch (error) {
400
+ throw new Error(`Failed to create VM disk copy: ${error.message}`);
401
+ }
197
402
  }
198
403
 
199
- const result = spawnSync(
200
- qemuImg,
201
- ['create', '-f', 'qcow2', '-F', 'qcow2', '-b', baseImagePath, diskPath],
202
- {
203
- stdio: 'pipe',
204
- encoding: 'utf8',
205
- },
206
- );
207
- if (result.status === 0 && fs.existsSync(diskPath)) {
404
+ try {
405
+ const result = spawnSync(
406
+ qemuImgPath,
407
+ ['create', '-f', 'qcow2', '-F', 'qcow2', '-b', baseImagePath, diskPath, '32G'],
408
+ {
409
+ stdio: 'pipe',
410
+ encoding: 'utf8',
411
+ },
412
+ );
413
+ if (result.status === 0 && fs.existsSync(diskPath)) {
414
+ return diskPath;
415
+ }
416
+
417
+ const detail = String(
418
+ result.stderr
419
+ || result.stdout
420
+ || result.error?.message
421
+ || `exit status ${result.status ?? 'unknown'}`
422
+ ).trim();
423
+ fs.copyFileSync(baseImagePath, diskPath);
208
424
  return diskPath;
425
+ } catch (error) {
426
+ try {
427
+ fs.copyFileSync(baseImagePath, diskPath);
428
+ return diskPath;
429
+ } catch (copyError) {
430
+ const detail = String(error?.message || copyError?.message || 'unknown error').trim();
431
+ throw new Error(`Failed to create VM overlay with qemu-img: ${detail}`);
432
+ }
209
433
  }
210
-
211
- const detail = String(
212
- result.stderr
213
- || result.stdout
214
- || result.error?.message
215
- || `exit status ${result.status ?? 'unknown'}`
216
- ).trim();
217
- throw new Error(`Failed to create VM overlay with qemu-img: ${detail}`);
218
434
  }
219
435
 
220
436
  function formatReadinessIssues(readiness) {
@@ -225,9 +441,6 @@ function formatReadinessIssues(readiness) {
225
441
  if (!readiness.qemuAvailable) {
226
442
  issues.push(`Missing QEMU binary (${readiness.qemuBinary}).`);
227
443
  }
228
- if (!readiness.qemuImgAvailable) {
229
- issues.push(`Missing qemu-img binary (${readiness.qemuImgBinary}).`);
230
- }
231
444
  if (!readiness.baseImageExists && !readiness.downloadConfigured) {
232
445
  issues.push('No VM base image is available for download or local reuse.');
233
446
  }
@@ -238,10 +451,13 @@ class QemuVmManager {
238
451
  constructor(options = {}) {
239
452
  this.rootDir = path.resolve(options.rootDir || VM_ROOT);
240
453
  this.baseImagePath = options.baseImagePath || process.env.NEOAGENT_VM_BASE_IMAGE || '';
241
- this.baseImageUrl = options.baseImageUrl || process.env.NEOAGENT_VM_BASE_IMAGE_URL || defaultBaseImageUrlForArch(options.guestArch || guestArchForHost());
454
+ this.guestArch = options.guestArch || guestArchForHost();
455
+ this.baseImageUrl = normalizeBaseImageUrlForArch(
456
+ options.baseImageUrl || process.env.NEOAGENT_VM_BASE_IMAGE_URL || defaultBaseImageUrlForArch(this.guestArch),
457
+ this.guestArch,
458
+ );
242
459
  this.memoryMb = Number(options.memoryMb || process.env.NEOAGENT_VM_MEMORY_MB || 4096);
243
460
  this.cpus = Number(options.cpus || process.env.NEOAGENT_VM_CPUS || 2);
244
- this.guestArch = options.guestArch || guestArchForHost();
245
461
  this.instances = new Map();
246
462
  this.baseImagePromise = null;
247
463
  fs.mkdirSync(this.rootDir, { recursive: true });
@@ -303,9 +519,8 @@ class QemuVmManager {
303
519
  const baseImageExists = Boolean(resolvedBaseImagePath && fs.existsSync(resolvedBaseImagePath));
304
520
  const downloadConfigured = !this.baseImagePath && isHttpUrl(this.baseImageUrl);
305
521
  const qemuAvailable = commandExists(qemuBinary);
306
- const qemuImgAvailable = commandExists(qemuImgBinary);
307
522
  return {
308
- ready: qemuAvailable && qemuImgAvailable && (baseImageExists || downloadConfigured),
523
+ ready: qemuAvailable && (baseImageExists || downloadConfigured),
309
524
  baseImagePath: resolvedBaseImagePath || null,
310
525
  baseImageExists,
311
526
  baseImageUrl: this.baseImageUrl || null,
@@ -313,8 +528,8 @@ class QemuVmManager {
313
528
  qemuBinary,
314
529
  qemuAvailable,
315
530
  qemuImgBinary,
316
- qemuImgAvailable,
317
- acceleration: resolveAcceleration(),
531
+ qemuImgAvailable: commandExists(qemuImgBinary),
532
+ acceleration: resolveAcceleration({ arch: this.guestArch }),
318
533
  guestArch: this.guestArch,
319
534
  platform: process.platform,
320
535
  };
@@ -326,20 +541,59 @@ class QemuVmManager {
326
541
  throw new Error('VM runtime requires a user ID.');
327
542
  }
328
543
  const existing = this.instances.get(key);
329
- if (existing?.process && !existing.process.killed) {
544
+ if (existing?.process && !existing.process.killed && existing.guestArch === this.guestArch) {
330
545
  return existing;
331
546
  }
547
+ if (existing?.process && existing.guestArch !== this.guestArch) {
548
+ try {
549
+ existing.process.kill('SIGTERM');
550
+ } catch {}
551
+ this.instances.delete(key);
552
+ }
332
553
  const readiness = this.getReadiness();
333
554
  if (!readiness.ready) {
334
555
  throw new Error(formatReadinessIssues(readiness).join(' '));
335
556
  }
336
557
 
337
- const userRoot = path.join(this.rootDir, key);
558
+ const userRoot = path.join(this.rootDir, key, this.guestArch);
338
559
  const baseImagePath = await this.ensureBaseImageAvailable();
339
560
  const diskPath = ensureUserVmDisk(userRoot, baseImagePath);
561
+ const guestToken = String(process.env.NEOAGENT_VM_GUEST_TOKEN || '').trim();
562
+ if (!guestToken) {
563
+ throw new Error('NEOAGENT_VM_GUEST_TOKEN is required to bootstrap the guest runtime.');
564
+ }
565
+ const bootstrap = ensureGuestBootstrapSeed({
566
+ userRoot,
567
+ guestToken,
568
+ });
569
+ const guestDataRoot = path.join(userRoot, 'guest-data');
570
+ const consoleLogPath = path.join(userRoot, 'console.log');
571
+ const firmware = this.guestArch === 'arm64'
572
+ ? resolveAarch64FirmwarePaths()
573
+ : resolveX86_64FirmwarePaths();
574
+ const firmwareVarsPath = firmware ? path.join(userRoot, 'uefi-vars.fd') : null;
575
+ if (firmware && !fs.existsSync(firmwareVarsPath)) {
576
+ if (!fs.existsSync(firmware.varsTemplatePath)) {
577
+ throw new Error(`Firmware vars template is missing: ${firmware.varsTemplatePath}`);
578
+ }
579
+ try {
580
+ fs.copyFileSync(firmware.varsTemplatePath, firmwareVarsPath);
581
+ } catch (error) {
582
+ const detail = error?.message || error;
583
+ console.error('[VM] Failed to copy firmware vars template', {
584
+ source: firmware.varsTemplatePath,
585
+ destination: firmwareVarsPath,
586
+ error: detail,
587
+ });
588
+ throw new Error(`Failed to copy firmware vars template: ${detail}`);
589
+ }
590
+ }
591
+ fs.mkdirSync(guestDataRoot, { recursive: true });
340
592
  const agentPort = await allocatePort();
341
593
  const sshPort = await allocatePort();
342
594
  const qemuBinary = resolveQemuBinary({ arch: this.guestArch });
595
+ const qemuBinaryPath = resolveCommandPath(qemuBinary) || qemuBinary;
596
+ const hostShareRoot = ensureHostShareRoot();
343
597
  const args = buildQemuArgs({
344
598
  imagePath: diskPath,
345
599
  sshPort,
@@ -347,9 +601,16 @@ class QemuVmManager {
347
601
  memoryMb: this.memoryMb,
348
602
  cpus: this.cpus,
349
603
  arch: this.guestArch,
604
+ hostShareRoot,
605
+ hostDataRoot: guestDataRoot,
606
+ seedPath: bootstrap.seedImagePath || bootstrap.isoPath,
607
+ seedIsRaw: Boolean(bootstrap.seedImagePath && bootstrap.seedImagePath.endsWith('.img')),
608
+ consoleLogPath,
609
+ firmwareCodePath: firmware?.codePath || null,
610
+ firmwareVarsPath,
350
611
  });
351
612
 
352
- const child = spawn(qemuBinary, args, {
613
+ const child = spawn(qemuBinaryPath, args, {
353
614
  cwd: userRoot,
354
615
  detached: process.platform !== 'win32',
355
616
  stdio: ['ignore', 'ignore', 'pipe'],
@@ -375,6 +636,7 @@ class QemuVmManager {
375
636
  process: child,
376
637
  qemuBinary,
377
638
  qemuArgs: args,
639
+ guestArch: this.guestArch,
378
640
  userRoot,
379
641
  diskPath,
380
642
  agentPort,
@@ -386,6 +648,31 @@ class QemuVmManager {
386
648
  return session;
387
649
  }
388
650
 
651
+ hasVm(userId) {
652
+ const key = String(userId || '').trim();
653
+ return Boolean(key && this.instances.has(key));
654
+ }
655
+
656
+ async killVm(userId) {
657
+ const key = String(userId || '').trim();
658
+ const session = this.instances.get(key);
659
+ if (!session) return;
660
+
661
+ try {
662
+ if (process.platform === 'win32') {
663
+ spawnSync('taskkill', ['/F', '/T', '/PID', session.process.pid]);
664
+ } else {
665
+ process.kill(-session.process.pid, 'SIGKILL');
666
+ }
667
+ } catch (err) {
668
+ try {
669
+ session.process.kill('SIGKILL');
670
+ } catch {}
671
+ }
672
+
673
+ this.instances.delete(key);
674
+ }
675
+
389
676
  async shutdown() {
390
677
  for (const session of this.instances.values()) {
391
678
  try {
@@ -38,9 +38,6 @@ function getRuntimeValidation(runtimeManager) {
38
38
  if (!vmReadiness.qemuAvailable) {
39
39
  issues.push(`prod profile requires QEMU (${vmReadiness.qemuBinary}) to be installed.`);
40
40
  }
41
- if (!vmReadiness.qemuImgAvailable) {
42
- issues.push(`prod profile requires qemu-img (${vmReadiness.qemuImgBinary}) to be installed.`);
43
- }
44
41
  if (!vmReadiness.baseImageExists && !vmReadiness.downloadConfigured) {
45
42
  issues.push('prod profile requires a VM base image or a downloadable base image URL.');
46
43
  }