Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69e66cba00 | |||
| 0e6384b3ee | |||
| a61694bd01 | |||
| c868d07d29 |
@@ -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`.
|
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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()`
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user