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 # 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) ## 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 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", "name": "@push.rocks/smartvm",
"version": "1.2.0", "version": "1.3.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",
+2
View File
@@ -8,6 +8,8 @@
- `BaseImageManager` downloads Firecracker CI demo artifacts or hosted project manifests into `/tmp/.smartvm/base-images` by default - `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 - Base image cache keeps 2 bundles by default and warns before evicting older bundles
- Hosted manifest examples live in `assets/base-images/` - 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()`
+457 -358
View File
@@ -1,190 +1,341 @@
# @push.rocks/smartvm # @push.rocks/smartvm
A TypeScript module that wraps Amazon's [Firecracker VMM](https://firecracker-microvm.github.io/) to create, configure, and manage lightweight microVMs with a clean, type-safe API. Run Firecracker microVMs from TypeScript without hand-rolling process management, Unix-socket HTTP calls, TAP devices, bridge setup, image caching, and cleanup. `@push.rocks/smartvm` gives you a typed orchestration layer for building tiny, fast, disk-light VM workflows on Linux/KVM.
## Issue Reporting and Security ## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly. For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## What It Does
`smartvm` wraps the operational parts of Amazon Firecracker:
- Downloads and caches Firecracker binaries.
- Resolves bootable Firecracker CI base-image bundles with `latest` and `lts` presets.
- Supports project-owned hosted base-image manifests with SHA256 verification.
- Creates and controls microVMs through Firecracker's HTTP-over-Unix-socket API.
- Converts TypeScript camelCase config into Firecracker's snake_case payloads.
- Creates TAP devices, a Linux bridge, static guest IP assignments, and NAT rules.
- Defaults VM runtime artifacts to tmpfs via `/dev/shm/.smartvm/runtime` when available.
- Stages writable drives into per-VM ephemeral storage by default so guest writes do not touch cached rootfs files.
- Cleans up Firecracker processes, sockets, TAPs, bridges, NAT rules, and staged drive copies.
The design goal is close to a Cloudflare Workers/Deno-style filesystem model, adapted to Firecracker: immutable root image, explicit writable scratch, no accidental persistent state, and persistence only when you opt in.
## Install ## Install
```bash ```bash
pnpm install @push.rocks/smartvm pnpm add @push.rocks/smartvm
``` ```
> ⚡ **Prerequisites**: Firecracker requires a Linux host with KVM support (`/dev/kvm`). Networking features (TAP devices, bridges, NAT) require root privileges. ## Runtime Requirements
Runtime host requirements: Firecracker is a Linux/KVM technology. The package is TypeScript, but the runtime host must provide the VM substrate.
- Linux with `/dev/kvm` available to the running process | Requirement | Why it matters |
- A Firecracker binary downloaded by `ensureBinary()` or supplied through `firecrackerBinaryPath` |---|---|
- Root privileges for automatic bridge, TAP, IP forwarding, and iptables NAT setup | Linux with `/dev/kvm` | Firecracker needs KVM acceleration. |
- Host tools available for networking: `ip`, `sysctl`, and `iptables` | Firecracker binary | Downloaded by `ensureBinary()` or supplied through `firecrackerBinaryPath`. |
- IPv4 CIDR subnets with prefix length `1-30`; the bridge uses the first usable address as gateway and guests start at the second usable address | Root privileges for networking | TAP devices, bridges, IP forwarding, and iptables NAT require elevated privileges. |
| Host tools: `curl`, `tar`, `ip`, `sysctl`, `iptables` | Used for binary/image downloads and network setup. |
| Enough tmpfs memory | Writable VM drives are copied into `/dev/shm` by default when available. |
## Quick Start ## Quick Start
This is the happy path: let `smartvm` download Firecracker, resolve a known-good base image, boot it, and clean it up.
```typescript ```typescript
import { SmartVM } from '@push.rocks/smartvm'; import { SmartVM } from '@push.rocks/smartvm';
// 1. Create the orchestrator
const smartvm = new SmartVM({ const smartvm = new SmartVM({
dataDir: '/opt/smartvm', // where binaries, kernels, rootfs are cached // Optional. Defaults are intentionally disk-light.
firecrackerVersion: 'v1.7.0', // or omit for latest dataDir: '/tmp/.smartvm',
arch: 'x86_64', runtimeDir: '/dev/shm/.smartvm/runtime',
}); });
// 2. Download Firecracker if not already present const baseImage = await smartvm.ensureBaseImage({ preset: 'latest' });
await smartvm.ensureBinary();
// 3. Create a MicroVM
const vm = await smartvm.createVM({ const vm = await smartvm.createVM({
id: 'hello-firecracker',
bootSource: { bootSource: {
kernelImagePath: '/opt/smartvm/kernels/vmlinux', kernelImagePath: baseImage.kernelImagePath,
bootArgs: 'console=ttyS0 reboot=k panic=1 pci=off', bootArgs: baseImage.bootArgs,
}, },
machineConfig: { machineConfig: {
vcpuCount: 2, vcpuCount: 1,
memSizeMib: 256, memSizeMib: 256,
}, },
drives: [ drives: [
{ {
driveId: 'rootfs', driveId: 'rootfs',
pathOnHost: '/opt/smartvm/rootfs/ubuntu.ext4', pathOnHost: baseImage.rootfsPath,
isRootDevice: true, isRootDevice: true,
isReadOnly: false, isReadOnly: baseImage.rootfsIsReadOnly,
}, },
], ],
networkInterfaces: [
{ ifaceId: 'eth0' }, // TAP device and MAC auto-generated
],
}); });
// 4. Start it 🚀 try {
await vm.start(); await vm.start();
console.log(vm.state); // "running"
// 5. Inspect console.log(await vm.getVersion());
console.log(vm.state); // 'running' console.log(await vm.getInfo());
console.log(await vm.getInfo()); // Firecracker instance info } finally {
if (vm.state === 'running' || vm.state === 'paused') {
// 6. Pause / Resume await vm.stop();
await vm.pause(); // state → 'paused' }
await vm.resume(); // state → 'running' await vm.cleanup();
await smartvm.cleanup();
// 7. Stop and clean up }
await vm.stop();
await vm.cleanup();
await smartvm.cleanup();
``` ```
## Architecture Overview ## Disk-Light Runtime Model
``` By default, `smartvm` treats VMs as ephemeral execution units.
┌─────────────────────────────────────────────┐
│ SmartVM │ ← Top-level orchestrator | Path | Default | Persistence model |
│ ┌──────────────┐ ┌────────────────────┐ │ |---|---|---|
│ │ ImageManager │ │ NetworkManager │ │ | Firecracker binaries | `/tmp/.smartvm/bin` | Cached for reuse. |
│ │ (binaries, │ │ (TAP, bridge, │ │ | Base images | `/tmp/.smartvm/base-images` | Cached, retention-limited, verified before reuse. |
│ │ kernels, │ │ NAT, IP alloc) │ │ | VM sockets | `/dev/shm/.smartvm/runtime/<vmId>/firecracker.sock` | Per-VM tmpfs, deleted on cleanup. |
│ │ rootfs) │ │ │ │ | Writable drives | `/dev/shm/.smartvm/runtime/<vmId>/drives/*` | Per-VM tmpfs copy, deleted on cleanup. |
│ └──────────────┘ └────────────────────┘ │ | Read-only drives | Original path | Not copied unless `ephemeral: true`. |
│ │
│ ┌─────────── MicroVM ────────────────┐ │ Writable drives are staged into the VM runtime directory before boot. Firecracker receives the staged path, so guest writes do not modify cached base images or source rootfs files.
│ │ state: created → configuring → │ │
│ │ running → paused → stopped │ │ ```typescript
│ │ │ │ const vm = await smartvm.createVM({
│ │ ┌──────────────────────────────┐ │ │ bootSource: { kernelImagePath: baseImage.kernelImagePath, bootArgs: baseImage.bootArgs },
│ │ │ FirecrackerProcess │ │ │ machineConfig: { vcpuCount: 1, memSizeMib: 256 },
│ │ │ (child process management) │ │ │ drives: [
│ │ └──────────────────────────────┘ │ │ {
│ │ ┌──────────────────────────────┐ │ │ driveId: 'rootfs',
│ │ │ SocketClient │ │ │ pathOnHost: baseImage.rootfsPath,
│ │ │ (HTTP over Unix socket) │ │ │ isRootDevice: true,
│ │ └──────────────────────────────┘ │ │ isReadOnly: false,
│ │ ┌──────────────────────────────┐ │ │ // Default for writable drives: true
│ │ │ VMConfig │ │ │ ephemeral: true,
│ │ │ (camelCase → snake_case) │ │ │ },
│ │ └──────────────────────────────┘ │ │ ],
│ └────────────────────────────────────┘ │ });
└─────────────────────────────────────────────┘
``` ```
Firecracker exposes a REST API over a Unix domain socket. This module handles all the plumbing: spawning the process, waiting for the socket, translating your TypeScript config into Firecracker's snake_case API payloads, managing TAP devices, and tearing everything down on exit. Opt into persistence only when that is the point:
## API Reference ```typescript
const persistentVm = await smartvm.createVM({
bootSource: { kernelImagePath: '/images/vmlinux', bootArgs: 'console=ttyS0 reboot=k panic=1 pci=off' },
machineConfig: { vcpuCount: 2, memSizeMib: 512 },
drives: [
{
driveId: 'state',
pathOnHost: '/var/lib/my-vm/state.ext4',
isRootDevice: true,
isReadOnly: false,
ephemeral: false,
},
],
});
```
### `SmartVM` — The Orchestrator You can also disable writable-drive staging globally:
The entry point for everything. Manages binary downloads, VM creation, and global cleanup. ```typescript
const smartvm = new SmartVM({
ephemeralWritableDrives: false,
});
```
Best practice for high-volume VM starts:
- Prefer `squashfs` or another read-only root filesystem.
- Put mutable scratch data on tmpfs-backed writable drives.
- Keep shared assets read-only by default.
- Use external services, object storage, databases, or explicit persistent drives for durable state.
- Use a dedicated `runtimeDir` on a real tmpfs if `/dev/shm` is too small or unavailable.
## Architecture
```text
SmartVM
ImageManager downloads/caches Firecracker binaries and manual images
BaseImageManager resolves known-good base-image bundles
NetworkManager creates TAP devices, bridge, NAT, and static guest network data
MicroVM
FirecrackerProcess starts/stops the VMM process
SocketClient talks HTTP over the Firecracker Unix socket
VMConfig validates and transforms TypeScript config
```
Firecracker exposes a REST API over a Unix domain socket. `smartvm` starts the child process, waits for readiness, sends pre-boot config in the right order, starts the instance, and tears down host resources when you are done.
## SmartVM
`SmartVM` is the top-level orchestrator.
```typescript ```typescript
import { SmartVM } from '@push.rocks/smartvm'; import { SmartVM } from '@push.rocks/smartvm';
import type { ISmartVMOptions } from '@push.rocks/smartvm'; import type { ISmartVMOptions } from '@push.rocks/smartvm';
const smartvm = new SmartVM({ const options: ISmartVMOptions = {
dataDir: '/tmp/.smartvm', // default: /tmp/.smartvm dataDir: '/tmp/.smartvm',
firecrackerVersion: 'v1.7.0', // default: latest from GitHub runtimeDir: '/dev/shm/.smartvm/runtime',
arch: 'x86_64', // default: x86_64 (also: aarch64) ephemeralWritableDrives: true,
firecrackerBinaryPath: '/usr/bin/firecracker', // optional: skip download firecrackerVersion: 'v1.7.0',
baseImageCacheDir: '/tmp/.smartvm/base-images', // default: /tmp/.smartvm/base-images arch: 'x86_64',
maxStoredBaseImages: 2, // default: keep at most 2 cached base image bundles firecrackerBinaryPath: '/usr/bin/firecracker',
bridgeName: 'svbr0', // default: svbr0 bridgeName: 'svbr0',
subnet: '172.30.0.0/24', // default: 172.30.0.0/24 subnet: '172.30.0.0/24',
}); baseImageCacheDir: '/tmp/.smartvm/base-images',
maxStoredBaseImages: 2,
baseImageManifestUrl: 'https://assets.example.com/smartvm/manifest.json',
baseImageManifestPath: './assets/base-images/local.manifest.json',
};
const smartvm = new SmartVM(options);
``` ```
| Method | Description | | API | Description |
|---|---| |---|---|
| `ensureBinary()` | Downloads Firecracker from GitHub if not cached. Returns path to binary. | | `ensureBinary()` | Ensures the Firecracker binary exists and returns its path. |
| `ensureBaseImage(options)` | Downloads/caches a Firecracker CI base image bundle. Defaults to the `latest` preset. | | `ensureBaseImage(options)` | Resolves/downloads a base-image bundle and returns kernel/rootfs paths plus boot args. |
| `createVM(config)` | Creates a `MicroVM` instance (not yet started). Returns the VM. | | `createVM(config)` | Creates a `MicroVM` instance. It does not boot until `vm.start()`. |
| `getVM(id)` | Look up an active VM by ID. | | `getRuntimeDir()` | Returns the active runtime directory used for per-VM tmpfs artifacts. |
| `listVMs()` | Returns an array of active VM IDs. | | `getVM(id)` | Looks up an active VM by ID. |
| `vmCount` | Number of active VMs. | | `listVMs()` | Lists active VM IDs. |
| `stopAll()` | Stops all running/paused VMs in parallel. | | `vmCount` | Number of tracked VMs. |
| `cleanup()` | Stops all VMs, removes TAP devices and bridge. | | `removeVM(id)` | Removes a VM from the internal tracking map. |
| `stopAll()` | Stops every running or paused VM. |
| `cleanup()` | Cleans up all tracked VMs and networking resources. |
### `MicroVM` — VM Lifecycle ## Base Images
Each VM follows a strict state machine: **created → configuring → running → paused → stopped**. `BaseImageManager` gives you fast bootable image discovery without committing giant rootfs files to git.
```typescript
const baseImage = await smartvm.ensureBaseImage(); // preset: "latest"
const ltsBaseImage = await smartvm.ensureBaseImage({ preset: 'lts' });
const freshBaseImage = await smartvm.ensureBaseImage({ preset: 'latest', forceDownload: true });
```
Presets:
| Preset | Behavior |
|---|---|
| `latest` | Resolves the latest Firecracker release and matching CI demo artifacts. |
| `lts` | Uses the pinned Firecracker CI train `v1.7` / Firecracker `v1.7.0`. |
| `hosted` | Uses a project-owned manifest. Requires `manifestUrl`, `manifestPath`, or manager-level hosted manifest options. |
The resolver prefers read-only `squashfs` rootfs artifacts when Firecracker CI exposes them, falling back to `ext4` when needed.
```typescript
import { BaseImageManager } from '@push.rocks/smartvm';
const baseImageManager = new BaseImageManager({
arch: 'x86_64',
cacheDir: '/tmp/.smartvm/base-images',
maxStoredBaseImages: 4,
hostedManifestPath: './assets/base-images/smartvm-minimal.manifest.json',
});
console.log(baseImageManager.getCacheDir());
console.log(baseImageManager.getMaxStoredBaseImages());
const hosted = await baseImageManager.ensureBaseImage({ preset: 'hosted' });
const evictedBundleIds = await baseImageManager.pruneBaseImageCache(hosted.bundleId);
```
`IBaseImageBundle` contains:
- `kernelImagePath`
- `rootfsPath`
- `rootfsType`
- `rootfsIsReadOnly`
- `bootArgs`
- `firecrackerVersion`
- `checksums`
- `sizes`
- source metadata
Cache behavior:
- Default cache directory: `/tmp/.smartvm/base-images`
- Default retention: `2` bundles
- Older bundles are evicted with a `console.warn` when retention is exceeded
- Cached artifacts are checked for size and SHA256 before reuse
- Hosted URL artifacts require SHA256 hashes
- Hosted local-path artifacts may omit SHA256, but hashes are still recorded in the cached manifest
Hosted manifest example:
The repository ships an example at `assets/base-images/smartvm-minimal.manifest.example.json`.
```json
{
"schemaVersion": 1,
"bundleId": "smartvm-minimal-v1-x86_64",
"name": "SmartVM minimal x86_64 bundle",
"arch": "x86_64",
"firecrackerVersion": "v1.15.1",
"rootfsType": "squashfs",
"rootfsIsReadOnly": true,
"bootArgs": "console=ttyS0 reboot=k panic=1 pci=off ro rootfstype=squashfs",
"kernel": {
"url": "https://assets.example.com/smartvm/vmlinux",
"fileName": "vmlinux",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"sizeBytes": 12345678
},
"rootfs": {
"url": "https://assets.example.com/smartvm/rootfs.squashfs",
"fileName": "rootfs.squashfs",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"sizeBytes": 12345678
}
}
```
## MicroVM Lifecycle
`MicroVM` is a single Firecracker instance with a strict state machine:
```text
created -> configuring -> running -> paused -> stopped
\-> error
```
```typescript ```typescript
const vm = await smartvm.createVM({ const vm = await smartvm.createVM({
id: 'my-vm', // optional, auto-generated UUID if omitted id: 'api-worker-1',
bootSource: { bootSource: {
kernelImagePath: '/path/to/vmlinux', kernelImagePath: baseImage.kernelImagePath,
bootArgs: 'console=ttyS0 reboot=k panic=1', bootArgs: baseImage.bootArgs,
initrdPath: '/path/to/initrd', // optional
}, },
machineConfig: { machineConfig: {
vcpuCount: 4, vcpuCount: 2,
memSizeMib: 512, memSizeMib: 512,
smt: false, smt: false,
cpuTemplate: 'T2', // optional: C3, T2, T2S, T2CL, T2A, V1N1 cpuTemplate: 'T2',
trackDirtyPages: true, trackDirtyPages: true,
}, },
drives: [ drives: [
{ {
driveId: 'rootfs', driveId: 'rootfs',
pathOnHost: '/path/to/rootfs.ext4', pathOnHost: baseImage.rootfsPath,
isRootDevice: true, isRootDevice: true,
isReadOnly: false, isReadOnly: baseImage.rootfsIsReadOnly,
cacheType: 'Unsafe', // or 'Writeback' cacheType: 'Unsafe',
ephemeral: true,
rateLimiter: { rateLimiter: {
bandwidth: { size: 100_000_000, refillTime: 1_000_000_000 }, bandwidth: { size: 100_000_000, refillTime: 1_000_000_000 },
ops: { size: 1000, refillTime: 1_000_000_000 }, ops: { size: 1000, refillTime: 1_000_000_000 },
}, },
}, },
], ],
networkInterfaces: [ networkInterfaces: [{ ifaceId: 'eth0' }],
{
ifaceId: 'eth0',
// hostDevName and guestMac auto-generated if omitted
},
],
vsock: { vsock: {
guestCid: 3, guestCid: 3,
udsPath: '/tmp/vsock.sock', udsPath: '/dev/shm/api-worker-1.vsock',
}, },
balloon: { balloon: {
amountMib: 128, amountMib: 128,
@@ -195,276 +346,184 @@ const vm = await smartvm.createVM({
version: 'V2', version: 'V2',
networkInterfaces: ['eth0'], networkInterfaces: ['eth0'],
}, },
logger: {
logPath: '/tmp/firecracker.log',
level: 'Debug',
showLogOrigin: true,
},
metrics: {
metricsPath: '/tmp/firecracker-metrics.fifo',
},
}); });
await vm.start();
await vm.pause();
await vm.resume();
await vm.stop();
await vm.cleanup();
``` ```
| Method | Valid States | Description | | API | Valid state | Description |
|---|---|---| |---|---|---|
| `start()` | `created` | Spawns Firecracker, applies config, boots the VM | | `start()` | `created` | Stages ephemeral drives, starts Firecracker, applies config, boots the VM. |
| `pause()` | `running` | Pauses VM execution | | `pause()` | `running` | Pauses execution. |
| `resume()` | `paused` | Resumes a paused VM | | `resume()` | `paused` | Resumes execution. |
| `stop()` | `running`, `paused` | Graceful shutdown (Ctrl+Alt+Del), then force kill | | `stop()` | `running`, `paused` | Sends Ctrl+Alt+Del, waits briefly, then stops the process. |
| `cleanup()` | any | Full cleanup: kill process, remove socket, remove TAPs | | `cleanup()` | any | Stops process, deletes sockets/runtime dir, removes auto-created TAPs. |
| `getInfo()` | any (after start) | Returns Firecracker instance info | | `getInfo()` | after start | Returns Firecracker instance info. |
| `getVersion()` | any (after start) | Returns Firecracker version | | `getVersion()` | after start | Returns Firecracker version info. |
| `createSnapshot(params)` | `paused` | Create a VM snapshot | | `setMetadata(data)` | `running`, `paused` | Writes MMDS metadata. |
| `loadSnapshot(params)` | `created`, `configuring` | Load a VM from snapshot | | `getMetadata()` | `running`, `paused` | Reads MMDS metadata. |
| `setMetadata(data)` | `running`, `paused` | Set MMDS metadata | | `updateDrive(id, path)` | `running`, `paused` | Hot-updates a drive path. |
| `getMetadata()` | `running`, `paused` | Get MMDS metadata | | `updateNetworkInterface(id, update)` | `running`, `paused` | Updates network interface config such as rate limiters. |
| `updateDrive(id, path)` | `running`, `paused` | Hot-update a drive path | | `updateBalloon(mib)` | `running`, `paused` | Resizes the balloon device. |
| `updateBalloon(mib)` | `running`, `paused` | Resize the balloon device | | `createSnapshot(params)` | `paused` | Creates a Firecracker snapshot. |
| `getTapDevices()` | any | Returns TAP devices associated with this VM | | `loadSnapshot(params)` | `created`, `configuring` | Low-level Firecracker snapshot-load call; requires an initialized socket client. |
| `getTapDevices()` | any | Returns TAP devices created automatically by this VM. |
| `getVMConfig()` | any | Returns the internal `VMConfig` instance. |
| `getRuntimeDir()` | any | Returns the per-VM runtime directory after it has been created. |
### `ImageManager` — Binary & Image Management Snapshot caveat: diff snapshots require dirty-page tracking to be enabled before boot through `machineConfig.trackDirtyPages`. Snapshot restore is currently exposed as a low-level API; full restore orchestration should be built around the Firecracker process/config lifecycle intentionally.
Handles downloading and caching Firecracker binaries, kernels, and rootfs images. ## Networking
`NetworkManager` creates host-side networking primitives. It does not run DHCP inside the guest. Your guest image must configure its interface itself, or you must pass static `ip=` kernel boot arguments.
Automatic mode:
```typescript ```typescript
const imageManager = smartvm.imageManager; const vm = await smartvm.createVM({
bootSource: { kernelImagePath: baseImage.kernelImagePath, bootArgs: baseImage.bootArgs },
// Auto-download the latest Firecracker release machineConfig: { vcpuCount: 1, memSizeMib: 256 },
const version = await imageManager.getLatestVersion(); // e.g. 'v1.7.0' drives: [{ driveId: 'rootfs', pathOnHost: baseImage.rootfsPath, isRootDevice: true, isReadOnly: baseImage.rootfsIsReadOnly }],
const binaryPath = await imageManager.downloadFirecracker(version); networkInterfaces: [{ ifaceId: 'eth0' }],
// Download kernel and rootfs images
const kernelPath = await imageManager.downloadKernel(
'https://example.com/vmlinux-5.10',
'vmlinux-5.10',
);
const rootfsPath = await imageManager.downloadRootfs(
'https://example.com/ubuntu-22.04.ext4',
'ubuntu-22.04.ext4',
);
// Create a blank rootfs or clone an existing one
const blankPath = await imageManager.createBlankRootfs('scratch.ext4', 1024);
const clonePath = await imageManager.cloneRootfs(rootfsPath, 'ubuntu-clone.ext4');
```
**Data directory layout:**
```
/tmp/.smartvm/
bin/<version>/firecracker
bin/<version>/jailer
kernels/<name>
rootfs/<name>
sockets/<vmId>.sock
```
### `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.
```typescript
const baseImage = await smartvm.ensureBaseImage(); // same as { preset: 'latest' }
const ltsBaseImage = await smartvm.ensureBaseImage({ preset: 'lts' });
const hostedBaseImage = await smartvm.ensureBaseImage({
manifestUrl: 'https://assets.example.com/push.rocks/smartvm/base-images/smartvm-minimal-v1/x86_64/manifest.json',
}); });
```
Static-kernel-args mode:
```typescript
const tap = await smartvm.networkManager.createTapDevice('net-vm', 'eth0');
const vm = await smartvm.createVM({ const vm = await smartvm.createVM({
id: 'net-vm',
bootSource: { bootSource: {
kernelImagePath: baseImage.kernelImagePath, kernelImagePath: baseImage.kernelImagePath,
bootArgs: baseImage.bootArgs, bootArgs: `${baseImage.bootArgs} ${smartvm.networkManager.getGuestNetworkBootArgs(tap)}`,
}, },
machineConfig: { vcpuCount: 1, memSizeMib: 256 }, machineConfig: { vcpuCount: 1, memSizeMib: 256 },
drives: [ drives: [{ driveId: 'rootfs', pathOnHost: baseImage.rootfsPath, isRootDevice: true, isReadOnly: baseImage.rootfsIsReadOnly }],
networkInterfaces: [
{ {
driveId: 'rootfs', ifaceId: 'eth0',
pathOnHost: baseImage.rootfsPath, hostDevName: tap.tapName,
isRootDevice: true, guestMac: tap.mac,
isReadOnly: baseImage.rootfsIsReadOnly,
}, },
], ],
}); });
``` ```
**Cache behavior:** Networking behavior:
- Default cache directory: `/tmp/.smartvm/base-images` - Default bridge: `svbr0`
- Default retention: at most `2` base image bundles - Default subnet: `172.30.0.0/24`
- Configure retention with `maxStoredBaseImages` - Subnet input is normalized to the network address
- Configure location with `baseImageCacheDir` - Prefix length must be `1-30`
- When a new download causes the retention limit to be exceeded, older bundles are removed and a console warning is emitted - Gateway uses the first usable address
- Downloaded bundles include a local `manifest.json` with source URLs/keys, file paths, sizes, and computed SHA256 hashes - Guest IP allocation starts at the second usable address
- Allocation is sequential and not reused within the same `NetworkManager` instance
- MAC addresses are deterministic and locally administered (`02:xx:xx:xx:xx:xx`)
- TAP names are capped to Linux's 15-character IFNAMSIZ limit
- NAT masquerade uses the host default route interface
- Use a dedicated bridge name; `cleanup()` tears down the bridge configured by this manager
Example configuration: ## ImageManager
`ImageManager` is the lower-level helper for Firecracker binaries and manually managed kernel/rootfs files.
```typescript ```typescript
const smartvm = new SmartVM({ const imageManager = smartvm.imageManager;
baseImageCacheDir: '/tmp/.smartvm/base-images',
maxStoredBaseImages: 4, await imageManager.ensureDirectories();
baseImageManifestUrl: 'https://assets.example.com/push.rocks/smartvm/base-images/smartvm-minimal-v1/x86_64/manifest.json',
}); const latest = await imageManager.getLatestVersion();
const firecrackerPath = await imageManager.downloadFirecracker(latest);
const kernelPath = await imageManager.downloadKernel(
'https://example.com/vmlinux',
'vmlinux',
);
const rootfsPath = await imageManager.downloadRootfs(
'https://example.com/rootfs.ext4',
'rootfs.ext4',
);
const blankRootfs = await imageManager.createBlankRootfs('scratch.ext4', 1024);
const clonedRootfs = await imageManager.cloneRootfs(rootfsPath, 'vm-rootfs.ext4');
``` ```
Hosted manifest format examples live in `assets/base-images/`. Hosted URL artifacts require SHA256 hashes; `smartvm` verifies them during download before returning the bundle paths. Useful path helpers:
### `NetworkManager` — Host Networking - `getBinDir()`
- `getKernelsDir()`
- `getRootfsDir()`
- `getSocketsDir()`
- `getFirecrackerPath(version)`
- `getJailerPath(version)`
- `getSocketPath(vmId)`
Automatically manages TAP devices, a Linux bridge, and iptables NAT masquerade rules so VMs get internet access out of the box. Note: `SmartVM.createVM()` uses `runtimeDir/<vmId>/firecracker.sock` for new VM sockets by default. `ImageManager.getSocketPath()` remains available for lower-level/custom flows.
```typescript ## VMConfig
const networkManager = smartvm.networkManager;
// Manually create a TAP device (usually handled by MicroVM.start()) `VMConfig` validates `IMicroVMConfig` and transforms it into Firecracker API payloads.
const tap = await networkManager.createTapDevice('vm-id', 'eth0');
console.log(tap);
// {
// tapName: 'svvmideth0',
// guestIp: '172.30.0.2',
// gatewayIp: '172.30.0.1',
// subnetMask: '255.255.255.0',
// mac: '02:a3:b1:c4:d2:e5'
// }
// Generate kernel boot args for the guest
const bootArgs = networkManager.getGuestNetworkBootArgs(tap);
// 'ip=172.30.0.2::172.30.0.1:255.255.255.0::eth0:off'
```
**Networking architecture:**
- Creates a Linux bridge (default: `svbr0`) with gateway at the first usable subnet address
- Each VM gets a TAP device attached to the bridge
- Sequential IP allocation from the second usable subnet address onwards
- Subnet input is normalized to the network address and allocation fails with `IP_EXHAUSTED` when no guest addresses remain
- iptables NAT masquerade for outbound internet
- Deterministic MAC generation (`02:xx:xx:xx:xx:xx` locally-administered)
- TAP names fit Linux's 15-char IFNAMSIZ limit
### `VMConfig` — Config Transformer
Converts your camelCase TypeScript config into Firecracker's snake_case API payloads. Also validates configuration before boot.
```typescript ```typescript
import { VMConfig } from '@push.rocks/smartvm'; import { VMConfig } from '@push.rocks/smartvm';
const vmConfig = new VMConfig({ const vmConfig = new VMConfig({
bootSource: { kernelImagePath: '/path/to/vmlinux' }, bootSource: { kernelImagePath: '/images/vmlinux', bootArgs: 'console=ttyS0 reboot=k panic=1 pci=off' },
machineConfig: { vcpuCount: 2, memSizeMib: 256 }, machineConfig: { vcpuCount: 2, memSizeMib: 256 },
drives: [{ driveId: 'rootfs', pathOnHost: '/images/rootfs.ext4', isRootDevice: true }],
}); });
// Validate const validation = vmConfig.validate();
const result = vmConfig.validate(); if (!validation.valid) {
// { valid: true, errors: [] } throw new Error(validation.errors.join('; '));
}
// Generate API payloads console.log(vmConfig.toBootSourcePayload());
vmConfig.toBootSourcePayload(); console.log(vmConfig.toMachineConfigPayload());
// { kernel_image_path: '/path/to/vmlinux' } console.log(vmConfig.toDrivePayload(vmConfig.config.drives![0]));
vmConfig.toMachineConfigPayload();
// { vcpu_count: 2, mem_size_mib: 256 }
``` ```
### `SocketClient` — Low-Level HTTP Client The constructor clones caller-provided config, so internal normalization does not mutate your original object.
Direct HTTP-over-Unix-socket communication with Firecracker. You typically don't need this directly — `MicroVM` handles it — but it's available if you want raw API access. ## SocketClient
`SocketClient` is the raw Firecracker API client. Most users should go through `MicroVM`, but the low-level client is exported for tooling and diagnostics.
```typescript ```typescript
import { SocketClient } from '@push.rocks/smartvm'; import { SocketClient } from '@push.rocks/smartvm';
const client = new SocketClient({ socketPath: '/tmp/firecracker.sock' }); const client = new SocketClient({ socketPath: '/dev/shm/.smartvm/runtime/vm/firecracker.sock' });
const info = await client.get('/'); const version = await client.get('/version');
const putResult = await client.put('/machine-config', { vcpu_count: 2, mem_size_mib: 256 }); const machineConfig = await client.put('/machine-config', {
const patchResult = await client.patch('/vm', { state: 'Paused' }); vcpu_count: 1,
mem_size_mib: 256,
});
const paused = await client.patch('/vm', { state: 'Paused' });
// Check if socket is alive (polls with timeout) console.log(version.ok, version.statusCode, version.body);
const ready = await client.isReady(5000); console.log(machineConfig.ok, machineConfig.statusCode, machineConfig.body);
console.log(paused.ok, paused.statusCode, paused.body);
``` ```
### `SmartVMError` — Error Handling `SocketClient` returns `{ ok, statusCode, body }`. Non-2xx responses do not become `API_ERROR` until higher-level `MicroVM` helpers validate them.
All errors thrown by this module are `SmartVMError` instances with structured error codes. ## Metadata Service
```typescript Firecracker MMDS lets the host pass structured metadata to a running VM.
import { SmartVMError } from '@push.rocks/smartvm';
try {
await vm.start();
} catch (err) {
if (err instanceof SmartVMError) {
console.log(err.code); // 'INVALID_CONFIG', 'SOCKET_TIMEOUT', 'API_ERROR', etc.
console.log(err.statusCode); // HTTP status from Firecracker (if applicable)
console.log(err.details); // Raw error body from Firecracker (if applicable)
}
}
```
**Error codes:**
| Code | Description |
|---|---|
| `INVALID_STATE` | Operation not valid for current VM state |
| `INVALID_CONFIG` | Config validation failed |
| `SOCKET_TIMEOUT` | Firecracker socket didn't become ready |
| `API_TIMEOUT` | Firecracker API didn't respond in time |
| `SOCKET_REQUEST_FAILED` | HTTP request to socket failed |
| `API_ERROR` | Firecracker returned a non-2xx response |
| `BINARY_NOT_FOUND` | Firecracker binary not at expected path |
| `DOWNLOAD_FAILED` | Failed to download binary/kernel/rootfs |
| `VERSION_FETCH_FAILED` | Couldn't query GitHub for latest version |
| `BASE_IMAGE_RESOLVE_FAILED` | Failed to resolve Firecracker CI base image artifacts |
| `BASE_IMAGE_MANIFEST_FAILED` | Failed to load or use a hosted base image manifest |
| `BASE_IMAGE_PREPARE_FAILED` | Failed to download or prepare a base image bundle |
| `INVALID_BASE_IMAGE_MANIFEST` | Hosted base image manifest is invalid |
| `INVALID_BASE_IMAGE_CACHE_LIMIT` | Base image cache retention limit is invalid |
| `INVALID_SUBNET` | Subnet is not a supported IPv4 CIDR range |
| `INVALID_INTERFACE_NAME` | Bridge or TAP interface name is invalid |
| `IP_EXHAUSTED` | No guest IP addresses remain in the configured subnet |
| `BRIDGE_SETUP_FAILED` | Failed to create network bridge |
| `TAP_CREATE_FAILED` | Failed to create TAP device |
| `ROOTFS_CREATE_FAILED` | Failed to create blank rootfs |
| `ROOTFS_CLONE_FAILED` | Failed to clone rootfs image |
| `START_FAILED` | VM start sequence failed |
| `NO_CLIENT` | Socket client not initialized |
## Snapshots
Create and restore VM snapshots for fast cold-start or live migration:
```typescript
// Pause first (required for snapshots)
await vm.pause();
// Create a snapshot
await vm.createSnapshot({
snapshotPath: '/tmp/snapshot.bin',
memFilePath: '/tmp/snapshot-mem.bin',
snapshotType: 'Full', // or 'Diff' for incremental
});
// Later: restore from snapshot
const freshVm = await smartvm.createVM({
bootSource: { kernelImagePath: '/path/to/vmlinux' },
machineConfig: { vcpuCount: 2, memSizeMib: 256 },
});
await freshVm.loadSnapshot({
snapshotPath: '/tmp/snapshot.bin',
memFilePath: '/tmp/snapshot-mem.bin',
resumeVm: true,
});
```
## MMDS (Metadata Service)
Pass metadata to your guest VM via Firecracker's Microvm Metadata Service:
```typescript ```typescript
const vm = await smartvm.createVM({ const vm = await smartvm.createVM({
bootSource: { /* ... */ }, bootSource: { kernelImagePath: baseImage.kernelImagePath, bootArgs: baseImage.bootArgs },
machineConfig: { /* ... */ }, machineConfig: { vcpuCount: 1, memSizeMib: 256 },
drives: [{ driveId: 'rootfs', pathOnHost: baseImage.rootfsPath, isRootDevice: true, isReadOnly: baseImage.rootfsIsReadOnly }],
networkInterfaces: [{ ifaceId: 'eth0' }], networkInterfaces: [{ ifaceId: 'eth0' }],
mmds: { mmds: {
version: 'V2', version: 'V2',
@@ -474,74 +533,114 @@ const vm = await smartvm.createVM({
await vm.start(); await vm.start();
// Set metadata from host
await vm.setMetadata({ await vm.setMetadata({
instance: { id: 'my-instance', region: 'eu-central-1' }, instance: { id: 'api-worker-1', region: 'local' },
secrets: { apiKey: 'sk-...' }, config: { mode: 'ephemeral' },
}); });
// Guest can access via: curl http://169.254.169.254/latest/meta-data/ console.log(await vm.getMetadata());
const data = await vm.getMetadata();
``` ```
## Graceful Cleanup ## Error Handling
The module registers cleanup handlers via `@push.rocks/smartexit` so resources are released even if your process crashes: All package-level failures use `SmartVMError` with structured codes.
- 🔌 Firecracker child processes are killed
- 🧹 Unix socket files are removed
- 🌐 TAP devices are deleted
- 🌉 Bridge and NAT rules are torn down
You can also trigger cleanup manually:
```typescript ```typescript
// Stop one VM import { SmartVMError } from '@push.rocks/smartvm';
await vm.stop();
await vm.cleanup();
// Stop all VMs and clean everything try {
await smartvm.stopAll(); await vm.start();
await smartvm.cleanup(); } catch (err) {
if (err instanceof SmartVMError) {
console.error(err.code);
console.error(err.statusCode);
console.error(err.details);
}
throw err;
}
``` ```
| Code | Meaning |
|---|---|
| `INVALID_STATE` | Operation is invalid for the current VM state. |
| `INVALID_CONFIG` | VM configuration failed validation. |
| `SOCKET_TIMEOUT` | Firecracker did not create its socket in time. |
| `API_TIMEOUT` | Firecracker API readiness check timed out. |
| `SOCKET_REQUEST_FAILED` | Unix-socket HTTP request failed. |
| `API_ERROR` | Firecracker returned a non-2xx response through a high-level VM call. |
| `BINARY_NOT_FOUND` | Custom Firecracker binary path does not exist. |
| `DOWNLOAD_FAILED` | Binary, kernel, or rootfs download failed. |
| `VERSION_FETCH_FAILED` | Latest Firecracker version lookup failed. |
| `BASE_IMAGE_RESOLVE_FAILED` | Firecracker CI base-image artifact resolution failed. |
| `BASE_IMAGE_MANIFEST_FAILED` | Hosted manifest could not be loaded or used. |
| `BASE_IMAGE_PREPARE_FAILED` | Base-image download/copy/verification failed. |
| `INVALID_BASE_IMAGE_MANIFEST` | Hosted manifest schema or artifact metadata is invalid. |
| `INVALID_BASE_IMAGE_CACHE_LIMIT` | Base-image retention limit is invalid. |
| `INVALID_SUBNET` | Subnet is not a supported IPv4 CIDR. |
| `INVALID_INTERFACE_NAME` | Bridge or TAP name is invalid. |
| `IP_EXHAUSTED` | No guest IPs remain in the configured subnet. |
| `BRIDGE_SETUP_FAILED` | Bridge/NAT setup failed. |
| `TAP_CREATE_FAILED` | TAP creation failed. |
| `ROOTFS_CREATE_FAILED` | Blank rootfs creation failed. |
| `ROOTFS_CLONE_FAILED` | Rootfs clone failed. |
| `START_FAILED` | VM start sequence failed. |
| `NO_CLIENT` | Socket client is not initialized. |
## Testing ## Testing
The default test suite is unit-level and safe to run without KVM or root privileges: Default tests are safe on machines without KVM or root privileges:
```bash ```bash
pnpm test pnpm test
pnpm run build pnpm run build
``` ```
These tests cover config validation, Firecracker payload generation, lifecycle guard errors, VM tracking, and subnet/IP allocation. They do not boot a real microVM. The default suite covers config validation, payload generation, lifecycle guards, base-image cache behavior, hosted manifest validation, VM tracking, ephemeral drive staging, and subnet/IP behavior.
Real Firecracker boot testing should be run on a Linux/KVM host with the runtime requirements above. At minimum, verify `ensureBinary()`, `createVM()`, `start()`, `getInfo()`, `stop()`, and `cleanup()` against a known-good kernel and rootfs image before relying on a new host setup. Opt into real Firecracker boot tests on a Linux/KVM host:
An opt-in integration test scaffold is included and skipped by default:
```bash ```bash
SMARTVM_RUN_INTEGRATION=true pnpm test SMARTVM_RUN_INTEGRATION=true pnpm test
``` ```
Accepted truthy values for `SMARTVM_RUN_INTEGRATION`: `1`, `true`, `yes`.
Useful integration-test environment variables: Useful integration-test environment variables:
- `SMARTVM_BASE_IMAGE_PRESET`: `latest` or `lts` (default: `latest`) | Variable | Purpose |
- `SMARTVM_BASE_IMAGE_MANIFEST_URL`: use a hosted/project-owned base image manifest instead of a preset |---|---|
- `SMARTVM_BASE_IMAGE_MANIFEST_PATH`: use a local base image manifest instead of a preset | `SMARTVM_BASE_IMAGE_PRESET` | `latest` or `lts`; default is `latest`. |
- `SMARTVM_BASE_IMAGE_CACHE_DIR`: override `/tmp/.smartvm/base-images` | `SMARTVM_BASE_IMAGE_MANIFEST_URL` | Hosted/project-owned base-image manifest URL. |
- `SMARTVM_MAX_STORED_BASE_IMAGES`: override the default retention limit of `2` | `SMARTVM_BASE_IMAGE_MANIFEST_PATH` | Local hosted manifest path. |
- `SMARTVM_FIRECRACKER_VERSION`: override the Firecracker binary version; otherwise the base image's recommended version is used | `SMARTVM_BASE_IMAGE_CACHE_DIR` | Override `/tmp/.smartvm/base-images`. |
- `SMARTVM_ARCH`: `x86_64` or `aarch64`; defaults from the host architecture | `SMARTVM_MAX_STORED_BASE_IMAGES` | Override default retention of `2`. |
- `SMARTVM_INTEGRATION_DATA_DIR`: override the Firecracker binary/socket data directory | `SMARTVM_FIRECRACKER_VERSION` | Override the Firecracker binary version. |
| `SMARTVM_ARCH` | `x86_64` or `aarch64`; defaults from host architecture. |
| `SMARTVM_INTEGRATION_DATA_DIR` | Override the Firecracker binary data directory used by integration tests. |
## TypeScript Interfaces ## TypeScript Surface
All configuration interfaces are fully exported for type-safe usage: Main exports:
```typescript
export {
SmartVM,
MicroVM,
NetworkManager,
FirecrackerProcess,
BaseImageManager,
ImageManager,
SocketClient,
VMConfig,
};
```
Important exported types:
```typescript ```typescript
import type { import type {
ISmartVMOptions, ISmartVMOptions,
IMicroVMRuntimeOptions,
IMicroVMConfig, IMicroVMConfig,
IBootSource, IBootSource,
IMachineConfig, IMachineConfig,
+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'); 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
// ============================================================ // ============================================================
@@ -688,6 +766,9 @@ tap.test('SmartVM - instantiation with defaults', async () => {
expect(smartvm.imageManager).toBeTruthy(); expect(smartvm.imageManager).toBeTruthy();
expect(smartvm.baseImageManager).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);
}); });
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartvm', name: '@push.rocks/smartvm',
version: '1.2.0', version: '1.3.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'
} }
+4 -4
View File
@@ -485,14 +485,14 @@ export class BaseImageManager {
} }
private selectRootfsKey(keys: string[]): string { 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)); const squashfsKeys = keys.filter((key) => /\/ubuntu-[^/]+\.squashfs$/.test(key));
if (squashfsKeys.length > 0) { if (squashfsKeys.length > 0) {
return squashfsKeys.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1)!; 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'); 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 { 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;
} }
/** /**
@@ -83,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,
@@ -318,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.
*/ */
@@ -334,12 +352,69 @@ 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.
*/ */
+33 -2
View File
@@ -26,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',
@@ -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. * Ensure the Firecracker binary is available.
* Downloads it if not present. * Downloads it if not present.
@@ -110,8 +136,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(
@@ -120,6 +147,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
+1
View File
@@ -26,6 +26,7 @@ export class VMConfig {
drives: config.drives?.map((drive) => ({ drives: config.drives?.map((drive) => ({
...drive, ...drive,
rateLimiter: drive.rateLimiter ? this.cloneRateLimiter(drive.rateLimiter) : undefined, rateLimiter: drive.rateLimiter ? this.cloneRateLimiter(drive.rateLimiter) : undefined,
ephemeral: drive.ephemeral,
})), })),
networkInterfaces: config.networkInterfaces?.map((iface) => ({ networkInterfaces: config.networkInterfaces?.map((iface) => ({
...iface, ...iface,
+16
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. */
@@ -139,6 +143,16 @@ export interface IBaseImageBundle {
lastAccessedAt: 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;
}
/** /**
* Firecracker boot source configuration. * Firecracker boot source configuration.
*/ */
@@ -205,6 +219,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;
} }
/** /**