12 Commits

23 changed files with 5191 additions and 2358 deletions
+28
View File
@@ -0,0 +1,28 @@
{
"@git.zone/cli": {
"projectType": "npm",
"module": {
"githost": "code.foss.global",
"gitscope": "push.rocks",
"gitrepo": "smartvm",
"description": "A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs",
"npmPackagename": "@push.rocks/smartvm",
"license": "MIT",
"projectDomain": "push.rocks"
},
"release": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
}
},
"@git.zone/tsdoc": {
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
},
"@ship.zone/szci": {
"npmGlobalTools": [],
"npmRegistryUrl": "registry.npmjs.org"
}
}
@@ -0,0 +1,22 @@
{
"schemaVersion": 1,
"bundleId": "smartvm-minimal-v1-x86_64",
"name": "SmartVM minimal x86_64 bundle",
"arch": "x86_64",
"firecrackerVersion": "v1.15.1",
"rootfsType": "ext4",
"rootfsIsReadOnly": false,
"bootArgs": "console=ttyS0 reboot=k panic=1 pci=off",
"kernel": {
"url": "https://assets.example.com/push.rocks/smartvm/base-images/smartvm-minimal-v1/x86_64/vmlinux",
"fileName": "vmlinux",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"sizeBytes": 12345678
},
"rootfs": {
"url": "https://assets.example.com/push.rocks/smartvm/base-images/smartvm-minimal-v1/x86_64/rootfs.ext4",
"fileName": "rootfs.ext4",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"sizeBytes": 12345678
}
}
+45
View File
@@ -1,5 +1,50 @@
# Changelog # Changelog
## 2026-05-01 - 1.4.1 - fix(readme)
improve documentation for Firecracker runtime, networking, and base image usage
- expand the project overview and feature summary with clearer runtime, networking, firewall, and WireGuard behavior
- clarify host requirements, root privilege expectations, and optional tooling for blank rootfs creation
- add quick-start outcome details and stronger guidance for using hosted base image manifests in production
## 2026-05-01 - 1.4.0 - feat(network)
add configurable VM egress firewall policies and WireGuard-based host routing
- adds firewall and wireguard options to SmartVM and NetworkManager configuration interfaces
- validates firewall rules, WireGuard managed configs, and unsupported hook or IPv6 settings with dedicated error codes
- sets up iptables egress chains, NAT, policy routing, and cleanup for managed VM subnet traffic
- documents the new networking capabilities and adds tests for config validation and option forwarding
## 2026-05-01 - 1.3.1 - fix(docs)
remove outdated base image bundle readme and consolidate hosted manifest documentation
- Deletes the dedicated assets/base-images/readme.md documentation file
- Keeps hosted base image manifest guidance and example usage in the main project README
## 2026-05-01 - 1.3.0 - feat(runtime)
stage VM runtime artifacts and writable drives in per-VM ephemeral storage by default
- default runtime files to /dev/shm/.smartvm/runtime when available, with per-VM socket and drive staging paths
- copy writable drives into per-VM runtime storage before boot and remove them during cleanup, with per-drive and global opt-out controls
- prefer squashfs rootfs images over ext4 when resolving Firecracker CI base images
- add tests and documentation for ephemeral drive staging and runtime directory defaults
## 2026-05-01 - 1.2.0 - feat(base-images)
add managed base image bundles with cache retention, hosted manifests, and opt-in integration boot testing
- introduces BaseImageManager with support for Firecracker CI presets and hosted manifest-based kernel/rootfs bundles
- adds SmartVM.ensureBaseImage() and exports new base image types and manager APIs
- validates and verifies downloaded base image artifacts with checksums and bounded cache eviction
- hardens process, socket, network, and config handling with safer spawning, subnet/interface validation, and expanded tests
## 2026-04-30 - 1.1.1 - fix(build)
tighten TypeScript compiler settings and harden error message handling
- enable strict noImplicitAny checks and include Node types in the TypeScript configuration
- remove the implicit any override from the build script so compiler strictness is enforced during builds
- handle non-Error thrown values safely when wrapping startup and network setup failures
- update package metadata and bundled files to include project configuration and license assets
## 2026-02-08 - 1.1.0 - feat(release) ## 2026-02-08 - 1.1.0 - feat(release)
add release configuration with npm registries and public access; add @ship.zone/szci placeholder add release configuration with npm registries and public access; add @ship.zone/szci placeholder
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 Task Venture Capital GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+13 -9
View File
@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartvm", "name": "@push.rocks/smartvm",
"version": "1.1.0", "version": "1.4.1",
"private": false, "private": false,
"description": "A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs", "description": "A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs",
"type": "module", "type": "module",
@@ -14,7 +14,7 @@
}, },
"scripts": { "scripts": {
"test": "(tstest test/ --verbose)", "test": "(tstest test/ --verbose)",
"build": "(tsbuild --web --allowimplicitany)" "build": "(tsbuild --web)"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -23,21 +23,25 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@push.rocks/smartdelay": "^3.0.5", "@push.rocks/smartdelay": "^3.0.5",
"@push.rocks/smartexit": "^1.0.22", "@push.rocks/smartexit": "^2.0.3",
"@push.rocks/smartrequest": "^5.0.1", "@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartshell": "^3.2.3", "@push.rocks/smartshell": "^3.3.8",
"@push.rocks/smartunique": "^3.0.9" "@push.rocks/smartunique": "^3.0.9"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^4.1.2", "@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsrun": "^2.0.1", "@git.zone/tsrun": "^2.0.3",
"@git.zone/tstest": "^3.1.8", "@git.zone/tstest": "^3.6.3",
"@types/node": "^25.2.2" "@types/node": "^25.6.0"
}, },
"files": [ "files": [
"ts/**/*", "ts/**/*",
"dist_ts/**/*", "dist_ts/**/*",
"assets/**/*", "assets/**/*",
".smartconfig.json",
"license",
"npmextra.json",
"readme.md" "readme.md"
] ],
"packageManager": "pnpm@10.28.2"
} }
+1545 -1902
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -5,6 +5,11 @@
- Uses `@push.rocks/smartrequest` with URL format `http://unix:<socket>:<path>` for socket communication - Uses `@push.rocks/smartrequest` with URL format `http://unix:<socket>:<path>` for socket communication
- Uses `@push.rocks/smartshell` `execStreaming()` to manage Firecracker child processes - Uses `@push.rocks/smartshell` `execStreaming()` to manage Firecracker child processes
- Uses `@push.rocks/smartexit` for cleanup on process exit - Uses `@push.rocks/smartexit` for cleanup on process exit
- `BaseImageManager` downloads Firecracker CI demo artifacts or hosted project manifests into `/tmp/.smartvm/base-images` by default
- Base image cache keeps 2 bundles by default and warns before evicting older bundles
- Hosted manifest examples live in `assets/base-images/`
- VM runtime files default to `/dev/shm/.smartvm/runtime` when available
- Writable drives are staged into per-VM runtime storage by default and removed during cleanup; use `ephemeral: false` only for explicit persistence
## Key API Patterns ## Key API Patterns
- SmartRequest: `SmartRequest.create().url(...).json(body).put()` — response has `.status`, `.ok`, `.json()` - SmartRequest: `SmartRequest.create().url(...).json(body).put()` — response has `.status`, `.ok`, `.json()`
@@ -16,3 +21,9 @@
- Start: PUT /actions { action_type: "InstanceStart" } - Start: PUT /actions { action_type: "InstanceStart" }
- Pause/Resume: PATCH /vm { state: "Paused" | "Resumed" } - Pause/Resume: PATCH /vm { state: "Paused" | "Resumed" }
- Stop: PUT /actions { action_type: "SendCtrlAltDel" } - Stop: PUT /actions { action_type: "SendCtrlAltDel" }
## Integration Testing
- Default `pnpm test` skips real Firecracker boot testing
- Set `SMARTVM_RUN_INTEGRATION=true` to run the opt-in boot test
- `SMARTVM_BASE_IMAGE_PRESET` supports `latest` and `lts`; default is `latest`
- Hosted/project-owned bundles use `baseImageManifestUrl`, `baseImageManifestPath`, `manifestUrl`, or `manifestPath`
+614 -285
View File
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { BaseImageManager, SmartVM } from '../ts/index.js';
import type { TBaseImagePreset, TFirecrackerArch } from '../ts/index.js';
const integrationEnabled = ['1', 'true', 'yes'].includes(
(process.env.SMARTVM_RUN_INTEGRATION || '').toLowerCase(),
);
function getHostArch(): TFirecrackerArch {
return process.arch === 'arm64' ? 'aarch64' : 'x86_64';
}
async function assertHostReady(): Promise<void> {
if (process.platform !== 'linux') {
throw new Error('Firecracker integration tests require Linux');
}
await fs.promises.access('/dev/kvm', fs.constants.R_OK | fs.constants.W_OK);
}
tap.test('SmartVM integration - boots a Firecracker CI base image when explicitly enabled', async () => {
if (!integrationEnabled) {
console.log('Skipping SmartVM integration test. Set SMARTVM_RUN_INTEGRATION=true to enable it.');
return;
}
await assertHostReady();
const arch = (process.env.SMARTVM_ARCH as TFirecrackerArch | undefined) || getHostArch();
const preset = (process.env.SMARTVM_BASE_IMAGE_PRESET as TBaseImagePreset | undefined) || 'latest';
const maxStoredBaseImages = process.env.SMARTVM_MAX_STORED_BASE_IMAGES
? Number(process.env.SMARTVM_MAX_STORED_BASE_IMAGES)
: undefined;
const baseImageManager = new BaseImageManager({
arch,
cacheDir: process.env.SMARTVM_BASE_IMAGE_CACHE_DIR,
maxStoredBaseImages,
hostedManifestUrl: process.env.SMARTVM_BASE_IMAGE_MANIFEST_URL,
hostedManifestPath: process.env.SMARTVM_BASE_IMAGE_MANIFEST_PATH,
});
const baseImage = await baseImageManager.ensureBaseImage({
preset,
manifestUrl: process.env.SMARTVM_BASE_IMAGE_MANIFEST_URL,
manifestPath: process.env.SMARTVM_BASE_IMAGE_MANIFEST_PATH,
});
const smartvm = new SmartVM({
arch,
dataDir: process.env.SMARTVM_INTEGRATION_DATA_DIR || path.join(os.tmpdir(), '.smartvm-integration'),
firecrackerVersion: process.env.SMARTVM_FIRECRACKER_VERSION || baseImage.firecrackerVersion,
baseImageCacheDir: process.env.SMARTVM_BASE_IMAGE_CACHE_DIR,
maxStoredBaseImages,
baseImageManifestUrl: process.env.SMARTVM_BASE_IMAGE_MANIFEST_URL,
baseImageManifestPath: process.env.SMARTVM_BASE_IMAGE_MANIFEST_PATH,
});
const vm = await smartvm.createVM({
id: `smartvm-it-${Date.now()}`,
bootSource: {
kernelImagePath: baseImage.kernelImagePath,
bootArgs: baseImage.bootArgs,
},
machineConfig: {
vcpuCount: 1,
memSizeMib: 256,
},
drives: [
{
driveId: 'rootfs',
pathOnHost: baseImage.rootfsPath,
isRootDevice: true,
isReadOnly: baseImage.rootfsIsReadOnly,
},
],
});
try {
await vm.start();
expect(vm.state).toEqual('running');
expect(await vm.getInfo()).toBeTruthy();
} finally {
if (vm.state === 'running' || vm.state === 'paused') {
await vm.stop();
}
await vm.cleanup();
await smartvm.cleanup();
}
});
export default tap.start();
+695 -1
View File
@@ -1,11 +1,40 @@
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 { tap, expect } from '@git.zone/tstest/tapbundle';
import { import {
BaseImageManager,
VMConfig, VMConfig,
SocketClient, SocketClient,
NetworkManager, NetworkManager,
MicroVM,
SmartVM, SmartVM,
SmartVMError,
} from '../ts/index.js'; } from '../ts/index.js';
import type { IMicroVMConfig } 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 // VMConfig Tests
@@ -87,6 +116,33 @@ tap.test('VMConfig - validate() should fail for multiple root drives', async ()
expect(result.valid).toBeFalse(); 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 () => { tap.test('VMConfig - toBootSourcePayload() should generate correct snake_case', async () => {
const vmConfig = new VMConfig(sampleConfig); const vmConfig = new VMConfig(sampleConfig);
const payload = vmConfig.toBootSourcePayload(); const payload = vmConfig.toBootSourcePayload();
@@ -144,6 +200,65 @@ tap.test('VMConfig - toBalloonPayload() should generate correct payload', async
expect(payload!.stats_polling_interval_s).toEqual(5); 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 () => { tap.test('VMConfig - toLoggerPayload() should generate correct payload', async () => {
const config: IMicroVMConfig = { const config: IMicroVMConfig = {
...sampleConfig, ...sampleConfig,
@@ -167,6 +282,269 @@ tap.test('SocketClient - URL construction', async () => {
expect(client).toBeTruthy(); 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 // NetworkManager Tests
// ============================================================ // ============================================================
@@ -181,6 +559,65 @@ tap.test('NetworkManager - allocateIp() should allocate sequential IPs', async (
expect(ip3).toEqual('172.30.0.4'); 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 () => { tap.test('NetworkManager - generateMac() should generate locally-administered MACs', async () => {
const nm = new NetworkManager(); const nm = new NetworkManager();
const mac1 = nm.generateMac('vm1', 'eth0'); const mac1 = nm.generateMac('vm1', 'eth0');
@@ -228,6 +665,218 @@ tap.test('NetworkManager - getGuestNetworkBootArgs() should format correctly', a
expect(bootArgs).toEqual('ip=172.30.0.2::172.30.0.1:255.255.255.0::eth0:off'); 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 // SmartVM Tests
// ============================================================ // ============================================================
@@ -236,7 +885,11 @@ tap.test('SmartVM - instantiation with defaults', async () => {
const smartvm = new SmartVM(); const smartvm = new SmartVM();
expect(smartvm).toBeTruthy(); expect(smartvm).toBeTruthy();
expect(smartvm.imageManager).toBeTruthy(); expect(smartvm.imageManager).toBeTruthy();
expect(smartvm.baseImageManager).toBeTruthy();
expect(smartvm.networkManager).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.vmCount).toEqual(0);
expect(smartvm.listVMs()).toHaveLength(0); expect(smartvm.listVMs()).toHaveLength(0);
}); });
@@ -251,4 +904,45 @@ tap.test('SmartVM - instantiation with custom options', async () => {
expect(smartvm).toBeTruthy(); 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(); export default tap.start();
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartvm', name: '@push.rocks/smartvm',
version: '1.1.0', version: '1.4.1',
description: 'A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs' description: 'A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs'
} }
+713
View File
@@ -0,0 +1,713 @@
import * as plugins from './plugins.js';
import type {
IBaseImageArtifactManifest,
IBaseImageBundle,
IBaseImageHostedManifest,
IBaseImageManagerOptions,
IEnsureBaseImageOptions,
TBaseImagePreset,
TBaseImageRootfsType,
TFirecrackerArch,
} from './interfaces/index.js';
import { SmartVMError } from './interfaces/index.js';
const FIRECRACKER_CI_BUCKET_URL = 'https://s3.amazonaws.com/spec.ccfc.min';
const DEFAULT_MAX_STORED_BASE_IMAGES = 2;
const LTS_CI_VERSION = 'v1.7';
const LTS_FIRECRACKER_VERSION = 'v1.7.0';
type TShellExecResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawn']>>;
interface IResolvedBaseImageSource {
preset: TBaseImagePreset;
arch: TFirecrackerArch;
ciVersion: string;
firecrackerVersion: string;
kernelKey?: string;
rootfsKey?: string;
kernelUrl?: string;
rootfsUrl?: string;
kernelSourcePath?: string;
rootfsSourcePath?: string;
kernelFileName?: string;
rootfsFileName?: string;
expectedKernelSha256?: string;
expectedRootfsSha256?: string;
expectedKernelBytes?: number;
expectedRootfsBytes?: number;
rootfsType: TBaseImageRootfsType;
rootfsIsReadOnly: boolean;
bundleId: string;
bootArgs: string;
source: IBaseImageBundle['source'];
}
function getErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/**
* Downloads and retains Firecracker CI base images for integration testing.
*/
export class BaseImageManager {
private arch: TFirecrackerArch;
private cacheDir: string;
private maxStoredBaseImages: number;
private hostedManifestUrl?: string;
private hostedManifestPath?: string;
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
constructor(options: IBaseImageManagerOptions = {}) {
this.arch = options.arch || 'x86_64';
this.cacheDir = options.cacheDir || plugins.path.join(plugins.os.tmpdir(), '.smartvm', 'base-images');
this.maxStoredBaseImages = options.maxStoredBaseImages ?? DEFAULT_MAX_STORED_BASE_IMAGES;
this.hostedManifestUrl = options.hostedManifestUrl;
this.hostedManifestPath = options.hostedManifestPath;
if (!Number.isInteger(this.maxStoredBaseImages) || this.maxStoredBaseImages < 1) {
throw new SmartVMError(
'maxStoredBaseImages must be a positive integer',
'INVALID_BASE_IMAGE_CACHE_LIMIT',
);
}
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
}
public getCacheDir(): string {
return this.cacheDir;
}
public getMaxStoredBaseImages(): number {
return this.maxStoredBaseImages;
}
/**
* Ensure a base image bundle exists locally and return its paths.
*/
public async ensureBaseImage(options: IEnsureBaseImageOptions = {}): Promise<IBaseImageBundle> {
const source = await this.resolveBaseImageSource(options);
const bundleDir = plugins.path.join(this.cacheDir, source.bundleId);
const manifestPath = this.getManifestPath(bundleDir);
const cachedBundle = options.forceDownload ? undefined : await this.readCompleteBundle(bundleDir);
if (cachedBundle) {
const updatedBundle = {
...cachedBundle,
lastAccessedAt: new Date().toISOString(),
};
await this.writeBundleManifest(updatedBundle);
await this.pruneBaseImageCache(updatedBundle.bundleId);
return updatedBundle;
}
await plugins.fs.promises.mkdir(bundleDir, { recursive: true });
const kernelFileName = source.kernelFileName || this.getSourceFileName(source.kernelUrl || source.kernelSourcePath || source.kernelKey!, 'vmlinux');
const rootfsFileName = source.rootfsFileName || this.getSourceFileName(source.rootfsUrl || source.rootfsSourcePath || source.rootfsKey!, `rootfs.${source.rootfsType}`);
const kernelPath = this.resolveBundleFilePath(bundleDir, kernelFileName);
const rootfsPath = this.resolveBundleFilePath(bundleDir, rootfsFileName);
try {
await this.prepareArtifact({
url: source.kernelUrl || (source.kernelKey ? this.keyToUrl(source.kernelKey) : undefined),
sourcePath: source.kernelSourcePath,
targetPath: kernelPath,
expectedSha256: source.expectedKernelSha256,
expectedBytes: source.expectedKernelBytes,
});
await this.prepareArtifact({
url: source.rootfsUrl || (source.rootfsKey ? this.keyToUrl(source.rootfsKey) : undefined),
sourcePath: source.rootfsSourcePath,
targetPath: rootfsPath,
expectedSha256: source.expectedRootfsSha256,
expectedBytes: source.expectedRootfsBytes,
});
const now = new Date().toISOString();
const bundle: IBaseImageBundle = {
preset: source.preset,
arch: source.arch,
ciVersion: source.ciVersion,
firecrackerVersion: source.firecrackerVersion,
bundleId: source.bundleId,
bundleDir,
kernelImagePath: kernelPath,
rootfsPath,
rootfsType: source.rootfsType,
rootfsIsReadOnly: source.rootfsIsReadOnly,
bootArgs: source.bootArgs,
source: source.source,
checksums: {
kernelSha256: await this.sha256File(kernelPath),
rootfsSha256: await this.sha256File(rootfsPath),
},
sizes: {
kernelBytes: (await plugins.fs.promises.stat(kernelPath)).size,
rootfsBytes: (await plugins.fs.promises.stat(rootfsPath)).size,
},
createdAt: now,
lastAccessedAt: now,
};
await this.writeBundleManifest(bundle);
await this.pruneBaseImageCache(bundle.bundleId);
return bundle;
} catch (err) {
await plugins.fs.promises.rm(bundleDir, { recursive: true, force: true });
throw new SmartVMError(
`Failed to prepare base image bundle ${source.bundleId}: ${getErrorMessage(err)}`,
'BASE_IMAGE_PREPARE_FAILED',
);
}
}
/**
* Prune cached base image bundles according to the retention limit.
*/
public async pruneBaseImageCache(keepBundleId?: string): Promise<string[]> {
await plugins.fs.promises.mkdir(this.cacheDir, { recursive: true });
const bundles = await this.listCachedBundles();
bundles.sort((a, b) => {
if (keepBundleId) {
if (a.bundleId === keepBundleId) return -1;
if (b.bundleId === keepBundleId) return 1;
}
return Date.parse(b.lastAccessedAt) - Date.parse(a.lastAccessedAt);
});
const evicted: string[] = [];
for (const bundle of bundles.slice(this.maxStoredBaseImages)) {
console.warn(
`[smartvm] Base image cache stores at most ${this.maxStoredBaseImages} bundle(s). ` +
`Evicting ${bundle.bundleId} from ${bundle.bundleDir}. Configure maxStoredBaseImages to change this behavior.`,
);
await plugins.fs.promises.rm(bundle.bundleDir, { recursive: true, force: true });
evicted.push(bundle.bundleId);
}
return evicted;
}
private async resolveBaseImageSource(options: IEnsureBaseImageOptions): Promise<IResolvedBaseImageSource> {
const arch = options.arch || this.arch;
const manifestUrl = options.manifestUrl || this.hostedManifestUrl;
const manifestPath = options.manifestPath || this.hostedManifestPath;
if (manifestUrl || manifestPath) {
return this.resolveHostedManifestSource({ arch, manifestUrl, manifestPath });
}
const preset = options.preset || 'latest';
if (preset === 'hosted') {
throw new SmartVMError(
'The hosted base image preset requires manifestUrl, manifestPath, or a manager-level hosted manifest option',
'BASE_IMAGE_MANIFEST_FAILED',
);
}
const firecrackerVersion = preset === 'latest'
? await this.getLatestFirecrackerVersion()
: LTS_FIRECRACKER_VERSION;
const ciVersion = preset === 'latest'
? firecrackerVersion.split('.').slice(0, 2).join('.')
: LTS_CI_VERSION;
const keys = await this.listCiKeys(ciVersion, arch);
const kernelKey = this.selectKernelKey(keys);
const rootfsKey = this.selectRootfsKey(keys);
const rootfsType = rootfsKey.endsWith('.ext4') ? 'ext4' : 'squashfs';
const bundleId = this.buildBundleId(preset, ciVersion, arch, kernelKey, rootfsKey);
return {
preset,
arch,
ciVersion,
firecrackerVersion,
kernelKey,
rootfsKey,
rootfsType,
rootfsIsReadOnly: rootfsType === 'squashfs',
bundleId,
bootArgs: this.buildBootArgs(arch, rootfsType),
source: {
type: 'firecracker-ci',
bucketUrl: FIRECRACKER_CI_BUCKET_URL,
kernelKey,
rootfsKey,
},
};
}
private async resolveHostedManifestSource(options: {
arch: TFirecrackerArch;
manifestUrl?: string;
manifestPath?: string;
}): Promise<IResolvedBaseImageSource> {
const manifest = await this.loadHostedManifest(options);
this.validateHostedManifest(manifest, options.arch);
this.getArtifactSource(manifest.kernel, 'kernel');
this.getArtifactSource(manifest.rootfs, 'rootfs');
return {
preset: 'hosted',
arch: manifest.arch,
ciVersion: 'hosted',
firecrackerVersion: manifest.firecrackerVersion,
kernelUrl: manifest.kernel.url,
rootfsUrl: manifest.rootfs.url,
kernelSourcePath: manifest.kernel.path,
rootfsSourcePath: manifest.rootfs.path,
kernelFileName: manifest.kernel.fileName,
rootfsFileName: manifest.rootfs.fileName,
expectedKernelSha256: manifest.kernel.sha256,
expectedRootfsSha256: manifest.rootfs.sha256,
expectedKernelBytes: manifest.kernel.sizeBytes,
expectedRootfsBytes: manifest.rootfs.sizeBytes,
rootfsType: manifest.rootfsType,
rootfsIsReadOnly: manifest.rootfsIsReadOnly ?? manifest.rootfsType === 'squashfs',
bundleId: this.sanitizeBundleId(manifest.bundleId),
bootArgs: manifest.bootArgs || this.buildBootArgs(manifest.arch, manifest.rootfsType),
source: {
type: 'hosted-manifest',
manifestUrl: options.manifestUrl,
manifestPath: options.manifestPath,
kernelUrl: manifest.kernel.url,
rootfsUrl: manifest.rootfs.url,
kernelSourcePath: manifest.kernel.path,
rootfsSourcePath: manifest.rootfs.path,
},
};
}
private async getLatestFirecrackerVersion(): Promise<string> {
try {
const result = await this.shell.execSpawn('curl', [
'-fsSLI',
'-o',
'/dev/null',
'-w',
'%{url_effective}',
'https://github.com/firecracker-microvm/firecracker/releases/latest',
], { silent: true });
if (result.exitCode !== 0) {
const output = (result.stderr || result.stdout || '').trim();
throw new Error(`curl exited with code ${result.exitCode}${output ? `: ${output}` : ''}`);
}
const match = result.stdout.trim().match(/\/releases\/tag\/(v\d+\.\d+\.\d+)$/);
if (!match) {
throw new Error(`Could not parse latest Firecracker release URL: ${result.stdout.trim()}`);
}
return match[1];
} catch (err) {
throw new SmartVMError(
`Failed to resolve latest Firecracker version: ${getErrorMessage(err)}`,
'VERSION_FETCH_FAILED',
);
}
}
private async loadHostedManifest(options: {
manifestUrl?: string;
manifestPath?: string;
}): Promise<IBaseImageHostedManifest> {
try {
let raw: string;
if (options.manifestPath) {
raw = await plugins.fs.promises.readFile(options.manifestPath, 'utf8');
} else if (options.manifestUrl) {
const response = await plugins.SmartRequest.create()
.url(options.manifestUrl)
.get();
raw = await response.text();
} else {
throw new Error('manifestUrl or manifestPath is required');
}
return JSON.parse(raw) as IBaseImageHostedManifest;
} catch (err) {
throw new SmartVMError(
`Failed to load hosted base image manifest: ${getErrorMessage(err)}`,
'BASE_IMAGE_MANIFEST_FAILED',
);
}
}
private validateHostedManifest(manifest: IBaseImageHostedManifest, expectedArch: TFirecrackerArch): void {
if (manifest.schemaVersion !== 1) {
throw new SmartVMError(
'Hosted base image manifest schemaVersion must be 1',
'INVALID_BASE_IMAGE_MANIFEST',
);
}
if (!manifest.bundleId || !/^[a-zA-Z0-9._-]+$/.test(manifest.bundleId)) {
throw new SmartVMError(
'Hosted base image manifest bundleId must use only letters, numbers, dot, underscore, and dash',
'INVALID_BASE_IMAGE_MANIFEST',
);
}
if (manifest.arch !== expectedArch) {
throw new SmartVMError(
`Hosted base image arch '${manifest.arch}' does not match requested arch '${expectedArch}'`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
if (!manifest.firecrackerVersion || !/^v\d+\.\d+\.\d+$/.test(manifest.firecrackerVersion)) {
throw new SmartVMError(
'Hosted base image manifest firecrackerVersion must look like v1.15.1',
'INVALID_BASE_IMAGE_MANIFEST',
);
}
if (manifest.rootfsType !== 'ext4' && manifest.rootfsType !== 'squashfs') {
throw new SmartVMError(
'Hosted base image manifest rootfsType must be ext4 or squashfs',
'INVALID_BASE_IMAGE_MANIFEST',
);
}
this.validateArtifactManifest(manifest.kernel, 'kernel');
this.validateArtifactManifest(manifest.rootfs, 'rootfs');
}
private validateArtifactManifest(artifact: IBaseImageArtifactManifest, label: string): void {
this.getArtifactSource(artifact, label);
if (artifact.fileName !== undefined) {
this.validateArtifactFileName(artifact.fileName, label);
}
if (artifact.url && !artifact.sha256) {
throw new SmartVMError(
`Hosted base image manifest ${label} artifact with url requires sha256`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
if (artifact.sha256 !== undefined && !/^[a-fA-F0-9]{64}$/.test(artifact.sha256)) {
throw new SmartVMError(
`Hosted base image manifest ${label} artifact sha256 must be a 64 character hex string`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
if (
artifact.sizeBytes !== undefined &&
(!Number.isInteger(artifact.sizeBytes) || artifact.sizeBytes < 0)
) {
throw new SmartVMError(
`Hosted base image manifest ${label} artifact sizeBytes must be a non-negative integer`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
}
private validateArtifactFileName(fileName: string, label: string): void {
if (
!fileName ||
fileName === '.' ||
fileName === '..' ||
fileName !== plugins.path.basename(fileName) ||
!/^[a-zA-Z0-9._-]+$/.test(fileName)
) {
throw new SmartVMError(
`Hosted base image manifest ${label} artifact fileName must be a plain file name using letters, numbers, dot, underscore, and dash`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
}
private getArtifactSource(artifact: { url?: string; path?: string }, label: string): string {
if (!artifact.url && !artifact.path) {
throw new SmartVMError(
`Hosted base image manifest ${label} artifact requires url or path`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
if (artifact.url && artifact.path) {
throw new SmartVMError(
`Hosted base image manifest ${label} artifact must not set both url and path`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
return artifact.url || artifact.path!;
}
private getSourceFileName(source: string, fallback: string): string {
let fileName: string;
try {
fileName = plugins.path.basename(new URL(source).pathname);
} catch {
fileName = plugins.path.basename(source);
}
return this.sanitizeFileName(fileName || fallback);
}
private resolveBundleFilePath(bundleDir: string, fileName: string): string {
const resolvedBundleDir = plugins.path.resolve(bundleDir);
const resolvedFilePath = plugins.path.resolve(resolvedBundleDir, this.sanitizeFileName(fileName));
if (!this.isPathInside(resolvedBundleDir, resolvedFilePath)) {
throw new SmartVMError(
`Resolved base image artifact path escapes bundle directory: ${fileName}`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
return resolvedFilePath;
}
private sanitizeFileName(fileName: string): string {
const sanitized = plugins.path.basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '_');
if (!sanitized || sanitized === '.' || sanitized === '..') {
return 'artifact';
}
return sanitized;
}
private sanitizeBundleId(bundleId: string): string {
return bundleId.replace(/[^a-zA-Z0-9._-]/g, '_');
}
private async listCiKeys(ciVersion: string, arch: TFirecrackerArch): Promise<string[]> {
const prefix = `firecracker-ci/${ciVersion}/${arch}/`;
try {
const response = await plugins.SmartRequest.create()
.url(`${FIRECRACKER_CI_BUCKET_URL}/?prefix=${encodeURIComponent(prefix)}&list-type=2`)
.get();
const body = await response.text();
const keys = Array.from(body.matchAll(/<Key>([^<]+)<\/Key>/g)).map((match) => this.decodeXml(match[1]));
if (keys.length === 0) {
throw new Error(`No Firecracker CI artifacts found for ${ciVersion}/${arch}`);
}
return keys;
} catch (err) {
throw new SmartVMError(
`Failed to list Firecracker CI artifacts for ${ciVersion}/${arch}: ${getErrorMessage(err)}`,
'BASE_IMAGE_RESOLVE_FAILED',
);
}
}
private selectKernelKey(keys: string[]): string {
const kernelKeys = keys.filter((key) => /\/vmlinux-\d+\.\d+\.\d+$/.test(key) && !key.includes('/debug/'));
if (kernelKeys.length === 0) {
throw new SmartVMError('No suitable Firecracker CI kernel image found', 'BASE_IMAGE_RESOLVE_FAILED');
}
return kernelKeys.sort((a, b) => this.compareKernelKeys(a, b)).at(-1)!;
}
private selectRootfsKey(keys: string[]): string {
const squashfsKeys = keys.filter((key) => /\/ubuntu-[^/]+\.squashfs$/.test(key));
if (squashfsKeys.length > 0) {
return squashfsKeys.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1)!;
}
const ext4Keys = keys.filter((key) => /\/ubuntu-[^/]+\.ext4$/.test(key));
if (ext4Keys.length > 0) {
return ext4Keys.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1)!;
}
throw new SmartVMError('No suitable Firecracker CI rootfs image found', 'BASE_IMAGE_RESOLVE_FAILED');
}
private compareKernelKeys(a: string, b: string): number {
const aParts = this.extractKernelVersion(a);
const bParts = this.extractKernelVersion(b);
for (let i = 0; i < 3; i++) {
if (aParts[i] !== bParts[i]) {
return aParts[i] - bParts[i];
}
}
return a.localeCompare(b);
}
private extractKernelVersion(key: string): [number, number, number] {
const match = key.match(/vmlinux-(\d+)\.(\d+)\.(\d+)$/);
if (!match) {
return [0, 0, 0];
}
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
private buildBundleId(
preset: TBaseImagePreset,
ciVersion: string,
arch: TFirecrackerArch,
kernelKey: string,
rootfsKey: string,
): string {
const rawId = [
preset,
ciVersion,
arch,
plugins.path.basename(kernelKey),
plugins.path.basename(rootfsKey),
].join('-');
return this.sanitizeBundleId(rawId);
}
private buildBootArgs(arch: TFirecrackerArch, rootfsType: TBaseImageRootfsType): string {
const args = ['console=ttyS0', 'reboot=k', 'panic=1', 'pci=off'];
if (arch === 'aarch64') {
args.unshift('keep_bootcon');
}
if (rootfsType === 'squashfs') {
args.push('ro', 'rootfstype=squashfs');
}
return args.join(' ');
}
private keyToUrl(key: string): string {
return `${FIRECRACKER_CI_BUCKET_URL}/${key}`;
}
private async prepareArtifact(options: {
url?: string;
sourcePath?: string;
targetPath: string;
expectedSha256?: string;
expectedBytes?: number;
}): Promise<void> {
if (options.sourcePath) {
await plugins.fs.promises.copyFile(options.sourcePath, options.targetPath);
} else if (options.url) {
await this.downloadFile(options.url, options.targetPath);
} else {
throw new Error('Artifact requires url or sourcePath');
}
const stat = await plugins.fs.promises.stat(options.targetPath);
if (options.expectedBytes !== undefined && stat.size !== options.expectedBytes) {
throw new Error(
`Artifact ${options.targetPath} size mismatch: expected ${options.expectedBytes}, got ${stat.size}`,
);
}
if (options.expectedSha256) {
const actualSha256 = await this.sha256File(options.targetPath);
if (actualSha256.toLowerCase() !== options.expectedSha256.toLowerCase()) {
throw new Error(
`Artifact ${options.targetPath} SHA256 mismatch: expected ${options.expectedSha256}, got ${actualSha256}`,
);
}
}
}
private async downloadFile(url: string, targetPath: string): Promise<TShellExecResult> {
await plugins.fs.promises.mkdir(plugins.path.dirname(targetPath), { recursive: true });
const tempPath = `${targetPath}.download`;
await plugins.fs.promises.rm(tempPath, { force: true });
const result = await this.shell.execSpawn('curl', ['-fSL', '-o', tempPath, url], { silent: true });
if (result.exitCode !== 0) {
const output = (result.stderr || result.stdout || '').trim();
throw new Error(`curl failed for ${url} with code ${result.exitCode}${output ? `: ${output}` : ''}`);
}
await plugins.fs.promises.rename(tempPath, targetPath);
return result;
}
private async sha256File(filePath: string): Promise<string> {
const hash = plugins.crypto.createHash('sha256');
const stream = plugins.fs.createReadStream(filePath);
for await (const chunk of stream) {
hash.update(chunk);
}
return hash.digest('hex');
}
private async readCompleteBundle(bundleDir: string): Promise<IBaseImageBundle | undefined> {
const manifestPath = this.getManifestPath(bundleDir);
try {
const bundle = {
...await this.readBundleManifest(manifestPath),
bundleDir,
};
await this.verifyCachedBundle(bundle);
return bundle;
} catch {
return undefined;
}
}
private async verifyCachedBundle(bundle: IBaseImageBundle): Promise<void> {
if (!this.isPathInside(bundle.bundleDir, bundle.kernelImagePath)) {
throw new Error(`Cached kernel path escapes bundle directory: ${bundle.kernelImagePath}`);
}
if (!this.isPathInside(bundle.bundleDir, bundle.rootfsPath)) {
throw new Error(`Cached rootfs path escapes bundle directory: ${bundle.rootfsPath}`);
}
if (!bundle.checksums?.kernelSha256 || !bundle.checksums?.rootfsSha256) {
throw new Error(`Cached bundle ${bundle.bundleId} is missing checksums`);
}
if (bundle.sizes?.kernelBytes === undefined || bundle.sizes.rootfsBytes === undefined) {
throw new Error(`Cached bundle ${bundle.bundleId} is missing sizes`);
}
const [kernelStat, rootfsStat] = await Promise.all([
plugins.fs.promises.stat(bundle.kernelImagePath),
plugins.fs.promises.stat(bundle.rootfsPath),
]);
if (kernelStat.size !== bundle.sizes.kernelBytes) {
throw new Error(`Cached kernel size mismatch for bundle ${bundle.bundleId}`);
}
if (rootfsStat.size !== bundle.sizes.rootfsBytes) {
throw new Error(`Cached rootfs size mismatch for bundle ${bundle.bundleId}`);
}
const [kernelSha256, rootfsSha256] = await Promise.all([
this.sha256File(bundle.kernelImagePath),
this.sha256File(bundle.rootfsPath),
]);
if (kernelSha256.toLowerCase() !== bundle.checksums.kernelSha256.toLowerCase()) {
throw new Error(`Cached kernel SHA256 mismatch for bundle ${bundle.bundleId}`);
}
if (rootfsSha256.toLowerCase() !== bundle.checksums.rootfsSha256.toLowerCase()) {
throw new Error(`Cached rootfs SHA256 mismatch for bundle ${bundle.bundleId}`);
}
}
private isPathInside(baseDir: string, candidatePath: string): boolean {
const resolvedBase = plugins.path.resolve(baseDir);
const resolvedCandidate = plugins.path.resolve(candidatePath);
return resolvedCandidate === resolvedBase || resolvedCandidate.startsWith(`${resolvedBase}${plugins.path.sep}`);
}
private getManifestPath(bundleDir: string): string {
return plugins.path.join(bundleDir, 'manifest.json');
}
private async readBundleManifest(manifestPath: string): Promise<IBaseImageBundle> {
const raw = await plugins.fs.promises.readFile(manifestPath, 'utf8');
return JSON.parse(raw) as IBaseImageBundle;
}
private async writeBundleManifest(bundle: IBaseImageBundle): Promise<void> {
await plugins.fs.promises.mkdir(bundle.bundleDir, { recursive: true });
await plugins.fs.promises.writeFile(
this.getManifestPath(bundle.bundleDir),
`${JSON.stringify(bundle, null, 2)}\n`,
);
}
private async listCachedBundles(): Promise<IBaseImageBundle[]> {
let entries: string[];
try {
entries = await plugins.fs.promises.readdir(this.cacheDir);
} catch {
return [];
}
const bundles: IBaseImageBundle[] = [];
for (const entry of entries) {
const bundleDir = plugins.path.join(this.cacheDir, entry);
try {
const stat = await plugins.fs.promises.stat(bundleDir);
if (!stat.isDirectory()) {
continue;
}
const bundle = await this.readBundleManifest(this.getManifestPath(bundleDir));
bundles.push({
...bundle,
bundleDir,
});
} catch {
// Ignore incomplete cache entries.
}
}
return bundles;
}
private decodeXml(value: string): string {
return value
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
}
+70 -17
View File
@@ -3,14 +3,23 @@ import type { IFirecrackerProcessOptions } from './interfaces/index.js';
import { SmartVMError } from './interfaces/index.js'; import { SmartVMError } from './interfaces/index.js';
import { SocketClient } from './classes.socketclient.js'; import { SocketClient } from './classes.socketclient.js';
type TStreamingResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawnStreaming']>>;
type TExecResult = Awaited<TStreamingResult['finalPromise']>;
function getErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/** /**
* Manages a single Firecracker child process, including startup, readiness polling, and shutdown. * Manages a single Firecracker child process, including startup, readiness polling, and shutdown.
*/ */
export class FirecrackerProcess { export class FirecrackerProcess {
private options: IFirecrackerProcessOptions; private options: IFirecrackerProcessOptions;
private streaming: any | null = null; private streaming: TStreamingResult | null = null;
private shell: InstanceType<typeof plugins.smartshell.Smartshell>; private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
private smartExitInstance: InstanceType<typeof plugins.smartexit.SmartExit> | null = null; private smartExitInstance: InstanceType<typeof plugins.smartexit.SmartExit> | null = null;
private lastExitResult: TExecResult | null = null;
private lastExitError: string | null = null;
public socketClient: SocketClient; public socketClient: SocketClient;
constructor(options: IFirecrackerProcessOptions) { constructor(options: IFirecrackerProcessOptions) {
@@ -28,14 +37,21 @@ export class FirecrackerProcess {
plugins.fs.unlinkSync(this.options.socketPath); plugins.fs.unlinkSync(this.options.socketPath);
} }
// Build the command // Build the command args without a shell so paths are not interpreted.
let cmd = `${this.options.binaryPath} --api-sock ${this.options.socketPath}`; const args = ['--api-sock', this.options.socketPath];
if (this.options.logLevel) { if (this.options.logLevel) {
cmd += ` --level ${this.options.logLevel}`; args.push('--level', this.options.logLevel);
} }
// Spawn the process // Spawn the process
this.streaming = await this.shell.execStreaming(cmd, true); this.streaming = await this.shell.execSpawnStreaming(this.options.binaryPath, args, { silent: true });
this.streaming.finalPromise
.then((result) => {
this.lastExitResult = result;
})
.catch((err) => {
this.lastExitError = getErrorMessage(err);
});
// Register with smartexit for automatic cleanup // Register with smartexit for automatic cleanup
if (this.streaming?.childProcess) { if (this.streaming?.childProcess) {
@@ -46,9 +62,11 @@ export class FirecrackerProcess {
// Wait for the socket file to appear // Wait for the socket file to appear
const socketReady = await this.waitForSocket(10000); const socketReady = await this.waitForSocket(10000);
if (!socketReady) { if (!socketReady) {
const wasRunning = this.isRunning();
const diagnostics = this.formatDiagnostics();
await this.stop(); await this.stop();
throw new SmartVMError( throw new SmartVMError(
'Firecracker socket did not become ready within timeout', `Firecracker socket did not become ready within timeout${diagnostics || (wasRunning ? '' : this.formatDiagnostics())}`,
'SOCKET_TIMEOUT', 'SOCKET_TIMEOUT',
); );
} }
@@ -56,9 +74,10 @@ export class FirecrackerProcess {
// Wait for the API to be responsive // Wait for the API to be responsive
const apiReady = await this.socketClient.isReady(5000); const apiReady = await this.socketClient.isReady(5000);
if (!apiReady) { if (!apiReady) {
const diagnostics = this.formatDiagnostics();
await this.stop(); await this.stop();
throw new SmartVMError( throw new SmartVMError(
'Firecracker API did not become responsive within timeout', `Firecracker API did not become responsive within timeout${diagnostics}`,
'API_TIMEOUT', 'API_TIMEOUT',
); );
} }
@@ -73,36 +92,69 @@ export class FirecrackerProcess {
if (plugins.fs.existsSync(this.options.socketPath)) { if (plugins.fs.existsSync(this.options.socketPath)) {
return true; return true;
} }
if (this.streaming && !this.isRunning()) {
return false;
}
await plugins.smartdelay.delayFor(100); await plugins.smartdelay.delayFor(100);
} }
return false; return false;
} }
private async waitForExit(streaming: TStreamingResult, timeoutMs: number): Promise<boolean> {
return Promise.race([
streaming.finalPromise.then((result) => {
this.lastExitResult = result;
return true;
}).catch((err) => {
this.lastExitError = getErrorMessage(err);
return true;
}),
plugins.smartdelay.delayFor(timeoutMs).then(() => false),
]);
}
private formatDiagnostics(): string {
if (this.lastExitError) {
return `: ${this.lastExitError}`;
}
if (this.lastExitResult) {
const output = (this.lastExitResult.stderr || this.lastExitResult.stdout || '').trim();
return `: process exited with code ${this.lastExitResult.exitCode}${output ? `: ${output}` : ''}`;
}
return '';
}
/** /**
* Gracefully stop the Firecracker process with SIGTERM, then SIGKILL after timeout. * Gracefully stop the Firecracker process with SIGTERM, then SIGKILL after timeout.
*/ */
public async stop(): Promise<void> { public async stop(): Promise<void> {
if (!this.streaming) return; const streaming = this.streaming;
if (!streaming) return;
try { try {
// Try graceful termination first // Try graceful termination first
await this.streaming.terminate(); await streaming.terminate();
// Wait up to 5 seconds for the process to exit // Wait up to 5 seconds for the process to exit
const exitPromise = Promise.race([ const terminated = await this.waitForExit(streaming, 5000);
this.streaming.finalPromise, if (!terminated) {
plugins.smartdelay.delayFor(5000), await streaming.kill();
]); await this.waitForExit(streaming, 1000);
await exitPromise; }
} catch { } catch {
// If termination fails, force kill // If termination fails, force kill
try { try {
await this.streaming.kill(); await streaming.kill();
await this.waitForExit(streaming, 1000);
} catch { } catch {
// Process may already be dead // Process may already be dead
} }
} }
if (this.smartExitInstance) {
this.smartExitInstance.removeProcess(streaming.childProcess);
this.smartExitInstance = null;
}
this.streaming = null; this.streaming = null;
} }
@@ -122,10 +174,11 @@ export class FirecrackerProcess {
* Check if the process is currently running. * Check if the process is currently running.
*/ */
public isRunning(): boolean { public isRunning(): boolean {
if (!this.streaming?.childProcess) return false; const pid = this.streaming?.childProcess?.pid;
if (!pid) return false;
try { try {
// Sending signal 0 tests if process exists without actually sending a signal // Sending signal 0 tests if process exists without actually sending a signal
process.kill(this.streaming.childProcess.pid, 0); process.kill(pid, 0);
return true; return true;
} catch { } catch {
return false; return false;
+51 -27
View File
@@ -2,6 +2,12 @@ import * as plugins from './plugins.js';
import type { TFirecrackerArch } from './interfaces/index.js'; import type { TFirecrackerArch } from './interfaces/index.js';
import { SmartVMError } from './interfaces/index.js'; import { SmartVMError } from './interfaces/index.js';
type TShellExecResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawn']>>;
function getErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/** /**
* Helper to check if a file or directory exists. * Helper to check if a file or directory exists.
*/ */
@@ -21,10 +27,21 @@ async function pathExists(filePath: string): Promise<boolean> {
export class ImageManager { export class ImageManager {
private dataDir: string; private dataDir: string;
private arch: TFirecrackerArch; private arch: TFirecrackerArch;
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
constructor(dataDir: string, arch: TFirecrackerArch = 'x86_64') { constructor(dataDir: string, arch: TFirecrackerArch = 'x86_64') {
this.dataDir = dataDir; this.dataDir = dataDir;
this.arch = arch; this.arch = arch;
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
}
private async runChecked(command: string, args: string[]): Promise<TShellExecResult> {
const result = await this.shell.execSpawn(command, args, { silent: true });
if (result.exitCode !== 0) {
const output = (result.stderr || result.stdout || '').trim();
throw new Error(`${command} ${args.join(' ')} exited with code ${result.exitCode}${output ? `: ${output}` : ''}`);
}
return result;
} }
/** /**
@@ -89,14 +106,22 @@ export class ImageManager {
*/ */
public async getLatestVersion(): Promise<string> { public async getLatestVersion(): Promise<string> {
try { try {
const response = await plugins.SmartRequest.create() const result = await this.runChecked('curl', [
.url('https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest') '-fsSLI',
.get(); '-o',
const data = await response.json() as { tag_name: string }; '/dev/null',
return data.tag_name; '-w',
'%{url_effective}',
'https://github.com/firecracker-microvm/firecracker/releases/latest',
]);
const match = result.stdout.trim().match(/\/releases\/tag\/(v\d+\.\d+\.\d+)$/);
if (!match) {
throw new Error(`Could not parse latest Firecracker release URL: ${result.stdout.trim()}`);
}
return match[1];
} catch (err) { } catch (err) {
throw new SmartVMError( throw new SmartVMError(
`Failed to fetch latest Firecracker version: ${(err as Error).message}`, `Failed to fetch latest Firecracker version: ${getErrorMessage(err)}`,
'VERSION_FETCH_FAILED', 'VERSION_FETCH_FAILED',
); );
} }
@@ -119,11 +144,10 @@ export class ImageManager {
try { try {
// Download the archive // Download the archive
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' }); await this.runChecked('curl', ['-fSL', '-o', archivePath, downloadUrl]);
await shell.exec(`curl -fSL -o "${archivePath}" "${downloadUrl}"`);
// Extract the archive // Extract the archive
await shell.exec(`tar -xzf "${archivePath}" -C "${targetDir}"`); await this.runChecked('tar', ['-xzf', archivePath, '-C', targetDir]);
// Firecracker archives contain a directory like release-v1.5.0-x86_64/ // Firecracker archives contain a directory like release-v1.5.0-x86_64/
// with binaries named like firecracker-v1.5.0-x86_64 // with binaries named like firecracker-v1.5.0-x86_64
@@ -134,21 +158,25 @@ export class ImageManager {
const jailerDst = this.getJailerPath(version); const jailerDst = this.getJailerPath(version);
// Move binaries to expected paths // Move binaries to expected paths
await shell.exec(`mv "${firecrackerSrc}" "${firecrackerDst}"`); await plugins.fs.promises.rename(firecrackerSrc, firecrackerDst);
if (await pathExists(jailerSrc)) { if (await pathExists(jailerSrc)) {
await shell.exec(`mv "${jailerSrc}" "${jailerDst}"`); await plugins.fs.promises.rename(jailerSrc, jailerDst);
} }
// Make executable // Make executable
await shell.exec(`chmod +x "${firecrackerDst}"`); await plugins.fs.promises.chmod(firecrackerDst, 0o755);
if (await pathExists(jailerDst)) {
await plugins.fs.promises.chmod(jailerDst, 0o755);
}
// Clean up // Clean up
await shell.exec(`rm -rf "${archivePath}" "${extractedDir}"`); await plugins.fs.promises.rm(archivePath, { force: true });
await plugins.fs.promises.rm(extractedDir, { recursive: true, force: true });
return firecrackerDst; return firecrackerDst;
} catch (err) { } catch (err) {
throw new SmartVMError( throw new SmartVMError(
`Failed to download Firecracker ${version}: ${(err as Error).message}`, `Failed to download Firecracker ${version}: ${getErrorMessage(err)}`,
'DOWNLOAD_FAILED', 'DOWNLOAD_FAILED',
); );
} }
@@ -163,12 +191,11 @@ export class ImageManager {
const kernelPath = plugins.path.join(kernelsDir, name); const kernelPath = plugins.path.join(kernelsDir, name);
try { try {
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' }); await this.runChecked('curl', ['-fSL', '-o', kernelPath, url]);
await shell.exec(`curl -fSL -o "${kernelPath}" "${url}"`);
return kernelPath; return kernelPath;
} catch (err) { } catch (err) {
throw new SmartVMError( throw new SmartVMError(
`Failed to download kernel from ${url}: ${(err as Error).message}`, `Failed to download kernel from ${url}: ${getErrorMessage(err)}`,
'DOWNLOAD_FAILED', 'DOWNLOAD_FAILED',
); );
} }
@@ -183,12 +210,11 @@ export class ImageManager {
const rootfsPath = plugins.path.join(rootfsDir, name); const rootfsPath = plugins.path.join(rootfsDir, name);
try { try {
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' }); await this.runChecked('curl', ['-fSL', '-o', rootfsPath, url]);
await shell.exec(`curl -fSL -o "${rootfsPath}" "${url}"`);
return rootfsPath; return rootfsPath;
} catch (err) { } catch (err) {
throw new SmartVMError( throw new SmartVMError(
`Failed to download rootfs from ${url}: ${(err as Error).message}`, `Failed to download rootfs from ${url}: ${getErrorMessage(err)}`,
'DOWNLOAD_FAILED', 'DOWNLOAD_FAILED',
); );
} }
@@ -203,13 +229,12 @@ export class ImageManager {
const rootfsPath = plugins.path.join(rootfsDir, name); const rootfsPath = plugins.path.join(rootfsDir, name);
try { try {
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' }); await this.runChecked('dd', ['if=/dev/zero', `of=${rootfsPath}`, 'bs=1M', `count=${sizeMib}`]);
await shell.exec(`dd if=/dev/zero of="${rootfsPath}" bs=1M count=${sizeMib}`); await this.runChecked('mkfs.ext4', [rootfsPath]);
await shell.exec(`mkfs.ext4 "${rootfsPath}"`);
return rootfsPath; return rootfsPath;
} catch (err) { } catch (err) {
throw new SmartVMError( throw new SmartVMError(
`Failed to create blank rootfs: ${(err as Error).message}`, `Failed to create blank rootfs: ${getErrorMessage(err)}`,
'ROOTFS_CREATE_FAILED', 'ROOTFS_CREATE_FAILED',
); );
} }
@@ -224,12 +249,11 @@ export class ImageManager {
const targetPath = plugins.path.join(rootfsDir, targetName); const targetPath = plugins.path.join(rootfsDir, targetName);
try { try {
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' }); await plugins.fs.promises.copyFile(sourcePath, targetPath);
await shell.exec(`cp "${sourcePath}" "${targetPath}"`);
return targetPath; return targetPath;
} catch (err) { } catch (err) {
throw new SmartVMError( throw new SmartVMError(
`Failed to clone rootfs: ${(err as Error).message}`, `Failed to clone rootfs: ${getErrorMessage(err)}`,
'ROOTFS_CLONE_FAILED', 'ROOTFS_CLONE_FAILED',
); );
} }
+92 -12
View File
@@ -2,8 +2,10 @@ import * as plugins from './plugins.js';
import type { import type {
TVMState, TVMState,
IMicroVMConfig, IMicroVMConfig,
IMicroVMRuntimeOptions,
ISnapshotCreateParams, ISnapshotCreateParams,
ISnapshotLoadParams, ISnapshotLoadParams,
IDriveConfig,
ITapDevice, ITapDevice,
} from './interfaces/index.js'; } from './interfaces/index.js';
import { SmartVMError } from './interfaces/index.js'; import { SmartVMError } from './interfaces/index.js';
@@ -26,6 +28,9 @@ export class MicroVM {
private networkManager: NetworkManager; private networkManager: NetworkManager;
private binaryPath: string; private binaryPath: string;
private socketPath: string; private socketPath: string;
private runtimeDir: string;
private ephemeralWritableDrives: boolean;
private vmRuntimeDir: string | null = null;
private tapDevices: ITapDevice[] = []; private tapDevices: ITapDevice[] = [];
constructor( constructor(
@@ -34,12 +39,15 @@ export class MicroVM {
binaryPath: string, binaryPath: string,
socketPath: string, socketPath: string,
networkManager: NetworkManager, networkManager: NetworkManager,
runtimeOptions: IMicroVMRuntimeOptions = {},
) { ) {
this.id = id; this.id = id;
this.vmConfig = new VMConfig(config); this.vmConfig = new VMConfig(config);
this.binaryPath = binaryPath; this.binaryPath = binaryPath;
this.socketPath = socketPath; this.socketPath = socketPath;
this.networkManager = networkManager; this.networkManager = networkManager;
this.runtimeDir = runtimeOptions.runtimeDir || plugins.path.join(plugins.os.tmpdir(), '.smartvm', 'runtime');
this.ephemeralWritableDrives = runtimeOptions.ephemeralWritableDrives ?? true;
} }
/** /**
@@ -54,6 +62,16 @@ export class MicroVM {
} }
} }
private getSocketClient(operation: string): SocketClient {
if (!this.socketClient) {
throw new SmartVMError(
`Cannot ${operation}: socket client not initialized`,
'NO_CLIENT',
);
}
return this.socketClient;
}
/** /**
* Start the MicroVM. * Start the MicroVM.
* Validates config, starts the Firecracker process, applies pre-boot configuration, and boots the VM. * Validates config, starts the Firecracker process, applies pre-boot configuration, and boots the VM.
@@ -73,6 +91,9 @@ export class MicroVM {
this.state = 'configuring'; this.state = 'configuring';
try { try {
await this.ensureVMRuntimeDir();
await this.prepareEphemeralDrives();
// Start the Firecracker process // Start the Firecracker process
this.process = new FirecrackerProcess({ this.process = new FirecrackerProcess({
binaryPath: this.binaryPath, binaryPath: this.binaryPath,
@@ -155,8 +176,9 @@ export class MicroVM {
if (err instanceof SmartVMError) { if (err instanceof SmartVMError) {
throw err; throw err;
} }
const message = err instanceof Error ? err.message : String(err);
throw new SmartVMError( throw new SmartVMError(
`Failed to start VM ${this.id}: ${err.message}`, `Failed to start VM ${this.id}: ${message}`,
'START_FAILED', 'START_FAILED',
); );
} }
@@ -243,7 +265,7 @@ export class MicroVM {
*/ */
public async getMetadata(): Promise<any> { public async getMetadata(): Promise<any> {
this.assertState(['running', 'paused'], 'getMetadata'); this.assertState(['running', 'paused'], 'getMetadata');
const response = await this.socketClient!.get('/mmds'); const response = await this.getSocketClient('getMetadata').get('/mmds');
return response.body; return response.body;
} }
@@ -281,7 +303,7 @@ export class MicroVM {
* Get VM instance info. * Get VM instance info.
*/ */
public async getInfo(): Promise<any> { public async getInfo(): Promise<any> {
const response = await this.socketClient!.get('/'); const response = await this.getSocketClient('getInfo').get('/');
return response.body; return response.body;
} }
@@ -289,7 +311,7 @@ export class MicroVM {
* Get Firecracker version info. * Get Firecracker version info.
*/ */
public async getVersion(): Promise<any> { public async getVersion(): Promise<any> {
const response = await this.socketClient!.get('/version'); const response = await this.getSocketClient('getVersion').get('/version');
return response.body; return response.body;
} }
@@ -307,6 +329,13 @@ export class MicroVM {
return this.vmConfig; return this.vmConfig;
} }
/**
* Get the per-VM runtime directory if it has been created.
*/
public getRuntimeDir(): string | null {
return this.vmRuntimeDir;
}
/** /**
* Full cleanup: stop process, remove socket, remove TAP devices. * Full cleanup: stop process, remove socket, remove TAP devices.
*/ */
@@ -323,20 +352,74 @@ export class MicroVM {
} }
this.tapDevices = []; this.tapDevices = [];
if (this.vmRuntimeDir) {
await plugins.fs.promises.rm(this.vmRuntimeDir, { recursive: true, force: true });
this.vmRuntimeDir = null;
}
this.socketClient = null; this.socketClient = null;
if (this.state !== 'error') { if (this.state !== 'error') {
this.state = 'stopped'; this.state = 'stopped';
} }
} }
private shouldStageDrive(drive: IDriveConfig): boolean {
if (!this.ephemeralWritableDrives) {
return false;
}
if (drive.ephemeral === false) {
return false;
}
if (drive.isReadOnly === true && drive.ephemeral !== true) {
return false;
}
return true;
}
private async ensureVMRuntimeDir(): Promise<string> {
if (!this.vmRuntimeDir) {
this.vmRuntimeDir = plugins.path.join(this.runtimeDir, this.sanitizePathPart(this.id));
}
await plugins.fs.promises.mkdir(this.vmRuntimeDir, { recursive: true });
return this.vmRuntimeDir;
}
private async prepareEphemeralDrives(): Promise<void> {
const drives = this.vmConfig.config.drives || [];
for (const drive of drives) {
if (!this.shouldStageDrive(drive)) {
continue;
}
const vmRuntimeDir = await this.ensureVMRuntimeDir();
const drivesDir = plugins.path.join(vmRuntimeDir, 'drives');
await plugins.fs.promises.mkdir(drivesDir, { recursive: true });
const sourcePath = drive.pathOnHost;
const sourceFileName = plugins.path.basename(sourcePath) || 'drive.img';
const stagedPath = plugins.path.join(
drivesDir,
`${this.sanitizePathPart(drive.driveId)}-${this.sanitizePathPart(sourceFileName)}`,
);
await plugins.fs.promises.copyFile(sourcePath, stagedPath);
drive.pathOnHost = stagedPath;
}
}
private sanitizePathPart(value: string): string {
const sanitized = value.replace(/[^a-zA-Z0-9._-]/g, '_');
if (!sanitized || sanitized === '.' || sanitized === '..') {
return 'item';
}
return sanitized;
}
/** /**
* Helper: PUT request with error handling. * Helper: PUT request with error handling.
*/ */
private async apiPut(path: string, body: Record<string, any>): Promise<void> { private async apiPut(path: string, body: Record<string, any>): Promise<void> {
if (!this.socketClient) { const response = await this.getSocketClient(`PUT ${path}`).put(path, body);
throw new SmartVMError('Socket client not initialized', 'NO_CLIENT');
}
const response = await this.socketClient.put(path, body);
if (!response.ok) { if (!response.ok) {
throw new SmartVMError( throw new SmartVMError(
`API PUT ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`, `API PUT ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`,
@@ -351,10 +434,7 @@ export class MicroVM {
* Helper: PATCH request with error handling. * Helper: PATCH request with error handling.
*/ */
private async apiPatch(path: string, body: Record<string, any>): Promise<void> { private async apiPatch(path: string, body: Record<string, any>): Promise<void> {
if (!this.socketClient) { const response = await this.getSocketClient(`PATCH ${path}`).patch(path, body);
throw new SmartVMError('Socket client not initialized', 'NO_CLIENT');
}
const response = await this.socketClient.patch(path, body);
if (!response.ok) { if (!response.ok) {
throw new SmartVMError( throw new SmartVMError(
`API PATCH ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`, `API PATCH ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`,
+821 -58
View File
@@ -1,7 +1,35 @@
import * as plugins from './plugins.js'; import * as plugins from './plugins.js';
import type { INetworkManagerOptions, ITapDevice } from './interfaces/index.js'; import type {
IFirewallConfig,
IFirewallRule,
INetworkManagerOptions,
ITapDevice,
TFirewallAction,
TFirewallProtocol,
TWireGuardConfig,
} from './interfaces/index.js';
import { SmartVMError } from './interfaces/index.js'; import { SmartVMError } from './interfaces/index.js';
type TShellExecResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawn']>>;
interface IParsedSubnet {
networkAddress: number;
broadcastAddress: number;
cidr: number;
subnetMask: string;
}
interface IParsedWireGuardConfig {
setConfig: string;
addresses: string[];
mtu?: number;
}
const FIREWALL_ACTION_TO_IPTABLES_TARGET: Record<TFirewallAction, 'ACCEPT' | 'DROP'> = {
allow: 'ACCEPT',
deny: 'DROP',
};
/** /**
* Manages host networking for Firecracker VMs. * Manages host networking for Firecracker VMs.
* Creates TAP devices, Linux bridges, and configures NAT for VM internet access. * Creates TAP devices, Linux bridges, and configures NAT for VM internet access.
@@ -12,53 +40,430 @@ export class NetworkManager {
private subnetCidr: number; private subnetCidr: number;
private gatewayIp: string; private gatewayIp: string;
private subnetMask: string; private subnetMask: string;
private nextIpOctet: number; private nextIpAddress: number;
private lastUsableIpAddress: number;
private activeTaps: Map<string, ITapDevice> = new Map(); private activeTaps: Map<string, ITapDevice> = new Map();
private bridgeCreated: boolean = false; private bridgeCreated: boolean = false;
private defaultRouteInterface: string | null = null;
private firewall?: IFirewallConfig;
private firewallChainName: string;
private firewallConfigured: boolean = false;
private wireguard?: TWireGuardConfig;
private wireGuardInterface: string | null = null;
private wireGuardManaged: boolean = false;
private wireGuardRouteConfigured: boolean = false;
private wireGuardRouteAdded: boolean = false;
private wireGuardIpRuleAdded: boolean = false;
private wireGuardRouteTable: number | null = null;
private natInterface: string | null = null;
private natConfigured: boolean = false;
private natRuleAdded: boolean = false;
private shell: InstanceType<typeof plugins.smartshell.Smartshell>; private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
constructor(options: INetworkManagerOptions = {}) { constructor(options: INetworkManagerOptions = {}) {
this.bridgeName = options.bridgeName || 'svbr0'; this.bridgeName = options.bridgeName || 'svbr0';
this.validateInterfaceName(this.bridgeName, 'bridgeName');
const subnet = options.subnet || '172.30.0.0/24'; const subnet = options.subnet || '172.30.0.0/24';
const parsedSubnet = this.parseSubnet(subnet);
// Parse the subnet this.subnetBase = this.intToIp(parsedSubnet.networkAddress);
const [baseIp, cidrStr] = subnet.split('/'); this.subnetCidr = parsedSubnet.cidr;
this.subnetBase = baseIp; this.subnetMask = parsedSubnet.subnetMask;
this.subnetCidr = parseInt(cidrStr, 10); this.gatewayIp = this.intToIp(parsedSubnet.networkAddress + 1);
this.subnetMask = this.cidrToSubnetMask(this.subnetCidr); this.nextIpAddress = parsedSubnet.networkAddress + 2;
this.lastUsableIpAddress = parsedSubnet.broadcastAddress - 1;
// Gateway is .1 in the subnet this.firewall = options.firewall;
const parts = this.subnetBase.split('.').map(Number); this.wireguard = options.wireguard;
parts[3] = 1; this.firewallChainName = this.buildFirewallChainName(this.bridgeName, this.subnetBase, this.subnetCidr);
this.gatewayIp = parts.join('.'); this.validateFirewallConfig(this.firewall);
this.validateWireGuardConfig(this.wireguard);
// VMs start at .2
this.nextIpOctet = 2;
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' }); this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
} }
/** /**
* Convert a CIDR prefix length to a dotted-decimal subnet mask. * Parse an IPv4 CIDR subnet and ensure there is room for a gateway and guests.
*/ */
private cidrToSubnetMask(cidr: number): string { private parseSubnet(subnet: string): IParsedSubnet {
const mask = (0xffffffff << (32 - cidr)) >>> 0; const [ip, cidrText, extra] = subnet.split('/');
const cidr = Number(cidrText);
if (!ip || !cidrText || extra !== undefined || !Number.isInteger(cidr) || cidr < 1 || cidr > 30) {
throw new SmartVMError(
`Invalid subnet '${subnet}': expected IPv4 CIDR with prefix length 1-30`,
'INVALID_SUBNET',
);
}
const ipAddress = this.ipToInt(ip);
const mask = this.cidrToMask(cidr);
const networkAddress = (ipAddress & mask) >>> 0;
const hostCount = 2 ** (32 - cidr);
const broadcastAddress = networkAddress + hostCount - 1;
if (hostCount < 4) {
throw new SmartVMError(
`Invalid subnet '${subnet}': at least two usable host addresses are required`,
'INVALID_SUBNET',
);
}
return {
networkAddress,
broadcastAddress,
cidr,
subnetMask: this.intToIp(mask),
};
}
private ipToInt(ip: string): number {
const octets = ip.split('.');
if (octets.length !== 4) {
throw new SmartVMError(`Invalid IPv4 address '${ip}'`, 'INVALID_SUBNET');
}
if (octets.some((octet) => !/^[0-9]+$/.test(octet))) {
throw new SmartVMError(`Invalid IPv4 address '${ip}'`, 'INVALID_SUBNET');
}
const numbers = octets.map((octet) => Number(octet));
if (numbers.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
throw new SmartVMError(`Invalid IPv4 address '${ip}'`, 'INVALID_SUBNET');
}
return (
numbers[0] * 256 ** 3 +
numbers[1] * 256 ** 2 +
numbers[2] * 256 +
numbers[3]
) >>> 0;
}
private intToIp(address: number): string {
return [ return [
(mask >>> 24) & 0xff, Math.floor(address / 256 ** 3) % 256,
(mask >>> 16) & 0xff, Math.floor(address / 256 ** 2) % 256,
(mask >>> 8) & 0xff, Math.floor(address / 256) % 256,
mask & 0xff, address % 256,
].join('.'); ].join('.');
} }
private cidrToMask(cidr: number): number {
return (0xffffffff << (32 - cidr)) >>> 0;
}
private validateInterfaceName(name: string, fieldName: string): void {
if (typeof name !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,14}$/.test(name)) {
throw new SmartVMError(
`${fieldName} '${name}' is not a valid Linux interface name`,
'INVALID_INTERFACE_NAME',
);
}
}
private buildFirewallChainName(bridgeName: string, subnetBase: string, subnetCidr: number): string {
const hash = plugins.crypto
.createHash('sha256')
.update(`${bridgeName}:${subnetBase}/${subnetCidr}`)
.digest('hex')
.slice(0, 10);
return `SVMEG-${hash}`;
}
private getSubnetCidr(): string {
return `${this.subnetBase}/${this.subnetCidr}`;
}
private validateIpv4Cidr(value: string, fieldName: string, errorCode = 'INVALID_FIREWALL_CONFIG'): void {
const [ip, cidrText, extra] = value.split('/');
const cidr = cidrText === undefined || cidrText === '' ? 32 : Number(cidrText);
if (!ip || extra !== undefined || !Number.isInteger(cidr) || cidr < 0 || cidr > 32) {
throw new SmartVMError(
`${fieldName} '${value}' must be an IPv4 address or CIDR with prefix length 0-32`,
errorCode,
);
}
try {
this.ipToInt(ip);
} catch (err) {
if (err instanceof SmartVMError) {
throw new SmartVMError(
`${fieldName} '${value}' must be an IPv4 address or CIDR with prefix length 0-32`,
errorCode,
);
}
throw err;
}
}
private normalizeCidr(value: string): string {
return value.includes('/') ? value : `${value}/32`;
}
private validateFirewallConfig(firewall?: IFirewallConfig): void {
if (!firewall || firewall.egress === undefined) {
return;
}
const egress = firewall.egress;
if (!egress || typeof egress !== 'object') {
throw new SmartVMError('Firewall egress config must be an object', 'INVALID_FIREWALL_CONFIG');
}
if (
egress.defaultAction !== undefined &&
egress.defaultAction !== 'allow' &&
egress.defaultAction !== 'deny'
) {
throw new SmartVMError(
`Invalid firewall egress defaultAction '${egress.defaultAction}'`,
'INVALID_FIREWALL_CONFIG',
);
}
if (egress.rules !== undefined && !Array.isArray(egress.rules)) {
throw new SmartVMError('Firewall egress rules must be an array', 'INVALID_FIREWALL_CONFIG');
}
for (const rule of egress.rules || []) {
this.validateFirewallRule(rule);
}
}
private validateFirewallRule(rule: IFirewallRule): void {
if (!rule || typeof rule !== 'object') {
throw new SmartVMError('Firewall rule must be an object', 'INVALID_FIREWALL_CONFIG');
}
if (rule.action !== 'allow' && rule.action !== 'deny') {
throw new SmartVMError(
`Invalid firewall rule action '${rule.action}'`,
'INVALID_FIREWALL_CONFIG',
);
}
const protocol = rule.protocol || 'all';
if (!['all', 'tcp', 'udp', 'icmp'].includes(protocol)) {
throw new SmartVMError(
`Invalid firewall rule protocol '${protocol}'`,
'INVALID_FIREWALL_CONFIG',
);
}
if (rule.to !== undefined) {
if (typeof rule.to !== 'string') {
throw new SmartVMError('Firewall rule destination must be a string', 'INVALID_FIREWALL_CONFIG');
}
this.validateIpv4Cidr(rule.to, 'firewall rule destination');
}
if (rule.comment !== undefined && typeof rule.comment !== 'string') {
throw new SmartVMError('Firewall rule comment must be a string', 'INVALID_FIREWALL_CONFIG');
}
const ports = this.normalizePorts(rule.ports);
if (ports.length > 0 && protocol !== 'tcp' && protocol !== 'udp') {
throw new SmartVMError(
'Firewall rule ports require protocol tcp or udp',
'INVALID_FIREWALL_CONFIG',
);
}
}
private normalizePorts(ports?: number | number[]): number[] {
if (ports === undefined) {
return [];
}
const portList = Array.isArray(ports) ? ports : [ports];
if (portList.length === 0) {
throw new SmartVMError('Firewall rule ports must not be empty', 'INVALID_FIREWALL_CONFIG');
}
for (const port of portList) {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new SmartVMError(
`Firewall rule port '${port}' must be an integer between 1 and 65535`,
'INVALID_FIREWALL_CONFIG',
);
}
}
return portList;
}
private validateWireGuardConfig(wireguard?: TWireGuardConfig): void {
if (!wireguard) {
return;
}
const routeTable = wireguard.routeTable === undefined ? 51820 : wireguard.routeTable;
if (!Number.isInteger(routeTable) || routeTable < 1 || routeTable > 4294967295) {
throw new SmartVMError(
`WireGuard routeTable '${routeTable}' must be an integer between 1 and 4294967295`,
'INVALID_WIREGUARD_CONFIG',
);
}
const wireguardOptions = wireguard as unknown as Record<string, unknown>;
const hasExistingInterface = Object.prototype.hasOwnProperty.call(wireguardOptions, 'existingInterface');
const hasManagedConfig = Object.prototype.hasOwnProperty.call(wireguardOptions, 'config');
if (hasExistingInterface && hasManagedConfig) {
throw new SmartVMError(
'WireGuard config must use either existingInterface or managed config, not both',
'INVALID_WIREGUARD_CONFIG',
);
}
if (hasExistingInterface) {
this.validateInterfaceName(wireguardOptions.existingInterface as string, 'wireguard.existingInterface');
return;
}
if (typeof wireguardOptions.config !== 'string') {
throw new SmartVMError('WireGuard managed config requires config text', 'INVALID_WIREGUARD_CONFIG');
}
const interfaceName = wireguardOptions.interfaceName === undefined
? 'svwg0'
: wireguardOptions.interfaceName;
this.validateInterfaceName(interfaceName as string, 'wireguard.interfaceName');
this.parseWireGuardConfig(wireguardOptions.config);
}
private parseWireGuardConfig(config: string): IParsedWireGuardConfig {
if (!config || !config.trim()) {
throw new SmartVMError('WireGuard config must not be empty', 'INVALID_WIREGUARD_CONFIG');
}
const unsafeFields = new Set(['preup', 'postup', 'predown', 'postdown', 'saveconfig']);
const ignoredFields = new Set(['address', 'dns', 'mtu', 'table']);
const wireGuardInterfaceFields = new Set(['privatekey', 'listenport', 'fwmark']);
const wireGuardPeerFields = new Set([
'publickey',
'presharedkey',
'allowedips',
'endpoint',
'persistentkeepalive',
]);
const setConfigLines: string[] = [];
const addresses: string[] = [];
let mtu: number | undefined;
let currentSection: 'Interface' | 'Peer' | null = null;
let sawPrivateKey = false;
for (const rawLine of config.split(/\r?\n/)) {
const trimmedLine = rawLine.trim();
if (!trimmedLine || trimmedLine.startsWith('#') || trimmedLine.startsWith(';')) {
continue;
}
const lineWithoutComment = trimmedLine.replace(/\s+[;#].*$/, '').trim();
if (!lineWithoutComment) {
continue;
}
const sectionMatch = lineWithoutComment.match(/^\[(Interface|Peer)\]$/i);
if (sectionMatch) {
currentSection = sectionMatch[1].toLowerCase() === 'interface' ? 'Interface' : 'Peer';
setConfigLines.push(`[${currentSection}]`);
continue;
}
const keyValueMatch = lineWithoutComment.match(/^([^=]+?)\s*=\s*(.+)$/);
if (!keyValueMatch || !currentSection) {
throw new SmartVMError(
`Invalid WireGuard config line '${rawLine.trim()}'`,
'INVALID_WIREGUARD_CONFIG',
);
}
const key = keyValueMatch[1].trim();
const normalizedKey = key.toLowerCase();
const value = keyValueMatch[2].trim();
if (unsafeFields.has(normalizedKey)) {
throw new SmartVMError(
`WireGuard config field '${key}' is not allowed because it can execute commands or mutate host state`,
'INVALID_WIREGUARD_CONFIG',
);
}
if (currentSection === 'Interface' && normalizedKey === 'address') {
for (const address of value.split(',').map((item) => item.trim()).filter(Boolean)) {
this.validateIpv4Cidr(address, 'WireGuard Address', 'INVALID_WIREGUARD_CONFIG');
addresses.push(this.normalizeCidr(address));
}
continue;
}
if (currentSection === 'Interface' && normalizedKey === 'mtu') {
const parsedMtu = Number(value);
if (!Number.isInteger(parsedMtu) || parsedMtu < 576 || parsedMtu > 9000) {
throw new SmartVMError(
`WireGuard MTU '${value}' must be an integer between 576 and 9000`,
'INVALID_WIREGUARD_CONFIG',
);
}
mtu = parsedMtu;
continue;
}
if (currentSection === 'Peer' && normalizedKey === 'allowedips') {
const allowedIps = value.split(',').map((item) => item.trim()).filter(Boolean);
if (allowedIps.length === 0) {
throw new SmartVMError('WireGuard Peer.AllowedIPs must not be empty', 'INVALID_WIREGUARD_CONFIG');
}
for (const allowedIp of allowedIps) {
this.validateIpv4Cidr(allowedIp, 'WireGuard AllowedIPs', 'INVALID_WIREGUARD_CONFIG');
}
setConfigLines.push(`${key} = ${allowedIps.join(', ')}`);
continue;
}
if (ignoredFields.has(normalizedKey)) {
continue;
}
const allowedFields = currentSection === 'Interface'
? wireGuardInterfaceFields
: wireGuardPeerFields;
if (!allowedFields.has(normalizedKey)) {
throw new SmartVMError(
`Unsupported WireGuard ${currentSection} field '${key}'`,
'INVALID_WIREGUARD_CONFIG',
);
}
if (currentSection === 'Interface' && normalizedKey === 'privatekey') {
sawPrivateKey = true;
}
setConfigLines.push(`${key} = ${value}`);
}
if (!sawPrivateKey) {
throw new SmartVMError('WireGuard config requires Interface.PrivateKey', 'INVALID_WIREGUARD_CONFIG');
}
if (addresses.length === 0) {
throw new SmartVMError('WireGuard config requires at least one IPv4 Interface.Address', 'INVALID_WIREGUARD_CONFIG');
}
return {
setConfig: `${setConfigLines.join('\n')}\n`,
addresses,
mtu,
};
}
/** /**
* Allocate the next available IP address in the subnet. * Allocate the next available IP address in the subnet.
*/ */
public allocateIp(): string { public allocateIp(): string {
const parts = this.subnetBase.split('.').map(Number); if (this.nextIpAddress > this.lastUsableIpAddress) {
parts[3] = this.nextIpOctet; throw new SmartVMError(
this.nextIpOctet++; `Subnet ${this.subnetBase}/${this.subnetCidr} has no available guest IP addresses`,
return parts.join('.'); 'IP_EXHAUSTED',
);
}
const ip = this.intToIp(this.nextIpAddress);
this.nextIpAddress++;
return ip;
} }
/** /**
@@ -102,39 +507,323 @@ export class NetworkManager {
return tapName.substring(0, 15); return tapName.substring(0, 15);
} }
private async run(command: string, args: string[]): Promise<TShellExecResult> {
return this.shell.execSpawn(command, args, { silent: true });
}
private async runChecked(command: string, args: string[]): Promise<TShellExecResult> {
const result = await this.run(command, args);
if (result.exitCode !== 0) {
const output = (result.stderr || result.stdout || '').trim();
throw new Error(`${command} ${args.join(' ')} exited with code ${result.exitCode}${output ? `: ${output}` : ''}`);
}
return result;
}
private async getDefaultRouteInterface(): Promise<string> {
if (this.defaultRouteInterface) {
return this.defaultRouteInterface;
}
const result = await this.runChecked('ip', ['route', 'show', 'default']);
const match = result.stdout.match(/\bdev\s+([^\s]+)/);
if (!match) {
throw new Error('Could not determine default route interface');
}
const iface = match[1];
this.validateInterfaceName(iface, 'default route interface');
this.defaultRouteInterface = iface;
return iface;
}
private getSharedMemoryTempDir(): string {
try {
if (plugins.fs.existsSync('/dev/shm') && plugins.fs.statSync('/dev/shm').isDirectory()) {
return '/dev/shm';
}
} catch {
// Fall through to os.tmpdir().
}
return plugins.os.tmpdir();
}
private async configureWireGuardEgress(): Promise<string> {
if (!this.wireguard || this.wireguard.routeAllVmTraffic === false) {
return this.getDefaultRouteInterface();
}
try {
const iface = await this.ensureWireGuardInterface();
const routeTable = this.wireguard.routeTable === undefined ? 51820 : this.wireguard.routeTable;
this.wireGuardInterface = iface;
this.wireGuardRouteTable = routeTable;
this.wireGuardRouteAdded = false;
this.wireGuardIpRuleAdded = false;
const routeResult = await this.run('ip', ['route', 'show', 'table', String(routeTable), 'default']);
const existingDefaultRoutes = routeResult.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
if (existingDefaultRoutes.some((line) => !line.includes(` dev ${iface} `) && !line.endsWith(` dev ${iface}`))) {
throw new SmartVMError(
`WireGuard route table ${routeTable} already has a default route not using ${iface}`,
'WIREGUARD_SETUP_FAILED',
);
}
if (existingDefaultRoutes.length === 0) {
await this.runChecked('ip', [
'route',
'add',
'default',
'dev',
iface,
'table',
String(routeTable),
]);
this.wireGuardRouteAdded = true;
}
this.wireGuardRouteConfigured = true;
if (!await this.hasWireGuardIpRule(routeTable)) {
await this.runChecked('ip', [
'rule',
'add',
'from',
this.getSubnetCidr(),
'table',
String(routeTable),
]);
this.wireGuardIpRuleAdded = true;
}
return iface;
} catch (err) {
if (err instanceof SmartVMError) {
throw err;
}
const message = err instanceof Error ? err.message : String(err);
throw new SmartVMError(`Failed to configure WireGuard egress: ${message}`, 'WIREGUARD_SETUP_FAILED');
}
}
private async hasWireGuardIpRule(routeTable: number): Promise<boolean> {
const result = await this.run('ip', ['rule', 'show']);
return result.stdout
.split('\n')
.some((line) => line.includes(`from ${this.getSubnetCidr()}`) && line.includes(`lookup ${routeTable}`));
}
private async ensureWireGuardInterface(): Promise<string> {
if (!this.wireguard) {
throw new Error('WireGuard is not configured');
}
if ('existingInterface' in this.wireguard) {
await this.runChecked('ip', ['link', 'show', this.wireguard.existingInterface]);
this.wireGuardManaged = false;
return this.wireguard.existingInterface;
}
const iface = this.wireguard.interfaceName || 'svwg0';
const existingInterface = await this.run('ip', ['link', 'show', iface]);
if (existingInterface.exitCode === 0) {
throw new SmartVMError(
`Managed WireGuard interface '${iface}' already exists; use existingInterface to route through it`,
'WIREGUARD_SETUP_FAILED',
);
}
const parsedConfig = this.parseWireGuardConfig(this.wireguard.config);
await this.runChecked('ip', ['link', 'add', 'dev', iface, 'type', 'wireguard']);
this.wireGuardManaged = true;
this.wireGuardInterface = iface;
const tempDir = await plugins.fs.promises.mkdtemp(
plugins.path.join(this.getSharedMemoryTempDir(), `smartvm-wg-${iface}-`),
);
const tempConfigPath = plugins.path.join(tempDir, 'wg.conf');
try {
await plugins.fs.promises.writeFile(tempConfigPath, parsedConfig.setConfig, { mode: 0o600 });
await this.runChecked('wg', ['setconf', iface, tempConfigPath]);
} finally {
await plugins.fs.promises.rm(tempDir, { recursive: true, force: true });
}
for (const address of parsedConfig.addresses) {
await this.runChecked('ip', ['addr', 'add', address, 'dev', iface]);
}
if (parsedConfig.mtu !== undefined) {
await this.runChecked('ip', ['link', 'set', 'mtu', String(parsedConfig.mtu), 'dev', iface]);
}
await this.runChecked('ip', ['link', 'set', iface, 'up']);
return iface;
}
private shouldApplyFailClosed(): boolean {
return Boolean(
this.wireguard &&
this.wireguard.routeAllVmTraffic !== false &&
this.wireguard.failClosed !== false &&
this.wireGuardInterface,
);
}
private async setupNat(egressInterface: string): Promise<void> {
this.natRuleAdded = false;
const ruleArgs = [
'-s',
this.getSubnetCidr(),
'-o',
egressInterface,
'-j',
'MASQUERADE',
];
const checkResult = await this.run('iptables', ['-t', 'nat', '-C', 'POSTROUTING', ...ruleArgs]);
if (checkResult.exitCode !== 0) {
await this.runChecked('iptables', ['-t', 'nat', '-A', 'POSTROUTING', ...ruleArgs]);
this.natRuleAdded = true;
}
this.natInterface = egressInterface;
this.natConfigured = true;
}
private async setupEgressFirewall(egressInterface: string): Promise<void> {
const egress = this.firewall?.egress;
const shouldSetupFirewall = Boolean(egress || this.shouldApplyFailClosed());
if (!shouldSetupFirewall) {
return;
}
await this.ensureIptablesChain('filter', this.firewallChainName);
this.firewallConfigured = true;
await this.runChecked('iptables', ['-t', 'filter', '-F', this.firewallChainName]);
await this.ensureIptablesRule('filter', 'FORWARD', ['-s', this.getSubnetCidr(), '-j', this.firewallChainName]);
await this.runChecked('iptables', [
'-t',
'filter',
'-A',
this.firewallChainName,
'-m',
'conntrack',
'--ctstate',
'ESTABLISHED,RELATED',
'-j',
'ACCEPT',
]);
if (this.shouldApplyFailClosed()) {
await this.runChecked('iptables', [
'-t',
'filter',
'-A',
this.firewallChainName,
'!',
'-o',
egressInterface,
'-j',
'DROP',
]);
}
for (const rule of egress?.rules || []) {
for (const ruleArgs of this.buildFirewallRuleArgs(rule)) {
await this.runChecked('iptables', ['-t', 'filter', '-A', this.firewallChainName, ...ruleArgs]);
}
}
const defaultAction = egress?.defaultAction || 'allow';
await this.runChecked('iptables', [
'-t',
'filter',
'-A',
this.firewallChainName,
'-j',
FIREWALL_ACTION_TO_IPTABLES_TARGET[defaultAction],
]);
}
private buildFirewallRuleArgs(rule: IFirewallRule): string[][] {
const baseArgs: string[] = [];
const protocol = rule.protocol || 'all';
if (rule.to !== undefined) {
baseArgs.push('-d', this.normalizeCidr(rule.to));
}
if (protocol !== 'all') {
baseArgs.push('-p', protocol);
}
if (rule.comment) {
baseArgs.push('-m', 'comment', '--comment', rule.comment.slice(0, 240));
}
const target = FIREWALL_ACTION_TO_IPTABLES_TARGET[rule.action];
const ports = this.normalizePorts(rule.ports);
if (ports.length === 0) {
return [[...baseArgs, '-j', target]];
}
return ports.map((port) => [...baseArgs, '--dport', String(port), '-j', target]);
}
private async ensureIptablesChain(table: string, chain: string): Promise<void> {
const checkResult = await this.run('iptables', ['-t', table, '-S', chain]);
if (checkResult.exitCode !== 0) {
await this.runChecked('iptables', ['-t', table, '-N', chain]);
}
}
private async ensureIptablesRule(table: string, chain: string, ruleArgs: string[]): Promise<void> {
const checkResult = await this.run('iptables', ['-t', table, '-C', chain, ...ruleArgs]);
if (checkResult.exitCode !== 0) {
await this.runChecked('iptables', ['-t', table, '-A', chain, ...ruleArgs]);
}
}
private async deleteIptablesRule(table: string, chain: string, ruleArgs: string[]): Promise<void> {
await this.run('iptables', ['-t', table, '-D', chain, ...ruleArgs]);
}
/** /**
* Ensure the Linux bridge is created and configured. * Ensure the Linux bridge is created and configured.
*/ */
public async ensureBridge(): Promise<void> { public async ensureBridge(): Promise<void> {
if (this.bridgeCreated) return; if (this.bridgeCreated) return;
let createdBridge = false;
try { try {
// Check if bridge already exists // Check if bridge already exists
const result = await this.shell.exec(`ip link show ${this.bridgeName} 2>/dev/null`); const result = await this.run('ip', ['link', 'show', this.bridgeName]);
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
// Create bridge // Create bridge
await this.shell.exec(`ip link add ${this.bridgeName} type bridge`); await this.runChecked('ip', ['link', 'add', this.bridgeName, 'type', 'bridge']);
await this.shell.exec(`ip addr add ${this.gatewayIp}/${this.subnetCidr} dev ${this.bridgeName}`); createdBridge = true;
await this.shell.exec(`ip link set ${this.bridgeName} up`); await this.runChecked('ip', ['addr', 'add', `${this.gatewayIp}/${this.subnetCidr}`, 'dev', this.bridgeName]);
await this.runChecked('ip', ['link', 'set', this.bridgeName, 'up']);
} }
// Enable IP forwarding // Enable IP forwarding
await this.shell.exec('sysctl -w net.ipv4.ip_forward=1'); await this.runChecked('sysctl', ['-w', 'net.ipv4.ip_forward=1']);
// Set up NAT masquerade (idempotent with -C check) const egressInterface = await this.configureWireGuardEgress();
const checkResult = await this.shell.exec( await this.setupEgressFirewall(egressInterface);
`iptables -t nat -C POSTROUTING -s ${this.subnetBase}/${this.subnetCidr} -o $(ip route | grep default | awk '{print $5}' | head -1) -j MASQUERADE 2>/dev/null`, await this.setupNat(egressInterface);
);
if (checkResult.exitCode !== 0) {
await this.shell.exec(
`iptables -t nat -A POSTROUTING -s ${this.subnetBase}/${this.subnetCidr} -o $(ip route | grep default | awk '{print $5}' | head -1) -j MASQUERADE`,
);
}
this.bridgeCreated = true; this.bridgeCreated = true;
} catch (err) { } catch (err) {
try {
await this.cleanupEgressFirewall();
await this.cleanupNat();
await this.cleanupWireGuardEgress();
if (createdBridge) {
await this.run('ip', ['link', 'set', this.bridgeName, 'down']);
await this.run('ip', ['link', 'del', this.bridgeName]);
}
} catch {
// Preserve the original setup error.
}
if (err instanceof SmartVMError) {
throw err;
}
const message = err instanceof Error ? err.message : String(err);
throw new SmartVMError( throw new SmartVMError(
`Failed to set up network bridge: ${err.message}`, `Failed to set up network bridge: ${message}`,
'BRIDGE_SETUP_FAILED', 'BRIDGE_SETUP_FAILED',
); );
} }
@@ -147,16 +836,19 @@ export class NetworkManager {
await this.ensureBridge(); await this.ensureBridge();
const tapName = this.generateTapName(vmId, ifaceId); const tapName = this.generateTapName(vmId, ifaceId);
this.validateInterfaceName(tapName, 'tapName');
const guestIp = this.allocateIp(); const guestIp = this.allocateIp();
const mac = this.generateMac(vmId, ifaceId); const mac = this.generateMac(vmId, ifaceId);
let tapCreated = false;
try { try {
// Create TAP device // Create TAP device
await this.shell.exec(`ip tuntap add dev ${tapName} mode tap`); await this.runChecked('ip', ['tuntap', 'add', 'dev', tapName, 'mode', 'tap']);
tapCreated = true;
// Attach to bridge // Attach to bridge
await this.shell.exec(`ip link set ${tapName} master ${this.bridgeName}`); await this.runChecked('ip', ['link', 'set', tapName, 'master', this.bridgeName]);
// Bring TAP device up // Bring TAP device up
await this.shell.exec(`ip link set ${tapName} up`); await this.runChecked('ip', ['link', 'set', tapName, 'up']);
const tap: ITapDevice = { const tap: ITapDevice = {
tapName, tapName,
@@ -169,8 +861,12 @@ export class NetworkManager {
this.activeTaps.set(tapName, tap); this.activeTaps.set(tapName, tap);
return tap; return tap;
} catch (err) { } catch (err) {
if (tapCreated) {
await this.removeTapDevice(tapName);
}
const message = err instanceof Error ? err.message : String(err);
throw new SmartVMError( throw new SmartVMError(
`Failed to create TAP device ${tapName}: ${err.message}`, `Failed to create TAP device ${tapName}: ${message}`,
'TAP_CREATE_FAILED', 'TAP_CREATE_FAILED',
); );
} }
@@ -180,8 +876,9 @@ export class NetworkManager {
* Remove a TAP device and free its resources. * Remove a TAP device and free its resources.
*/ */
public async removeTapDevice(tapName: string): Promise<void> { public async removeTapDevice(tapName: string): Promise<void> {
this.validateInterfaceName(tapName, 'tapName');
try { try {
await this.shell.exec(`ip link del ${tapName} 2>/dev/null`); await this.run('ip', ['link', 'del', tapName]);
this.activeTaps.delete(tapName); this.activeTaps.delete(tapName);
} catch { } catch {
// Device may already be gone // Device may already be gone
@@ -209,29 +906,95 @@ export class NetworkManager {
*/ */
public async cleanup(): Promise<void> { public async cleanup(): Promise<void> {
// Remove all TAP devices // Remove all TAP devices
for (const tapName of this.activeTaps.keys()) { for (const tapName of Array.from(this.activeTaps.keys())) {
await this.removeTapDevice(tapName); await this.removeTapDevice(tapName);
} }
await this.cleanupEgressFirewall();
await this.cleanupNat();
await this.cleanupWireGuardEgress();
// Remove bridge if we created it // Remove bridge if we created it
if (this.bridgeCreated) { if (this.bridgeCreated) {
try { try {
await this.shell.exec(`ip link set ${this.bridgeName} down 2>/dev/null`); await this.run('ip', ['link', 'set', this.bridgeName, 'down']);
await this.shell.exec(`ip link del ${this.bridgeName} 2>/dev/null`); await this.run('ip', ['link', 'del', this.bridgeName]);
} catch { } catch {
// Bridge may already be gone // Bridge may already be gone
} }
// Remove NAT rule
try {
await this.shell.exec(
`iptables -t nat -D POSTROUTING -s ${this.subnetBase}/${this.subnetCidr} -o $(ip route | grep default | awk '{print $5}' | head -1) -j MASQUERADE 2>/dev/null`,
);
} catch {
// Rule may not exist
}
this.bridgeCreated = false; this.bridgeCreated = false;
} }
} }
private async cleanupEgressFirewall(): Promise<void> {
if (!this.firewallConfigured) {
return;
}
await this.deleteIptablesRule('filter', 'FORWARD', [
'-s',
this.getSubnetCidr(),
'-j',
this.firewallChainName,
]);
await this.run('iptables', ['-t', 'filter', '-F', this.firewallChainName]);
await this.run('iptables', ['-t', 'filter', '-X', this.firewallChainName]);
this.firewallConfigured = false;
}
private async cleanupNat(): Promise<void> {
if (!this.natConfigured || !this.natInterface) {
return;
}
if (this.natRuleAdded) {
await this.deleteIptablesRule('nat', 'POSTROUTING', [
'-s',
this.getSubnetCidr(),
'-o',
this.natInterface,
'-j',
'MASQUERADE',
]);
}
this.natInterface = null;
this.natConfigured = false;
this.natRuleAdded = false;
}
private async cleanupWireGuardEgress(): Promise<void> {
if (this.wireGuardRouteConfigured && this.wireGuardRouteTable !== null) {
if (this.wireGuardIpRuleAdded) {
await this.run('ip', [
'rule',
'del',
'from',
this.getSubnetCidr(),
'table',
String(this.wireGuardRouteTable),
]);
}
if (this.wireGuardRouteAdded && this.wireGuardInterface) {
await this.run('ip', [
'route',
'del',
'default',
'dev',
this.wireGuardInterface,
'table',
String(this.wireGuardRouteTable),
]);
}
}
if (this.wireGuardManaged && this.wireGuardInterface) {
await this.run('ip', ['link', 'del', this.wireGuardInterface]);
}
this.wireGuardRouteConfigured = false;
this.wireGuardRouteAdded = false;
this.wireGuardIpRuleAdded = false;
this.wireGuardRouteTable = null;
this.wireGuardManaged = false;
this.wireGuardInterface = null;
}
} }
+58 -5
View File
@@ -1,16 +1,22 @@
import * as plugins from './plugins.js'; import * as plugins from './plugins.js';
import type { ISmartVMOptions, IMicroVMConfig } from './interfaces/index.js'; import type { IBaseImageBundle, IEnsureBaseImageOptions, ISmartVMOptions, IMicroVMConfig } from './interfaces/index.js';
import { SmartVMError } from './interfaces/index.js'; import { SmartVMError } from './interfaces/index.js';
import { ImageManager } from './classes.imagemanager.js'; import { ImageManager } from './classes.imagemanager.js';
import { BaseImageManager } from './classes.baseimagemanager.js';
import { NetworkManager } from './classes.networkmanager.js'; import { NetworkManager } from './classes.networkmanager.js';
import { MicroVM } from './classes.microvm.js'; import { MicroVM } from './classes.microvm.js';
function getErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/** /**
* Top-level orchestrator for creating and managing Firecracker MicroVMs. * Top-level orchestrator for creating and managing Firecracker MicroVMs.
*/ */
export class SmartVM { export class SmartVM {
private options: ISmartVMOptions; private options: ISmartVMOptions;
public imageManager: ImageManager; public imageManager: ImageManager;
public baseImageManager: BaseImageManager;
public networkManager: NetworkManager; public networkManager: NetworkManager;
private activeVMs: Map<string, MicroVM> = new Map(); private activeVMs: Map<string, MicroVM> = new Map();
private smartExitInstance: InstanceType<typeof plugins.smartexit.SmartExit>; private smartExitInstance: InstanceType<typeof plugins.smartexit.SmartExit>;
@@ -20,6 +26,8 @@ export class SmartVM {
constructor(options: ISmartVMOptions = {}) { constructor(options: ISmartVMOptions = {}) {
this.options = { this.options = {
dataDir: options.dataDir || '/tmp/.smartvm', dataDir: options.dataDir || '/tmp/.smartvm',
runtimeDir: options.runtimeDir || this.getDefaultRuntimeDir(),
ephemeralWritableDrives: options.ephemeralWritableDrives ?? true,
arch: options.arch || 'x86_64', arch: options.arch || 'x86_64',
bridgeName: options.bridgeName || 'svbr0', bridgeName: options.bridgeName || 'svbr0',
subnet: options.subnet || '172.30.0.0/24', subnet: options.subnet || '172.30.0.0/24',
@@ -27,9 +35,18 @@ export class SmartVM {
}; };
this.imageManager = new ImageManager(this.options.dataDir!, this.options.arch); this.imageManager = new ImageManager(this.options.dataDir!, this.options.arch);
this.baseImageManager = new BaseImageManager({
arch: this.options.arch,
cacheDir: this.options.baseImageCacheDir,
maxStoredBaseImages: this.options.maxStoredBaseImages,
hostedManifestUrl: this.options.baseImageManifestUrl,
hostedManifestPath: this.options.baseImageManifestPath,
});
this.networkManager = new NetworkManager({ this.networkManager = new NetworkManager({
bridgeName: this.options.bridgeName, bridgeName: this.options.bridgeName,
subnet: this.options.subnet, subnet: this.options.subnet,
firewall: this.options.firewall,
wireguard: this.options.wireguard,
}); });
// If a custom binary path is provided, use it directly // If a custom binary path is provided, use it directly
@@ -44,6 +61,30 @@ export class SmartVM {
}); });
} }
private getDefaultRuntimeDir(): string {
const tmpfsDir = '/dev/shm';
try {
if (plugins.fs.existsSync(tmpfsDir) && plugins.fs.statSync(tmpfsDir).isDirectory()) {
return plugins.path.join(tmpfsDir, '.smartvm', 'runtime');
}
} catch {
// Fall back to os.tmpdir() below.
}
return plugins.path.join(plugins.os.tmpdir(), '.smartvm', 'runtime');
}
public getRuntimeDir(): string {
return this.options.runtimeDir!;
}
private sanitizePathPart(value: string): string {
const sanitized = value.replace(/[^a-zA-Z0-9._-]/g, '_');
if (!sanitized || sanitized === '.' || sanitized === '..') {
return 'item';
}
return sanitized;
}
/** /**
* Ensure the Firecracker binary is available. * Ensure the Firecracker binary is available.
* Downloads it if not present. * Downloads it if not present.
@@ -97,8 +138,9 @@ export class SmartVM {
// Generate VM ID if not provided // Generate VM ID if not provided
const vmId = config.id || plugins.smartunique.uuid4(); const vmId = config.id || plugins.smartunique.uuid4();
// Generate socket path // Keep per-VM runtime artifacts in tmpfs by default.
const socketPath = this.imageManager.getSocketPath(vmId); const vmRuntimeDir = plugins.path.join(this.options.runtimeDir!, this.sanitizePathPart(vmId));
const socketPath = plugins.path.join(vmRuntimeDir, 'firecracker.sock');
// Create MicroVM instance // Create MicroVM instance
const vm = new MicroVM( const vm = new MicroVM(
@@ -107,6 +149,10 @@ export class SmartVM {
this.firecrackerBinaryPath!, this.firecrackerBinaryPath!,
socketPath, socketPath,
this.networkManager, this.networkManager,
{
runtimeDir: this.options.runtimeDir,
ephemeralWritableDrives: this.options.ephemeralWritableDrives,
},
); );
// Register in active VMs // Register in active VMs
@@ -115,6 +161,13 @@ export class SmartVM {
return vm; return vm;
} }
/**
* Ensure a Firecracker CI base image bundle is available locally.
*/
public async ensureBaseImage(options: IEnsureBaseImageOptions = {}): Promise<IBaseImageBundle> {
return this.baseImageManager.ensureBaseImage(options);
}
/** /**
* Get an active VM by ID. * Get an active VM by ID.
*/ */
@@ -145,7 +198,7 @@ export class SmartVM {
if (vm.state === 'running' || vm.state === 'paused') { if (vm.state === 'running' || vm.state === 'paused') {
stopPromises.push( stopPromises.push(
vm.stop().catch((err) => { vm.stop().catch((err) => {
console.error(`Failed to stop VM ${vm.id}: ${err.message}`); console.error(`Failed to stop VM ${vm.id}: ${getErrorMessage(err)}`);
}), }),
); );
} }
@@ -162,7 +215,7 @@ export class SmartVM {
for (const vm of this.activeVMs.values()) { for (const vm of this.activeVMs.values()) {
cleanupPromises.push( cleanupPromises.push(
vm.cleanup().catch((err) => { vm.cleanup().catch((err) => {
console.error(`Failed to clean up VM ${vm.id}: ${err.message}`); console.error(`Failed to clean up VM ${vm.id}: ${getErrorMessage(err)}`);
}), }),
); );
} }
+47 -36
View File
@@ -2,6 +2,10 @@ import * as plugins from './plugins.js';
import type { ISocketClientOptions, IApiResponse } from './interfaces/index.js'; import type { ISocketClientOptions, IApiResponse } from './interfaces/index.js';
import { SmartVMError } from './interfaces/index.js'; import { SmartVMError } from './interfaces/index.js';
function getErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/** /**
* HTTP client that communicates with Firecracker over a Unix domain socket. * HTTP client that communicates with Firecracker over a Unix domain socket.
* Uses @push.rocks/smartrequest with the `http://unix:<socket>:<path>` URL format. * Uses @push.rocks/smartrequest with the `http://unix:<socket>:<path>` URL format.
@@ -20,6 +24,22 @@ export class SocketClient {
return `http://unix:${this.socketPath}:${apiPath}`; return `http://unix:${this.socketPath}:${apiPath}`;
} }
private async parseResponseBody<T>(response: any): Promise<T> {
try {
const text = await response.text();
if (!text) {
return undefined as T;
}
try {
return JSON.parse(text) as T;
} catch {
return text as T;
}
} catch {
return undefined as T;
}
}
/** /**
* Perform a GET request. * Perform a GET request.
*/ */
@@ -31,12 +51,7 @@ export class SocketClient {
.get(); .get();
const statusCode = response.status; const statusCode = response.status;
let body: T; const body = await this.parseResponseBody<T>(response);
try {
body = await response.json() as T;
} catch {
body = undefined as any;
}
return { return {
statusCode, statusCode,
body, body,
@@ -44,7 +59,7 @@ export class SocketClient {
}; };
} catch (err) { } catch (err) {
throw new SmartVMError( throw new SmartVMError(
`GET ${apiPath} failed: ${(err as Error).message}`, `GET ${apiPath} failed: ${getErrorMessage(err)}`,
'SOCKET_REQUEST_FAILED', 'SOCKET_REQUEST_FAILED',
); );
} }
@@ -54,21 +69,19 @@ export class SocketClient {
* Perform a PUT request with a JSON body. * Perform a PUT request with a JSON body.
*/ */
public async put<T = any>(apiPath: string, body?: Record<string, any>): Promise<IApiResponse<T>> { public async put<T = any>(apiPath: string, body?: Record<string, any>): Promise<IApiResponse<T>> {
const url = this.buildUrl(apiPath); const url = this.buildUrl(apiPath);
try { try {
let request = plugins.SmartRequest.create().url(url); let request = plugins.SmartRequest.create().url(url);
if (body !== undefined) { if (body !== undefined) {
request = request.json(body); const bodyBuffer = Buffer.from(JSON.stringify(body));
} request = request
.buffer(bodyBuffer, 'application/json')
.header('Content-Length', String(bodyBuffer.length));
}
const response = await request.put(); const response = await request.put();
const statusCode = response.status; const statusCode = response.status;
let responseBody: T; const responseBody = await this.parseResponseBody<T>(response);
try {
responseBody = await response.json() as T;
} catch {
responseBody = undefined as any;
}
return { return {
statusCode, statusCode,
body: responseBody, body: responseBody,
@@ -76,7 +89,7 @@ export class SocketClient {
}; };
} catch (err) { } catch (err) {
throw new SmartVMError( throw new SmartVMError(
`PUT ${apiPath} failed: ${(err as Error).message}`, `PUT ${apiPath} failed: ${getErrorMessage(err)}`,
'SOCKET_REQUEST_FAILED', 'SOCKET_REQUEST_FAILED',
); );
} }
@@ -86,21 +99,19 @@ export class SocketClient {
* Perform a PATCH request with a JSON body. * Perform a PATCH request with a JSON body.
*/ */
public async patch<T = any>(apiPath: string, body?: Record<string, any>): Promise<IApiResponse<T>> { public async patch<T = any>(apiPath: string, body?: Record<string, any>): Promise<IApiResponse<T>> {
const url = this.buildUrl(apiPath); const url = this.buildUrl(apiPath);
try { try {
let request = plugins.SmartRequest.create().url(url); let request = plugins.SmartRequest.create().url(url);
if (body !== undefined) { if (body !== undefined) {
request = request.json(body); const bodyBuffer = Buffer.from(JSON.stringify(body));
} request = request
.buffer(bodyBuffer, 'application/json')
.header('Content-Length', String(bodyBuffer.length));
}
const response = await request.patch(); const response = await request.patch();
const statusCode = response.status; const statusCode = response.status;
let responseBody: T; const responseBody = await this.parseResponseBody<T>(response);
try {
responseBody = await response.json() as T;
} catch {
responseBody = undefined as any;
}
return { return {
statusCode, statusCode,
body: responseBody, body: responseBody,
@@ -108,21 +119,21 @@ export class SocketClient {
}; };
} catch (err) { } catch (err) {
throw new SmartVMError( throw new SmartVMError(
`PATCH ${apiPath} failed: ${(err as Error).message}`, `PATCH ${apiPath} failed: ${getErrorMessage(err)}`,
'SOCKET_REQUEST_FAILED', 'SOCKET_REQUEST_FAILED',
); );
} }
} }
/** /**
* Check if the Firecracker API socket is ready by polling GET /. * Check if the Firecracker API socket is ready by polling GET /version.
*/ */
public async isReady(timeoutMs: number = 5000): Promise<boolean> { public async isReady(timeoutMs: number = 5000): Promise<boolean> {
const start = Date.now(); const start = Date.now();
while (Date.now() - start < timeoutMs) { while (Date.now() - start < timeoutMs) {
try { try {
const response = await this.get('/'); const response = await this.get('/version');
if (response.ok || response.statusCode === 200 || response.statusCode === 400) { if (response.ok) {
return true; return true;
} }
} catch { } catch {
+39 -1
View File
@@ -12,7 +12,45 @@ export class VMConfig {
public config: IMicroVMConfig; public config: IMicroVMConfig;
constructor(config: IMicroVMConfig) { constructor(config: IMicroVMConfig) {
this.config = config; this.config = this.cloneConfig(config);
}
/**
* Keep internal normalization from mutating the caller's config object.
*/
private cloneConfig(config: IMicroVMConfig): IMicroVMConfig {
return {
...config,
bootSource: config.bootSource ? { ...config.bootSource } : config.bootSource,
machineConfig: config.machineConfig ? { ...config.machineConfig } : config.machineConfig,
drives: config.drives?.map((drive) => ({
...drive,
rateLimiter: drive.rateLimiter ? this.cloneRateLimiter(drive.rateLimiter) : undefined,
ephemeral: drive.ephemeral,
})),
networkInterfaces: config.networkInterfaces?.map((iface) => ({
...iface,
rxRateLimiter: iface.rxRateLimiter ? this.cloneRateLimiter(iface.rxRateLimiter) : undefined,
txRateLimiter: iface.txRateLimiter ? this.cloneRateLimiter(iface.txRateLimiter) : undefined,
})),
vsock: config.vsock ? { ...config.vsock } : undefined,
balloon: config.balloon ? { ...config.balloon } : undefined,
mmds: config.mmds ? {
...config.mmds,
networkInterfaces: config.mmds.networkInterfaces
? [...config.mmds.networkInterfaces]
: config.mmds.networkInterfaces,
} : undefined,
logger: config.logger ? { ...config.logger } : undefined,
metrics: config.metrics ? { ...config.metrics } : undefined,
};
}
private cloneRateLimiter(rateLimiter: IRateLimiter): IRateLimiter {
return {
bandwidth: rateLimiter.bandwidth ? { ...rateLimiter.bandwidth } : undefined,
ops: rateLimiter.ops ? { ...rateLimiter.ops } : undefined,
};
} }
/** /**
+1
View File
@@ -2,6 +2,7 @@ export * from './interfaces/index.js';
export { VMConfig } from './classes.vmconfig.js'; export { VMConfig } from './classes.vmconfig.js';
export { SocketClient } from './classes.socketclient.js'; export { SocketClient } from './classes.socketclient.js';
export { ImageManager } from './classes.imagemanager.js'; export { ImageManager } from './classes.imagemanager.js';
export { BaseImageManager } from './classes.baseimagemanager.js';
export { FirecrackerProcess } from './classes.firecrackerprocess.js'; export { FirecrackerProcess } from './classes.firecrackerprocess.js';
export { NetworkManager } from './classes.networkmanager.js'; export { NetworkManager } from './classes.networkmanager.js';
export { MicroVM } from './classes.microvm.js'; export { MicroVM } from './classes.microvm.js';
+208 -2
View File
@@ -6,6 +6,10 @@ import type { TFirecrackerArch, TCacheType, TSnapshotType, TLogLevel } from './c
export interface ISmartVMOptions { export interface ISmartVMOptions {
/** Directory for storing binaries, kernels, rootfs images, and sockets. Defaults to /tmp/.smartvm */ /** Directory for storing binaries, kernels, rootfs images, and sockets. Defaults to /tmp/.smartvm */
dataDir?: string; dataDir?: string;
/** Directory for VM sockets and ephemeral per-VM files. Defaults to /dev/shm/.smartvm/runtime on Linux when available. */
runtimeDir?: string;
/** Copy writable drives into the VM runtime directory before boot and delete them on cleanup. Defaults to true. */
ephemeralWritableDrives?: boolean;
/** Firecracker version to use. Defaults to latest. */ /** Firecracker version to use. Defaults to latest. */
firecrackerVersion?: string; firecrackerVersion?: string;
/** Target architecture. Defaults to x86_64. */ /** Target architecture. Defaults to x86_64. */
@@ -16,8 +20,204 @@ export interface ISmartVMOptions {
bridgeName?: string; bridgeName?: string;
/** Network subnet in CIDR notation. Defaults to '172.30.0.0/24'. */ /** Network subnet in CIDR notation. Defaults to '172.30.0.0/24'. */
subnet?: string; subnet?: string;
/** VM egress firewall configuration. */
firewall?: IFirewallConfig;
/** Host-side WireGuard egress routing configuration for VM traffic. */
wireguard?: TWireGuardConfig;
/** Directory for cached base images. Defaults to /tmp/.smartvm/base-images. */
baseImageCacheDir?: string;
/** Maximum number of cached base image bundles. Defaults to 2. */
maxStoredBaseImages?: number;
/** Hosted/project-owned base image manifest URL. */
baseImageManifestUrl?: string;
/** Local hosted/project-owned base image manifest path for development and tests. */
baseImageManifestPath?: string;
} }
/**
* Predefined base image sources for integration testing and quick starts.
*/
export type TBaseImagePreset = 'latest' | 'lts' | 'hosted';
/**
* Root filesystem image type used by a base image bundle.
*/
export type TBaseImageRootfsType = 'ext4' | 'squashfs';
/**
* Options for the BaseImageManager.
*/
export interface IBaseImageManagerOptions {
/** Architecture to resolve. Defaults to x86_64. */
arch?: TFirecrackerArch;
/** Directory for cached base image bundles. Defaults to /tmp/.smartvm/base-images. */
cacheDir?: string;
/** Maximum number of cached base image bundles. Defaults to 2. */
maxStoredBaseImages?: number;
/** Hosted base image manifest URL for project-owned bundles. */
hostedManifestUrl?: string;
/** Local hosted base image manifest path for development and tests. */
hostedManifestPath?: string;
}
/**
* Options when resolving or downloading a base image bundle.
*/
export interface IEnsureBaseImageOptions {
/** Preset to use. Defaults to latest. */
preset?: TBaseImagePreset;
/** Architecture to resolve. Defaults to manager architecture. */
arch?: TFirecrackerArch;
/** Redownload even if the bundle already exists locally. */
forceDownload?: boolean;
/** Hosted base image manifest URL. Overrides preset resolution. */
manifestUrl?: string;
/** Local hosted base image manifest path. Overrides preset resolution. */
manifestPath?: string;
}
/**
* Single hosted base image artifact in a manifest.
*/
export interface IBaseImageArtifactManifest {
/** Public URL for hosted artifacts. */
url?: string;
/** Local path for development/tests. */
path?: string;
/** Optional plain output filename. Defaults to basename of url/path. */
fileName?: string;
/** Expected SHA256 for verification. Required when url is used. */
sha256?: string;
/** Expected file size in bytes. */
sizeBytes?: number;
}
/**
* Hosted/project-owned base image manifest format.
*/
export interface IBaseImageHostedManifest {
schemaVersion: 1;
bundleId: string;
name?: string;
arch: TFirecrackerArch;
firecrackerVersion: string;
rootfsType: TBaseImageRootfsType;
rootfsIsReadOnly?: boolean;
bootArgs?: string;
kernel: IBaseImageArtifactManifest;
rootfs: IBaseImageArtifactManifest;
}
/**
* Cached base image bundle metadata.
*/
export interface IBaseImageBundle {
preset: TBaseImagePreset;
arch: TFirecrackerArch;
ciVersion: string;
firecrackerVersion: string;
bundleId: string;
bundleDir: string;
kernelImagePath: string;
rootfsPath: string;
rootfsType: TBaseImageRootfsType;
rootfsIsReadOnly: boolean;
bootArgs: string;
source: {
type?: 'firecracker-ci' | 'hosted-manifest';
bucketUrl?: string;
kernelKey?: string;
rootfsKey?: string;
manifestUrl?: string;
manifestPath?: string;
kernelUrl?: string;
rootfsUrl?: string;
kernelSourcePath?: string;
rootfsSourcePath?: string;
};
checksums?: {
kernelSha256?: string;
rootfsSha256?: string;
};
sizes?: {
kernelBytes?: number;
rootfsBytes?: number;
};
createdAt: string;
lastAccessedAt: string;
}
/**
* Runtime behavior for a MicroVM instance.
*/
export interface IMicroVMRuntimeOptions {
/** Directory for VM sockets and ephemeral per-VM files. */
runtimeDir?: string;
/** Copy writable drives into runtimeDir before boot and delete them on cleanup. Defaults to true. */
ephemeralWritableDrives?: boolean;
}
/** Firewall action for VM egress traffic. */
export type TFirewallAction = 'allow' | 'deny';
/** Firewall protocol selector for VM egress traffic. */
export type TFirewallProtocol = 'all' | 'tcp' | 'udp' | 'icmp';
/** One ordered VM egress firewall rule. */
export interface IFirewallRule {
/** Rule action. */
action: TFirewallAction;
/** Destination IPv4 address or CIDR. Omit to match all destinations. */
to?: string;
/** Protocol to match. Defaults to all. */
protocol?: TFirewallProtocol;
/** Destination port or ports for tcp/udp rules. */
ports?: number | number[];
/** Optional human-readable rule label. */
comment?: string;
}
/** VM egress firewall policy. */
export interface IFirewallEgressConfig {
/** Final action when no rule matches. Defaults to allow. */
defaultAction?: TFirewallAction;
/** Ordered rules; first match wins. */
rules?: IFirewallRule[];
}
/** Firewall configuration. */
export interface IFirewallConfig {
/** Egress firewall for traffic leaving the VM subnet. */
egress?: IFirewallEgressConfig;
}
/** Common WireGuard routing options. */
export interface IWireGuardBaseConfig {
/** Route all VM subnet traffic through this WireGuard interface. Defaults to true. */
routeAllVmTraffic?: boolean;
/** Drop VM traffic that would leave through a non-WireGuard interface. Defaults to true. */
failClosed?: boolean;
/** Linux routing table number for VM WireGuard egress. Defaults to 51820. */
routeTable?: number;
}
/** Managed WireGuard interface created and removed by smartvm. */
export interface IWireGuardManagedConfig extends IWireGuardBaseConfig {
/** wg-quick-style WireGuard config text. Hook fields are rejected. */
config: string;
/** Interface name to create. Defaults to svwg0. */
interfaceName?: string;
}
/** Existing WireGuard interface owned outside smartvm. */
export interface IWireGuardExistingInterfaceConfig extends IWireGuardBaseConfig {
/** Existing WireGuard interface to route VM traffic through. */
existingInterface: string;
}
/** WireGuard egress configuration. */
export type TWireGuardConfig = IWireGuardManagedConfig | IWireGuardExistingInterfaceConfig;
/** /**
* Firecracker boot source configuration. * Firecracker boot source configuration.
*/ */
@@ -84,6 +284,8 @@ export interface IDriveConfig {
rateLimiter?: IRateLimiter; rateLimiter?: IRateLimiter;
/** Path to a file that backs the device for I/O. */ /** Path to a file that backs the device for I/O. */
ioEngine?: string; ioEngine?: string;
/** Whether this drive should be staged into per-VM ephemeral storage. Defaults to true for writable drives. */
ephemeral?: boolean;
} }
/** /**
@@ -142,9 +344,9 @@ export interface ILoggerConfig {
logPath: string; logPath: string;
/** Log level. */ /** Log level. */
level?: TLogLevel; level?: TLogLevel;
/** Whether to show log origin (file, line). */
showLevel?: boolean;
/** Whether to show log level. */ /** Whether to show log level. */
showLevel?: boolean;
/** Whether to show log origin (file, line). */
showLogOrigin?: boolean; showLogOrigin?: boolean;
} }
@@ -216,6 +418,10 @@ export interface INetworkManagerOptions {
bridgeName?: string; bridgeName?: string;
/** Subnet in CIDR notation. Defaults to '172.30.0.0/24'. */ /** Subnet in CIDR notation. Defaults to '172.30.0.0/24'. */
subnet?: string; subnet?: string;
/** VM egress firewall configuration. */
firewall?: IFirewallConfig;
/** Host-side WireGuard egress routing configuration for VM traffic. */
wireguard?: TWireGuardConfig;
} }
/** /**
+2 -1
View File
@@ -2,8 +2,9 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
import * as crypto from 'crypto';
export { fs, path, os }; export { fs, path, os, crypto };
// @push.rocks scope // @push.rocks scope
import * as smartdelay from '@push.rocks/smartdelay'; import * as smartdelay from '@push.rocks/smartdelay';
+3 -1
View File
@@ -3,8 +3,10 @@
"target": "ES2022", "target": "ES2022",
"module": "NodeNext", "module": "NodeNext",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"noImplicitAny": true,
"esModuleInterop": true, "esModuleInterop": true,
"verbatimModuleSyntax": true "verbatimModuleSyntax": true,
"types": ["node"]
}, },
"exclude": [ "exclude": [
"dist_*/**/*.d.ts" "dist_*/**/*.d.ts"