949 lines
32 KiB
TypeScript
949 lines
32 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as crypto from 'crypto';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
import {
|
|
BaseImageManager,
|
|
VMConfig,
|
|
SocketClient,
|
|
NetworkManager,
|
|
MicroVM,
|
|
SmartVM,
|
|
SmartVMError,
|
|
} from '../ts/index.js';
|
|
import type { IBaseImageBundle, IBaseImageHostedManifest, IMicroVMConfig } from '../ts/index.js';
|
|
|
|
async function getRejectedError(promise: Promise<unknown>): Promise<unknown> {
|
|
try {
|
|
await promise;
|
|
} catch (err) {
|
|
return err;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function getThrownError(fn: () => unknown): unknown {
|
|
try {
|
|
fn();
|
|
} catch (err) {
|
|
return err;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function sha256Buffer(buffer: Buffer): string {
|
|
return crypto.createHash('sha256').update(buffer).digest('hex');
|
|
}
|
|
|
|
// ============================================================
|
|
// VMConfig Tests
|
|
// ============================================================
|
|
|
|
const sampleConfig: IMicroVMConfig = {
|
|
id: 'test-vm-1',
|
|
bootSource: {
|
|
kernelImagePath: '/path/to/vmlinux',
|
|
bootArgs: 'console=ttyS0 reboot=k panic=1',
|
|
},
|
|
machineConfig: {
|
|
vcpuCount: 2,
|
|
memSizeMib: 256,
|
|
smt: false,
|
|
},
|
|
drives: [
|
|
{
|
|
driveId: 'rootfs',
|
|
pathOnHost: '/path/to/rootfs.ext4',
|
|
isRootDevice: true,
|
|
isReadOnly: false,
|
|
cacheType: 'Unsafe',
|
|
},
|
|
],
|
|
networkInterfaces: [
|
|
{
|
|
ifaceId: 'eth0',
|
|
hostDevName: 'tap0',
|
|
guestMac: '02:00:00:00:00:01',
|
|
},
|
|
],
|
|
};
|
|
|
|
tap.test('VMConfig - validate() should pass for valid config', async () => {
|
|
const vmConfig = new VMConfig(sampleConfig);
|
|
const result = vmConfig.validate();
|
|
expect(result.valid).toBeTrue();
|
|
expect(result.errors).toHaveLength(0);
|
|
});
|
|
|
|
tap.test('VMConfig - validate() should fail for missing bootSource', async () => {
|
|
const vmConfig = new VMConfig({
|
|
machineConfig: { vcpuCount: 1, memSizeMib: 128 },
|
|
} as any);
|
|
const result = vmConfig.validate();
|
|
expect(result.valid).toBeFalse();
|
|
expect(result.errors.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
tap.test('VMConfig - validate() should fail for invalid vcpuCount', async () => {
|
|
const vmConfig = new VMConfig({
|
|
bootSource: { kernelImagePath: '/vmlinux' },
|
|
machineConfig: { vcpuCount: 0, memSizeMib: 128 },
|
|
});
|
|
const result = vmConfig.validate();
|
|
expect(result.valid).toBeFalse();
|
|
});
|
|
|
|
tap.test('VMConfig - validate() should fail for vcpuCount > 32', async () => {
|
|
const vmConfig = new VMConfig({
|
|
bootSource: { kernelImagePath: '/vmlinux' },
|
|
machineConfig: { vcpuCount: 64, memSizeMib: 128 },
|
|
});
|
|
const result = vmConfig.validate();
|
|
expect(result.valid).toBeFalse();
|
|
});
|
|
|
|
tap.test('VMConfig - validate() should fail for multiple root drives', async () => {
|
|
const vmConfig = new VMConfig({
|
|
bootSource: { kernelImagePath: '/vmlinux' },
|
|
machineConfig: { vcpuCount: 1, memSizeMib: 128 },
|
|
drives: [
|
|
{ driveId: 'rootfs1', pathOnHost: '/rootfs1', isRootDevice: true },
|
|
{ driveId: 'rootfs2', pathOnHost: '/rootfs2', isRootDevice: true },
|
|
],
|
|
});
|
|
const result = vmConfig.validate();
|
|
expect(result.valid).toBeFalse();
|
|
});
|
|
|
|
tap.test('VMConfig - validate() should fail for invalid vsock guestCid', async () => {
|
|
const vmConfig = new VMConfig({
|
|
bootSource: { kernelImagePath: '/vmlinux' },
|
|
machineConfig: { vcpuCount: 1, memSizeMib: 128 },
|
|
vsock: { guestCid: 2, udsPath: '/tmp/vsock.sock' },
|
|
});
|
|
const result = vmConfig.validate();
|
|
expect(result.valid).toBeFalse();
|
|
expect(result.errors).toContain('vsock.guestCid must be >= 3');
|
|
});
|
|
|
|
tap.test('VMConfig - constructor should not retain caller references', async () => {
|
|
const config: IMicroVMConfig = {
|
|
...sampleConfig,
|
|
networkInterfaces: [{ ifaceId: 'eth0', guestMac: '02:00:00:00:00:01' }],
|
|
mmds: { version: 'V2', networkInterfaces: ['eth0'] },
|
|
};
|
|
const vmConfig = new VMConfig(config);
|
|
|
|
config.networkInterfaces![0].guestMac = '02:00:00:00:00:02';
|
|
config.mmds!.networkInterfaces.push('eth1');
|
|
|
|
expect(vmConfig.toNetworkInterfacePayload(vmConfig.config.networkInterfaces![0]).guest_mac)
|
|
.toEqual('02:00:00:00:00:01');
|
|
expect(vmConfig.toMmdsConfigPayload()!.network_interfaces).toEqual(['eth0']);
|
|
});
|
|
|
|
tap.test('VMConfig - toBootSourcePayload() should generate correct snake_case', async () => {
|
|
const vmConfig = new VMConfig(sampleConfig);
|
|
const payload = vmConfig.toBootSourcePayload();
|
|
expect(payload.kernel_image_path).toEqual('/path/to/vmlinux');
|
|
expect(payload.boot_args).toEqual('console=ttyS0 reboot=k panic=1');
|
|
expect(payload.initrd_path).toBeUndefined();
|
|
});
|
|
|
|
tap.test('VMConfig - toMachineConfigPayload() should generate correct payload', async () => {
|
|
const vmConfig = new VMConfig(sampleConfig);
|
|
const payload = vmConfig.toMachineConfigPayload();
|
|
expect(payload.vcpu_count).toEqual(2);
|
|
expect(payload.mem_size_mib).toEqual(256);
|
|
expect(payload.smt).toEqual(false);
|
|
});
|
|
|
|
tap.test('VMConfig - toDrivePayload() should generate correct payload', async () => {
|
|
const vmConfig = new VMConfig(sampleConfig);
|
|
const payload = vmConfig.toDrivePayload(sampleConfig.drives![0]);
|
|
expect(payload.drive_id).toEqual('rootfs');
|
|
expect(payload.path_on_host).toEqual('/path/to/rootfs.ext4');
|
|
expect(payload.is_root_device).toEqual(true);
|
|
expect(payload.is_read_only).toEqual(false);
|
|
expect(payload.cache_type).toEqual('Unsafe');
|
|
});
|
|
|
|
tap.test('VMConfig - toNetworkInterfacePayload() should generate correct payload', async () => {
|
|
const vmConfig = new VMConfig(sampleConfig);
|
|
const payload = vmConfig.toNetworkInterfacePayload(sampleConfig.networkInterfaces![0]);
|
|
expect(payload.iface_id).toEqual('eth0');
|
|
expect(payload.host_dev_name).toEqual('tap0');
|
|
expect(payload.guest_mac).toEqual('02:00:00:00:00:01');
|
|
});
|
|
|
|
tap.test('VMConfig - toVsockPayload() should return null when no vsock configured', async () => {
|
|
const vmConfig = new VMConfig(sampleConfig);
|
|
expect(vmConfig.toVsockPayload()).toBeNull();
|
|
});
|
|
|
|
tap.test('VMConfig - toBalloonPayload() should return null when no balloon configured', async () => {
|
|
const vmConfig = new VMConfig(sampleConfig);
|
|
expect(vmConfig.toBalloonPayload()).toBeNull();
|
|
});
|
|
|
|
tap.test('VMConfig - toBalloonPayload() should generate correct payload', async () => {
|
|
const config: IMicroVMConfig = {
|
|
...sampleConfig,
|
|
balloon: { amountMib: 64, deflateOnOom: true, statsPollingIntervalS: 5 },
|
|
};
|
|
const vmConfig = new VMConfig(config);
|
|
const payload = vmConfig.toBalloonPayload();
|
|
expect(payload).not.toBeNull();
|
|
expect(payload!.amount_mib).toEqual(64);
|
|
expect(payload!.deflate_on_oom).toEqual(true);
|
|
expect(payload!.stats_polling_interval_s).toEqual(5);
|
|
});
|
|
|
|
tap.test('VMConfig - toVsockPayload() should generate correct payload', async () => {
|
|
const config: IMicroVMConfig = {
|
|
...sampleConfig,
|
|
vsock: { guestCid: 3, udsPath: '/tmp/vsock.sock' },
|
|
};
|
|
const vmConfig = new VMConfig(config);
|
|
const payload = vmConfig.toVsockPayload();
|
|
expect(payload).not.toBeNull();
|
|
expect(payload!.guest_cid).toEqual(3);
|
|
expect(payload!.uds_path).toEqual('/tmp/vsock.sock');
|
|
});
|
|
|
|
tap.test('VMConfig - toMmdsConfigPayload() should generate correct payload', async () => {
|
|
const config: IMicroVMConfig = {
|
|
...sampleConfig,
|
|
mmds: { version: 'V2', networkInterfaces: ['eth0'] },
|
|
};
|
|
const vmConfig = new VMConfig(config);
|
|
const payload = vmConfig.toMmdsConfigPayload();
|
|
expect(payload).not.toBeNull();
|
|
expect(payload!.version).toEqual('V2');
|
|
expect(payload!.network_interfaces).toEqual(['eth0']);
|
|
});
|
|
|
|
tap.test('VMConfig - toMetricsPayload() should generate correct payload', async () => {
|
|
const config: IMicroVMConfig = {
|
|
...sampleConfig,
|
|
metrics: { metricsPath: '/tmp/firecracker-metrics.fifo' },
|
|
};
|
|
const vmConfig = new VMConfig(config);
|
|
const payload = vmConfig.toMetricsPayload();
|
|
expect(payload).not.toBeNull();
|
|
expect(payload!.metrics_path).toEqual('/tmp/firecracker-metrics.fifo');
|
|
});
|
|
|
|
tap.test('VMConfig - toDrivePayload() should include rate limiter payloads', async () => {
|
|
const vmConfig = new VMConfig(sampleConfig);
|
|
const payload = vmConfig.toDrivePayload({
|
|
driveId: 'data',
|
|
pathOnHost: '/path/to/data.ext4',
|
|
isRootDevice: false,
|
|
rateLimiter: {
|
|
bandwidth: { size: 1000, refillTime: 2000, oneTimeBurst: 3000 },
|
|
ops: { size: 10, refillTime: 20, oneTimeBurst: 30 },
|
|
},
|
|
});
|
|
|
|
expect(payload.rate_limiter.bandwidth).toEqual({
|
|
size: 1000,
|
|
refill_time: 2000,
|
|
one_time_burst: 3000,
|
|
});
|
|
expect(payload.rate_limiter.ops).toEqual({
|
|
size: 10,
|
|
refill_time: 20,
|
|
one_time_burst: 30,
|
|
});
|
|
});
|
|
|
|
tap.test('VMConfig - toLoggerPayload() should generate correct payload', async () => {
|
|
const config: IMicroVMConfig = {
|
|
...sampleConfig,
|
|
logger: { logPath: '/tmp/fc.log', level: 'Debug', showLogOrigin: true },
|
|
};
|
|
const vmConfig = new VMConfig(config);
|
|
const payload = vmConfig.toLoggerPayload();
|
|
expect(payload).not.toBeNull();
|
|
expect(payload!.log_path).toEqual('/tmp/fc.log');
|
|
expect(payload!.level).toEqual('Debug');
|
|
expect(payload!.show_log_origin).toEqual(true);
|
|
});
|
|
|
|
// ============================================================
|
|
// SocketClient Tests
|
|
// ============================================================
|
|
|
|
tap.test('SocketClient - URL construction', async () => {
|
|
const client = new SocketClient({ socketPath: '/tmp/test.sock' });
|
|
// We can't directly access buildUrl, but we can verify the client instantiates
|
|
expect(client).toBeTruthy();
|
|
});
|
|
|
|
// ============================================================
|
|
// BaseImageManager Tests
|
|
// ============================================================
|
|
|
|
tap.test('BaseImageManager - instantiation with defaults', async () => {
|
|
const manager = new BaseImageManager();
|
|
expect(manager.getCacheDir()).toEqual(path.join(os.tmpdir(), '.smartvm', 'base-images'));
|
|
expect(manager.getMaxStoredBaseImages()).toEqual(2);
|
|
});
|
|
|
|
tap.test('BaseImageManager - rejects invalid maxStoredBaseImages', async () => {
|
|
let error: unknown;
|
|
try {
|
|
new BaseImageManager({ maxStoredBaseImages: 0 });
|
|
} catch (err) {
|
|
error = err;
|
|
}
|
|
|
|
expect(error).toBeInstanceOf(SmartVMError);
|
|
expect((error as SmartVMError).code).toEqual('INVALID_BASE_IMAGE_CACHE_LIMIT');
|
|
});
|
|
|
|
tap.test('BaseImageManager - pruneBaseImageCache() should evict old bundles', async () => {
|
|
const cacheDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-base-image-test-'));
|
|
const manager = new BaseImageManager({ cacheDir, maxStoredBaseImages: 2 });
|
|
const originalWarn = console.warn;
|
|
const warnings: string[] = [];
|
|
console.warn = (message?: any) => {
|
|
warnings.push(String(message));
|
|
};
|
|
|
|
const createManifest = async (bundleId: string, lastAccessedAt: string) => {
|
|
const bundleDir = path.join(cacheDir, bundleId);
|
|
await fs.promises.mkdir(bundleDir, { recursive: true });
|
|
const bundle: IBaseImageBundle = {
|
|
preset: 'lts',
|
|
arch: 'x86_64',
|
|
ciVersion: 'v1.7',
|
|
firecrackerVersion: 'v1.7.0',
|
|
bundleId,
|
|
bundleDir,
|
|
kernelImagePath: path.join(bundleDir, 'vmlinux'),
|
|
rootfsPath: path.join(bundleDir, 'rootfs.ext4'),
|
|
rootfsType: 'ext4',
|
|
rootfsIsReadOnly: false,
|
|
bootArgs: 'console=ttyS0 reboot=k panic=1 pci=off',
|
|
source: {
|
|
bucketUrl: 'https://s3.amazonaws.com/spec.ccfc.min',
|
|
kernelKey: 'kernel',
|
|
rootfsKey: 'rootfs',
|
|
},
|
|
createdAt: lastAccessedAt,
|
|
lastAccessedAt,
|
|
};
|
|
await fs.promises.writeFile(path.join(bundleDir, 'manifest.json'), `${JSON.stringify(bundle, null, 2)}\n`);
|
|
};
|
|
|
|
try {
|
|
await createManifest('old', '2024-01-01T00:00:00.000Z');
|
|
await createManifest('middle', '2024-01-02T00:00:00.000Z');
|
|
await createManifest('new', '2024-01-03T00:00:00.000Z');
|
|
|
|
const evicted = await manager.pruneBaseImageCache('new');
|
|
expect(evicted).toEqual(['old']);
|
|
expect(warnings.length).toEqual(1);
|
|
expect(warnings[0]).toInclude('Evicting old');
|
|
expect(fs.existsSync(path.join(cacheDir, 'old'))).toBeFalse();
|
|
expect(fs.existsSync(path.join(cacheDir, 'middle'))).toBeTrue();
|
|
expect(fs.existsSync(path.join(cacheDir, 'new'))).toBeTrue();
|
|
} finally {
|
|
console.warn = originalWarn;
|
|
await fs.promises.rm(cacheDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
tap.test('BaseImageManager - ensureBaseImage() should copy hosted manifest artifacts', async () => {
|
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-test-'));
|
|
const cacheDir = path.join(workDir, 'cache');
|
|
const assetsDir = path.join(workDir, 'assets');
|
|
await fs.promises.mkdir(assetsDir, { recursive: true });
|
|
|
|
const kernelBuffer = Buffer.from('fake-kernel');
|
|
const rootfsBuffer = Buffer.from('fake-rootfs');
|
|
const kernelPath = path.join(assetsDir, 'vmlinux-test');
|
|
const rootfsPath = path.join(assetsDir, 'rootfs-test.ext4');
|
|
await fs.promises.writeFile(kernelPath, kernelBuffer);
|
|
await fs.promises.writeFile(rootfsPath, rootfsBuffer);
|
|
|
|
const manifest: IBaseImageHostedManifest = {
|
|
schemaVersion: 1,
|
|
bundleId: 'smartvm-minimal-test',
|
|
name: 'SmartVM minimal test bundle',
|
|
arch: 'x86_64',
|
|
firecrackerVersion: 'v1.15.1',
|
|
rootfsType: 'ext4',
|
|
rootfsIsReadOnly: false,
|
|
bootArgs: 'console=ttyS0 reboot=k panic=1 pci=off',
|
|
kernel: {
|
|
path: kernelPath,
|
|
fileName: 'vmlinux',
|
|
sha256: sha256Buffer(kernelBuffer),
|
|
sizeBytes: kernelBuffer.length,
|
|
},
|
|
rootfs: {
|
|
path: rootfsPath,
|
|
fileName: 'rootfs.ext4',
|
|
sha256: sha256Buffer(rootfsBuffer),
|
|
sizeBytes: rootfsBuffer.length,
|
|
},
|
|
};
|
|
const manifestPath = path.join(workDir, 'manifest.json');
|
|
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
|
|
try {
|
|
const manager = new BaseImageManager({ cacheDir });
|
|
const bundle = await manager.ensureBaseImage({ manifestPath });
|
|
|
|
expect(bundle.preset).toEqual('hosted');
|
|
expect(bundle.bundleId).toEqual('smartvm-minimal-test');
|
|
expect(bundle.firecrackerVersion).toEqual('v1.15.1');
|
|
expect(bundle.source.type).toEqual('hosted-manifest');
|
|
expect(bundle.source.manifestPath).toEqual(manifestPath);
|
|
expect(fs.existsSync(bundle.kernelImagePath)).toBeTrue();
|
|
expect(fs.existsSync(bundle.rootfsPath)).toBeTrue();
|
|
expect(bundle.checksums!.kernelSha256).toEqual(sha256Buffer(kernelBuffer));
|
|
expect(bundle.checksums!.rootfsSha256).toEqual(sha256Buffer(rootfsBuffer));
|
|
} finally {
|
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
tap.test('BaseImageManager - ensureBaseImage() should redownload corrupted cached artifacts', async () => {
|
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-cache-test-'));
|
|
const cacheDir = path.join(workDir, 'cache');
|
|
const assetsDir = path.join(workDir, 'assets');
|
|
await fs.promises.mkdir(assetsDir, { recursive: true });
|
|
|
|
const kernelBuffer = Buffer.from('fresh-kernel');
|
|
const rootfsBuffer = Buffer.from('fresh-rootfs');
|
|
const kernelPath = path.join(assetsDir, 'vmlinux-test');
|
|
const rootfsPath = path.join(assetsDir, 'rootfs-test.ext4');
|
|
await fs.promises.writeFile(kernelPath, kernelBuffer);
|
|
await fs.promises.writeFile(rootfsPath, rootfsBuffer);
|
|
|
|
const manifest: IBaseImageHostedManifest = {
|
|
schemaVersion: 1,
|
|
bundleId: 'smartvm-corruption-test',
|
|
arch: 'x86_64',
|
|
firecrackerVersion: 'v1.15.1',
|
|
rootfsType: 'ext4',
|
|
kernel: {
|
|
path: kernelPath,
|
|
fileName: 'vmlinux',
|
|
sha256: sha256Buffer(kernelBuffer),
|
|
sizeBytes: kernelBuffer.length,
|
|
},
|
|
rootfs: {
|
|
path: rootfsPath,
|
|
fileName: 'rootfs.ext4',
|
|
sha256: sha256Buffer(rootfsBuffer),
|
|
sizeBytes: rootfsBuffer.length,
|
|
},
|
|
};
|
|
const manifestPath = path.join(workDir, 'manifest.json');
|
|
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
|
|
try {
|
|
const manager = new BaseImageManager({ cacheDir });
|
|
const firstBundle = await manager.ensureBaseImage({ manifestPath });
|
|
await fs.promises.writeFile(firstBundle.kernelImagePath, 'tampered-kernel');
|
|
|
|
const secondBundle = await manager.ensureBaseImage({ manifestPath });
|
|
expect(await fs.promises.readFile(secondBundle.kernelImagePath, 'utf8')).toEqual('fresh-kernel');
|
|
expect(secondBundle.checksums!.kernelSha256).toEqual(sha256Buffer(kernelBuffer));
|
|
} finally {
|
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
tap.test('BaseImageManager - ensureBaseImage() should reject hosted manifest arch mismatch', async () => {
|
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-invalid-test-'));
|
|
const manifestPath = path.join(workDir, 'manifest.json');
|
|
const manifest: IBaseImageHostedManifest = {
|
|
schemaVersion: 1,
|
|
bundleId: 'smartvm-invalid-arch-test',
|
|
arch: 'aarch64',
|
|
firecrackerVersion: 'v1.15.1',
|
|
rootfsType: 'ext4',
|
|
kernel: { path: path.join(workDir, 'vmlinux') },
|
|
rootfs: { path: path.join(workDir, 'rootfs.ext4') },
|
|
};
|
|
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
|
|
try {
|
|
const manager = new BaseImageManager({ cacheDir: path.join(workDir, 'cache'), arch: 'x86_64' });
|
|
const error = await getRejectedError(manager.ensureBaseImage({ manifestPath }));
|
|
expect(error).toBeInstanceOf(SmartVMError);
|
|
expect((error as SmartVMError).code).toEqual('INVALID_BASE_IMAGE_MANIFEST');
|
|
} finally {
|
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
tap.test('BaseImageManager - ensureBaseImage() should reject hosted manifest fileName traversal', async () => {
|
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-filename-test-'));
|
|
const manifestPath = path.join(workDir, 'manifest.json');
|
|
const manifest: IBaseImageHostedManifest = {
|
|
schemaVersion: 1,
|
|
bundleId: 'smartvm-invalid-filename-test',
|
|
arch: 'x86_64',
|
|
firecrackerVersion: 'v1.15.1',
|
|
rootfsType: 'ext4',
|
|
kernel: { path: path.join(workDir, 'vmlinux'), fileName: '../vmlinux' },
|
|
rootfs: { path: path.join(workDir, 'rootfs.ext4'), fileName: 'rootfs.ext4' },
|
|
};
|
|
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
|
|
try {
|
|
const manager = new BaseImageManager({ cacheDir: path.join(workDir, 'cache') });
|
|
const error = await getRejectedError(manager.ensureBaseImage({ manifestPath }));
|
|
expect(error).toBeInstanceOf(SmartVMError);
|
|
expect((error as SmartVMError).code).toEqual('INVALID_BASE_IMAGE_MANIFEST');
|
|
} finally {
|
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
tap.test('BaseImageManager - ensureBaseImage() should reject hosted URL artifacts without sha256', async () => {
|
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-url-test-'));
|
|
const manifestPath = path.join(workDir, 'manifest.json');
|
|
const manifest: IBaseImageHostedManifest = {
|
|
schemaVersion: 1,
|
|
bundleId: 'smartvm-invalid-url-test',
|
|
arch: 'x86_64',
|
|
firecrackerVersion: 'v1.15.1',
|
|
rootfsType: 'ext4',
|
|
kernel: { url: 'https://example.com/vmlinux' },
|
|
rootfs: { path: path.join(workDir, 'rootfs.ext4') },
|
|
};
|
|
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
|
|
try {
|
|
const manager = new BaseImageManager({ cacheDir: path.join(workDir, 'cache') });
|
|
const error = await getRejectedError(manager.ensureBaseImage({ manifestPath }));
|
|
expect(error).toBeInstanceOf(SmartVMError);
|
|
expect((error as SmartVMError).code).toEqual('INVALID_BASE_IMAGE_MANIFEST');
|
|
} finally {
|
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
tap.test('BaseImageManager - hosted preset should require a manifest', async () => {
|
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-missing-test-'));
|
|
try {
|
|
const manager = new BaseImageManager({ cacheDir: path.join(workDir, 'cache') });
|
|
const error = await getRejectedError(manager.ensureBaseImage({ preset: 'hosted' }));
|
|
expect(error).toBeInstanceOf(SmartVMError);
|
|
expect((error as SmartVMError).code).toEqual('BASE_IMAGE_MANIFEST_FAILED');
|
|
} finally {
|
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// NetworkManager Tests
|
|
// ============================================================
|
|
|
|
tap.test('NetworkManager - allocateIp() should allocate sequential IPs', async () => {
|
|
const nm = new NetworkManager({ subnet: '172.30.0.0/24' });
|
|
const ip1 = nm.allocateIp();
|
|
const ip2 = nm.allocateIp();
|
|
const ip3 = nm.allocateIp();
|
|
expect(ip1).toEqual('172.30.0.2');
|
|
expect(ip2).toEqual('172.30.0.3');
|
|
expect(ip3).toEqual('172.30.0.4');
|
|
});
|
|
|
|
tap.test('NetworkManager - allocateIp() should normalize non-network CIDR input', async () => {
|
|
const nm = new NetworkManager({ subnet: '10.20.30.17/29' });
|
|
expect(nm.allocateIp()).toEqual('10.20.30.18');
|
|
expect(nm.allocateIp()).toEqual('10.20.30.19');
|
|
});
|
|
|
|
tap.test('NetworkManager - allocateIp() should fail when subnet is exhausted', async () => {
|
|
const nm = new NetworkManager({ subnet: '192.168.100.0/30' });
|
|
expect(nm.allocateIp()).toEqual('192.168.100.2');
|
|
|
|
let error: unknown;
|
|
try {
|
|
nm.allocateIp();
|
|
} catch (err) {
|
|
error = err;
|
|
}
|
|
|
|
expect(error).toBeInstanceOf(SmartVMError);
|
|
expect((error as SmartVMError).code).toEqual('IP_EXHAUSTED');
|
|
});
|
|
|
|
tap.test('NetworkManager - constructor should reject invalid subnet input', async () => {
|
|
let error: unknown;
|
|
try {
|
|
new NetworkManager({ subnet: '10.0.0.0/31' });
|
|
} catch (err) {
|
|
error = err;
|
|
}
|
|
|
|
expect(error).toBeInstanceOf(SmartVMError);
|
|
expect((error as SmartVMError).code).toEqual('INVALID_SUBNET');
|
|
});
|
|
|
|
tap.test('NetworkManager - constructor should reject malformed IPv4 octets', async () => {
|
|
for (const subnet of ['10..0.1/24', '10.0x10.0.1/24', '10.0.0.1 /24']) {
|
|
let error: unknown;
|
|
try {
|
|
new NetworkManager({ subnet });
|
|
} catch (err) {
|
|
error = err;
|
|
}
|
|
|
|
expect(error).toBeInstanceOf(SmartVMError);
|
|
expect((error as SmartVMError).code).toEqual('INVALID_SUBNET');
|
|
}
|
|
});
|
|
|
|
tap.test('NetworkManager - constructor should reject invalid bridge names', async () => {
|
|
let error: unknown;
|
|
try {
|
|
new NetworkManager({ bridgeName: 'bad bridge' });
|
|
} catch (err) {
|
|
error = err;
|
|
}
|
|
|
|
expect(error).toBeInstanceOf(SmartVMError);
|
|
expect((error as SmartVMError).code).toEqual('INVALID_INTERFACE_NAME');
|
|
});
|
|
|
|
tap.test('NetworkManager - generateMac() should generate locally-administered MACs', async () => {
|
|
const nm = new NetworkManager();
|
|
const mac1 = nm.generateMac('vm1', 'eth0');
|
|
const mac2 = nm.generateMac('vm2', 'eth0');
|
|
const mac3 = nm.generateMac('vm1', 'eth0');
|
|
|
|
// Should start with 02: (locally administered)
|
|
expect(mac1.startsWith('02:')).toBeTrue();
|
|
expect(mac2.startsWith('02:')).toBeTrue();
|
|
|
|
// Same inputs should produce same MAC (deterministic)
|
|
expect(mac1).toEqual(mac3);
|
|
|
|
// Different inputs should produce different MACs
|
|
expect(mac1).not.toEqual(mac2);
|
|
|
|
// Should be valid MAC format (6 hex pairs separated by colons)
|
|
expect(mac1).toMatch(/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/);
|
|
});
|
|
|
|
tap.test('NetworkManager - generateTapName() should respect IFNAMSIZ limit', async () => {
|
|
const nm = new NetworkManager();
|
|
const name1 = nm.generateTapName('abcdefgh-1234-5678', 'eth0');
|
|
const name2 = nm.generateTapName('short', 'eth0');
|
|
|
|
// Should not exceed 15 characters
|
|
expect(name1.length).toBeLessThanOrEqual(15);
|
|
expect(name2.length).toBeLessThanOrEqual(15);
|
|
|
|
// Should start with 'sv' prefix
|
|
expect(name1.startsWith('sv')).toBeTrue();
|
|
expect(name2.startsWith('sv')).toBeTrue();
|
|
});
|
|
|
|
tap.test('NetworkManager - getGuestNetworkBootArgs() should format correctly', async () => {
|
|
const nm = new NetworkManager();
|
|
const tap = {
|
|
tapName: 'svtest0eth0',
|
|
guestIp: '172.30.0.2',
|
|
gatewayIp: '172.30.0.1',
|
|
subnetMask: '255.255.255.0',
|
|
mac: '02:00:00:00:00:01',
|
|
};
|
|
const bootArgs = nm.getGuestNetworkBootArgs(tap);
|
|
expect(bootArgs).toEqual('ip=172.30.0.2::172.30.0.1:255.255.255.0::eth0:off');
|
|
});
|
|
|
|
tap.test('NetworkManager - constructor should accept valid egress firewall config', async () => {
|
|
const nm = new NetworkManager({
|
|
firewall: {
|
|
egress: {
|
|
defaultAction: 'deny',
|
|
rules: [
|
|
{ action: 'allow', to: '1.1.1.1', protocol: 'tcp', ports: [80, 443] },
|
|
{ action: 'deny', to: '10.0.0.0/8' },
|
|
{ action: 'allow', protocol: 'icmp' },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(nm).toBeTruthy();
|
|
});
|
|
|
|
tap.test('NetworkManager - constructor should reject invalid firewall CIDRs and ports', async () => {
|
|
const invalidCidrError = getThrownError(() => new NetworkManager({
|
|
firewall: {
|
|
egress: {
|
|
rules: [{ action: 'allow', to: '300.1.1.1/32' }],
|
|
},
|
|
},
|
|
}));
|
|
expect(invalidCidrError).toBeInstanceOf(SmartVMError);
|
|
expect((invalidCidrError as SmartVMError).code).toEqual('INVALID_FIREWALL_CONFIG');
|
|
|
|
const invalidPortError = getThrownError(() => new NetworkManager({
|
|
firewall: {
|
|
egress: {
|
|
rules: [{ action: 'allow', protocol: 'icmp', ports: 53 }],
|
|
},
|
|
},
|
|
}));
|
|
expect(invalidPortError).toBeInstanceOf(SmartVMError);
|
|
expect((invalidPortError as SmartVMError).code).toEqual('INVALID_FIREWALL_CONFIG');
|
|
});
|
|
|
|
tap.test('NetworkManager - constructor should accept valid WireGuard managed config', async () => {
|
|
const nm = new NetworkManager({
|
|
wireguard: {
|
|
interfaceName: 'svwgtest0',
|
|
routeTable: 51821,
|
|
config: `
|
|
# comments are ignored
|
|
[Interface]
|
|
PrivateKey = test-private-key
|
|
Address = 10.70.0.2/32
|
|
DNS = 1.1.1.1
|
|
MTU = 1420
|
|
Table = off
|
|
|
|
[Peer]
|
|
PublicKey = test-public-key
|
|
AllowedIPs = 0.0.0.0/0
|
|
Endpoint = 203.0.113.10:51820
|
|
PersistentKeepalive = 25
|
|
`,
|
|
},
|
|
});
|
|
|
|
expect(nm).toBeTruthy();
|
|
});
|
|
|
|
tap.test('NetworkManager - constructor should reject unsafe WireGuard hook fields', async () => {
|
|
const error = getThrownError(() => new NetworkManager({
|
|
wireguard: {
|
|
config: `
|
|
[Interface]
|
|
PrivateKey = test-private-key
|
|
Address = 10.70.0.2/32
|
|
PostUp = iptables -A OUTPUT -j ACCEPT
|
|
`,
|
|
},
|
|
}));
|
|
|
|
expect(error).toBeInstanceOf(SmartVMError);
|
|
expect((error as SmartVMError).code).toEqual('INVALID_WIREGUARD_CONFIG');
|
|
});
|
|
|
|
tap.test('NetworkManager - constructor should reject IPv6 WireGuard AllowedIPs', async () => {
|
|
const error = getThrownError(() => new NetworkManager({
|
|
wireguard: {
|
|
config: `
|
|
[Interface]
|
|
PrivateKey = test-private-key
|
|
Address = 10.70.0.2/32
|
|
|
|
[Peer]
|
|
PublicKey = test-public-key
|
|
AllowedIPs = ::/0
|
|
`,
|
|
},
|
|
}));
|
|
|
|
expect(error).toBeInstanceOf(SmartVMError);
|
|
expect((error as SmartVMError).code).toEqual('INVALID_WIREGUARD_CONFIG');
|
|
});
|
|
|
|
tap.test('NetworkManager - constructor should reject mixed WireGuard modes', async () => {
|
|
const error = getThrownError(() => new NetworkManager({
|
|
wireguard: {
|
|
existingInterface: 'wg0',
|
|
config: '[Interface]\nPrivateKey = test-private-key\nAddress = 10.70.0.2/32\n',
|
|
} as any,
|
|
}));
|
|
|
|
expect(error).toBeInstanceOf(SmartVMError);
|
|
expect((error as SmartVMError).code).toEqual('INVALID_WIREGUARD_CONFIG');
|
|
});
|
|
|
|
// ============================================================
|
|
// MicroVM Tests
|
|
// ============================================================
|
|
|
|
tap.test('MicroVM - invalid lifecycle calls should throw SmartVMError', async () => {
|
|
const vm = new MicroVM(
|
|
'lifecycle-vm',
|
|
sampleConfig,
|
|
'/bin/false',
|
|
'/tmp/smartvm-lifecycle.sock',
|
|
new NetworkManager(),
|
|
);
|
|
|
|
const pauseError = await getRejectedError(vm.pause());
|
|
expect(pauseError).toBeInstanceOf(SmartVMError);
|
|
expect((pauseError as SmartVMError).code).toEqual('INVALID_STATE');
|
|
|
|
const infoError = await getRejectedError(vm.getInfo());
|
|
expect(infoError).toBeInstanceOf(SmartVMError);
|
|
expect((infoError as SmartVMError).code).toEqual('NO_CLIENT');
|
|
});
|
|
|
|
tap.test('MicroVM - start() should stage writable drives ephemerally and clean them up on failure', async () => {
|
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-ephemeral-drive-test-'));
|
|
const runtimeDir = path.join(workDir, 'runtime');
|
|
const sourceRootfs = path.join(workDir, 'rootfs.ext4');
|
|
await fs.promises.writeFile(sourceRootfs, 'persistent-rootfs');
|
|
|
|
const config: IMicroVMConfig = {
|
|
...sampleConfig,
|
|
id: 'ephemeral-vm',
|
|
drives: [
|
|
{
|
|
driveId: 'rootfs',
|
|
pathOnHost: sourceRootfs,
|
|
isRootDevice: true,
|
|
isReadOnly: false,
|
|
},
|
|
],
|
|
networkInterfaces: [],
|
|
};
|
|
const vm = new MicroVM(
|
|
'ephemeral-vm',
|
|
config,
|
|
'/bin/false',
|
|
path.join(runtimeDir, 'ephemeral-vm', 'firecracker.sock'),
|
|
new NetworkManager(),
|
|
{ runtimeDir },
|
|
);
|
|
|
|
try {
|
|
const error = await getRejectedError(vm.start());
|
|
expect(error).toBeInstanceOf(SmartVMError);
|
|
expect(fs.existsSync(path.join(runtimeDir, 'ephemeral-vm'))).toBeFalse();
|
|
expect(await fs.promises.readFile(sourceRootfs, 'utf8')).toEqual('persistent-rootfs');
|
|
expect(vm.getVMConfig().config.drives![0].pathOnHost).not.toEqual(sourceRootfs);
|
|
} finally {
|
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
tap.test('MicroVM - start() should honor per-drive ephemeral opt-out', async () => {
|
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-persistent-drive-test-'));
|
|
const runtimeDir = path.join(workDir, 'runtime');
|
|
const sourceRootfs = path.join(workDir, 'rootfs.ext4');
|
|
await fs.promises.writeFile(sourceRootfs, 'persistent-rootfs');
|
|
|
|
const config: IMicroVMConfig = {
|
|
...sampleConfig,
|
|
id: 'persistent-vm',
|
|
drives: [
|
|
{
|
|
driveId: 'rootfs',
|
|
pathOnHost: sourceRootfs,
|
|
isRootDevice: true,
|
|
isReadOnly: false,
|
|
ephemeral: false,
|
|
},
|
|
],
|
|
networkInterfaces: [],
|
|
};
|
|
const vm = new MicroVM(
|
|
'persistent-vm',
|
|
config,
|
|
'/bin/false',
|
|
path.join(runtimeDir, 'persistent-vm', 'firecracker.sock'),
|
|
new NetworkManager(),
|
|
{ runtimeDir },
|
|
);
|
|
|
|
try {
|
|
const error = await getRejectedError(vm.start());
|
|
expect(error).toBeInstanceOf(SmartVMError);
|
|
expect(vm.getVMConfig().config.drives![0].pathOnHost).toEqual(sourceRootfs);
|
|
expect(fs.existsSync(path.join(runtimeDir, 'persistent-vm'))).toBeFalse();
|
|
} finally {
|
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// SmartVM Tests
|
|
// ============================================================
|
|
|
|
tap.test('SmartVM - instantiation with defaults', async () => {
|
|
const smartvm = new SmartVM();
|
|
expect(smartvm).toBeTruthy();
|
|
expect(smartvm.imageManager).toBeTruthy();
|
|
expect(smartvm.baseImageManager).toBeTruthy();
|
|
expect(smartvm.networkManager).toBeTruthy();
|
|
if (fs.existsSync('/dev/shm')) {
|
|
expect(smartvm.getRuntimeDir()).toEqual('/dev/shm/.smartvm/runtime');
|
|
}
|
|
expect(smartvm.vmCount).toEqual(0);
|
|
expect(smartvm.listVMs()).toHaveLength(0);
|
|
});
|
|
|
|
tap.test('SmartVM - instantiation with custom options', async () => {
|
|
const smartvm = new SmartVM({
|
|
dataDir: '/tmp/smartvm-test',
|
|
arch: 'aarch64',
|
|
bridgeName: 'testbr0',
|
|
subnet: '10.0.0.0/24',
|
|
});
|
|
expect(smartvm).toBeTruthy();
|
|
});
|
|
|
|
tap.test('SmartVM - should forward firewall and WireGuard options to NetworkManager', async () => {
|
|
const firewall = {
|
|
egress: {
|
|
defaultAction: 'deny' as const,
|
|
rules: [{ action: 'allow' as const, to: '1.1.1.1', protocol: 'tcp' as const, ports: 443 }],
|
|
},
|
|
};
|
|
const wireguard = {
|
|
existingInterface: 'wgsmartvm0',
|
|
routeAllVmTraffic: false,
|
|
};
|
|
const smartvm = new SmartVM({
|
|
dataDir: '/tmp/smartvm-test',
|
|
firecrackerBinaryPath: '/bin/false',
|
|
firewall,
|
|
wireguard,
|
|
});
|
|
|
|
try {
|
|
expect((smartvm.networkManager as any).firewall).toEqual(firewall);
|
|
expect((smartvm.networkManager as any).wireguard).toEqual(wireguard);
|
|
} finally {
|
|
await smartvm.cleanup();
|
|
}
|
|
});
|
|
|
|
tap.test('SmartVM - createVM() should track created VMs', async () => {
|
|
const smartvm = new SmartVM({
|
|
dataDir: '/tmp/smartvm-test',
|
|
firecrackerBinaryPath: '/bin/false',
|
|
});
|
|
const vm = await smartvm.createVM(sampleConfig);
|
|
|
|
expect(smartvm.vmCount).toEqual(1);
|
|
expect(smartvm.getVM(vm.id)).toEqual(vm);
|
|
expect(smartvm.listVMs()).toEqual([vm.id]);
|
|
|
|
await smartvm.cleanup();
|
|
expect(smartvm.vmCount).toEqual(0);
|
|
});
|
|
|
|
export default tap.start();
|