4 Commits

12 changed files with 686 additions and 401 deletions
-34
View File
@@ -1,34 +0,0 @@
# SmartVM Base Image Bundles
This directory documents the project-owned base image manifest format. The actual kernel and rootfs binaries should be hosted as release assets or in object storage, not committed to git.
## Bundle Layout
A hosted bundle should expose three files:
```text
smartvm-minimal-v1-x86_64.manifest.json
vmlinux
rootfs.ext4
```
The manifest is the only file shape `smartvm` needs to know. It points at the hosted kernel and rootfs artifacts and records checksums.
## Manifest Fields
- `schemaVersion`: currently `1`
- `bundleId`: stable cache key, using letters, numbers, dot, underscore, and dash only
- `arch`: `x86_64` or `aarch64`
- `firecrackerVersion`: Firecracker version validated with this bundle
- `rootfsType`: `ext4` or `squashfs`
- `rootfsIsReadOnly`: use `true` for squashfs or immutable rootfs images
- `bootArgs`: kernel boot args to use with the bundle
- `kernel`: hosted kernel artifact URL/path plus `sha256` for URL artifacts and optional `sizeBytes`
- `rootfs`: hosted rootfs artifact URL/path plus `sha256` for URL artifacts and optional `sizeBytes`
- `fileName`: optional plain output filename; path separators are rejected
`sha256` is required for hosted URL artifacts. `sizeBytes` is optional but helps catch incomplete downloads.
## Cache Behavior
Downloaded bundles are cached under `/tmp/.smartvm/base-images` by default. The cache keeps two bundles unless `maxStoredBaseImages` is configured. Eviction is announced with `console.warn`.
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## 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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartvm",
"version": "1.2.0",
"version": "1.3.1",
"private": false,
"description": "A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs",
"type": "module",
+2
View File
@@ -8,6 +8,8 @@
- `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
- SmartRequest: `SmartRequest.create().url(...).json(body).put()` — response has `.status`, `.ok`, `.json()`
+458 -359
View File
File diff suppressed because it is too large Load Diff
+81
View File
@@ -678,6 +678,84 @@ tap.test('MicroVM - invalid lifecycle calls should throw SmartVMError', async ()
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
// ============================================================
@@ -688,6 +766,9 @@ tap.test('SmartVM - instantiation with defaults', async () => {
expect(smartvm.imageManager).toBeTruthy();
expect(smartvm.baseImageManager).toBeTruthy();
expect(smartvm.networkManager).toBeTruthy();
if (fs.existsSync('/dev/shm')) {
expect(smartvm.getRuntimeDir()).toEqual('/dev/shm/.smartvm/runtime');
}
expect(smartvm.vmCount).toEqual(0);
expect(smartvm.listVMs()).toHaveLength(0);
});
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartvm',
version: '1.2.0',
version: '1.3.1',
description: 'A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs'
}
+4 -4
View File
@@ -485,14 +485,14 @@ export class BaseImageManager {
}
private selectRootfsKey(keys: string[]): string {
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)!;
}
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');
}
+75
View File
@@ -2,8 +2,10 @@ import * as plugins from './plugins.js';
import type {
TVMState,
IMicroVMConfig,
IMicroVMRuntimeOptions,
ISnapshotCreateParams,
ISnapshotLoadParams,
IDriveConfig,
ITapDevice,
} from './interfaces/index.js';
import { SmartVMError } from './interfaces/index.js';
@@ -26,6 +28,9 @@ export class MicroVM {
private networkManager: NetworkManager;
private binaryPath: string;
private socketPath: string;
private runtimeDir: string;
private ephemeralWritableDrives: boolean;
private vmRuntimeDir: string | null = null;
private tapDevices: ITapDevice[] = [];
constructor(
@@ -34,12 +39,15 @@ export class MicroVM {
binaryPath: string,
socketPath: string,
networkManager: NetworkManager,
runtimeOptions: IMicroVMRuntimeOptions = {},
) {
this.id = id;
this.vmConfig = new VMConfig(config);
this.binaryPath = binaryPath;
this.socketPath = socketPath;
this.networkManager = networkManager;
this.runtimeDir = runtimeOptions.runtimeDir || plugins.path.join(plugins.os.tmpdir(), '.smartvm', 'runtime');
this.ephemeralWritableDrives = runtimeOptions.ephemeralWritableDrives ?? true;
}
/**
@@ -83,6 +91,9 @@ export class MicroVM {
this.state = 'configuring';
try {
await this.ensureVMRuntimeDir();
await this.prepareEphemeralDrives();
// Start the Firecracker process
this.process = new FirecrackerProcess({
binaryPath: this.binaryPath,
@@ -318,6 +329,13 @@ export class MicroVM {
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.
*/
@@ -334,12 +352,69 @@ export class MicroVM {
}
this.tapDevices = [];
if (this.vmRuntimeDir) {
await plugins.fs.promises.rm(this.vmRuntimeDir, { recursive: true, force: true });
this.vmRuntimeDir = null;
}
this.socketClient = null;
if (this.state !== 'error') {
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.
*/
+33 -2
View File
@@ -26,6 +26,8 @@ export class SmartVM {
constructor(options: ISmartVMOptions = {}) {
this.options = {
dataDir: options.dataDir || '/tmp/.smartvm',
runtimeDir: options.runtimeDir || this.getDefaultRuntimeDir(),
ephemeralWritableDrives: options.ephemeralWritableDrives ?? true,
arch: options.arch || 'x86_64',
bridgeName: options.bridgeName || 'svbr0',
subnet: options.subnet || '172.30.0.0/24',
@@ -57,6 +59,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.
* Downloads it if not present.
@@ -110,8 +136,9 @@ export class SmartVM {
// Generate VM ID if not provided
const vmId = config.id || plugins.smartunique.uuid4();
// Generate socket path
const socketPath = this.imageManager.getSocketPath(vmId);
// Keep per-VM runtime artifacts in tmpfs by default.
const vmRuntimeDir = plugins.path.join(this.options.runtimeDir!, this.sanitizePathPart(vmId));
const socketPath = plugins.path.join(vmRuntimeDir, 'firecracker.sock');
// Create MicroVM instance
const vm = new MicroVM(
@@ -120,6 +147,10 @@ export class SmartVM {
this.firecrackerBinaryPath!,
socketPath,
this.networkManager,
{
runtimeDir: this.options.runtimeDir,
ephemeralWritableDrives: this.options.ephemeralWritableDrives,
},
);
// Register in active VMs
+1
View File
@@ -26,6 +26,7 @@ export class VMConfig {
drives: config.drives?.map((drive) => ({
...drive,
rateLimiter: drive.rateLimiter ? this.cloneRateLimiter(drive.rateLimiter) : undefined,
ephemeral: drive.ephemeral,
})),
networkInterfaces: config.networkInterfaces?.map((iface) => ({
...iface,
+16
View File
@@ -6,6 +6,10 @@ import type { TFirecrackerArch, TCacheType, TSnapshotType, TLogLevel } from './c
export interface ISmartVMOptions {
/** Directory for storing binaries, kernels, rootfs images, and sockets. Defaults to /tmp/.smartvm */
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. */
firecrackerVersion?: string;
/** Target architecture. Defaults to x86_64. */
@@ -139,6 +143,16 @@ export interface IBaseImageBundle {
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;
}
/**
* Firecracker boot source configuration.
*/
@@ -205,6 +219,8 @@ export interface IDriveConfig {
rateLimiter?: IRateLimiter;
/** Path to a file that backs the device for I/O. */
ioEngine?: string;
/** Whether this drive should be staged into per-VM ephemeral storage. Defaults to true for writable drives. */
ephemeral?: boolean;
}
/**