From c868d07d296f5e7ccfc8e8345f5d0e2288702500 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 1 May 2026 15:28:06 +0000 Subject: [PATCH] feat(runtime): stage VM runtime artifacts and writable drives in per-VM ephemeral storage by default --- changelog.md | 8 ++++ readme.hints.md | 2 + readme.md | 17 ++++++- test/test.ts | 81 ++++++++++++++++++++++++++++++++++ ts/00_commitinfo_data.ts | 2 +- ts/classes.baseimagemanager.ts | 8 ++-- ts/classes.microvm.ts | 75 +++++++++++++++++++++++++++++++ ts/classes.smartvm.ts | 35 ++++++++++++++- ts/classes.vmconfig.ts | 1 + ts/interfaces/config.ts | 16 +++++++ 10 files changed, 237 insertions(+), 8 deletions(-) diff --git a/changelog.md b/changelog.md index c0417df..4ae45ec 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 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 diff --git a/readme.hints.md b/readme.hints.md index 440e25f..e74aa45 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -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()` diff --git a/readme.md b/readme.md index aa75fb2..5607d32 100644 --- a/readme.md +++ b/readme.md @@ -257,9 +257,22 @@ const clonePath = await imageManager.cloneRootfs(rootfsPath, 'ubuntu-clone.ext4' bin//jailer kernels/ rootfs/ - sockets/.sock + +/dev/shm/.smartvm/runtime// + firecracker.sock + drives/- ``` +Runtime files default to `/dev/shm/.smartvm/runtime` on Linux when available, so sockets and per-VM writable drive copies live on tmpfs and are removed during cleanup. Set `runtimeDir` to override this. + +**Ephemeral drives:** + +- Writable drives are copied into the per-VM runtime directory before boot by default +- Firecracker receives the staged tmpfs path, so guest writes do not modify cached/base rootfs files +- Per-VM staged drive files are deleted by `vm.cleanup()` / `smartvm.cleanup()` +- Read-only drives are not copied unless `ephemeral: true` is set on the drive +- Set `ephemeral: false` on a drive, or `ephemeralWritableDrives: false` on `SmartVM`, only when persistence is explicitly required + ### `BaseImageManager` — Base Images Downloads known base image bundles into a `/tmp` cache for integration tests and quick local smoke tests. The default preset is `latest`; `lts` maps to a pinned Firecracker CI train (`v1.7`) for a stable fallback. Hosted project-owned manifests are also supported for pinned Alpine/BusyBox-style bundles. @@ -304,6 +317,7 @@ Example configuration: ```typescript const smartvm = new SmartVM({ baseImageCacheDir: '/tmp/.smartvm/base-images', + runtimeDir: '/dev/shm/.smartvm/runtime', maxStoredBaseImages: 4, baseImageManifestUrl: 'https://assets.example.com/push.rocks/smartvm/base-images/smartvm-minimal-v1/x86_64/manifest.json', }); @@ -542,6 +556,7 @@ All configuration interfaces are fully exported for type-safe usage: ```typescript import type { ISmartVMOptions, + IMicroVMRuntimeOptions, IMicroVMConfig, IBootSource, IMachineConfig, diff --git a/test/test.ts b/test/test.ts index 36eb264..f4deb2a 100644 --- a/test/test.ts +++ b/test/test.ts @@ -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); }); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index b2b4178..67bd3ee 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartvm', - version: '1.2.0', + version: '1.3.0', description: 'A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs' } diff --git a/ts/classes.baseimagemanager.ts b/ts/classes.baseimagemanager.ts index 1126e23..ba4df69 100644 --- a/ts/classes.baseimagemanager.ts +++ b/ts/classes.baseimagemanager.ts @@ -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'); } diff --git a/ts/classes.microvm.ts b/ts/classes.microvm.ts index f03cd11..122cb99 100644 --- a/ts/classes.microvm.ts +++ b/ts/classes.microvm.ts @@ -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 { + 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 { + 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. */ diff --git a/ts/classes.smartvm.ts b/ts/classes.smartvm.ts index b5d9d54..fb1997e 100644 --- a/ts/classes.smartvm.ts +++ b/ts/classes.smartvm.ts @@ -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 diff --git a/ts/classes.vmconfig.ts b/ts/classes.vmconfig.ts index 7cfe118..e67460f 100644 --- a/ts/classes.vmconfig.ts +++ b/ts/classes.vmconfig.ts @@ -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, diff --git a/ts/interfaces/config.ts b/ts/interfaces/config.ts index 4e7b612..5e18fac 100644 --- a/ts/interfaces/config.ts +++ b/ts/interfaces/config.ts @@ -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; } /**