6 Commits

19 changed files with 2588 additions and 436 deletions
@@ -0,0 +1,22 @@
{
"schemaVersion": 1,
"bundleId": "smartvm-minimal-v1-x86_64",
"name": "SmartVM minimal x86_64 bundle",
"arch": "x86_64",
"firecrackerVersion": "v1.15.1",
"rootfsType": "ext4",
"rootfsIsReadOnly": false,
"bootArgs": "console=ttyS0 reboot=k panic=1 pci=off",
"kernel": {
"url": "https://assets.example.com/push.rocks/smartvm/base-images/smartvm-minimal-v1/x86_64/vmlinux",
"fileName": "vmlinux",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"sizeBytes": 12345678
},
"rootfs": {
"url": "https://assets.example.com/push.rocks/smartvm/base-images/smartvm-minimal-v1/x86_64/rootfs.ext4",
"fileName": "rootfs.ext4",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"sizeBytes": 12345678
}
}
+22
View File
@@ -1,5 +1,27 @@
# 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)
add managed base image bundles with cache retention, hosted manifests, and opt-in integration boot testing
- introduces BaseImageManager with support for Firecracker CI presets and hosted manifest-based kernel/rootfs bundles
- adds SmartVM.ensureBaseImage() and exports new base image types and manager APIs
- validates and verifies downloaded base image artifacts with checksums and bounded cache eviction
- hardens process, socket, network, and config handling with safer spawning, subnet/interface validation, and expanded tests
## 2026-04-30 - 1.1.1 - fix(build) ## 2026-04-30 - 1.1.1 - fix(build)
tighten TypeScript compiler settings and harden error message handling tighten TypeScript compiler settings and harden error message handling
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartvm", "name": "@push.rocks/smartvm",
"version": "1.1.1", "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",
+11
View File
@@ -5,6 +5,11 @@
- Uses `@push.rocks/smartrequest` with URL format `http://unix:<socket>:<path>` for socket communication - Uses `@push.rocks/smartrequest` with URL format `http://unix:<socket>:<path>` for socket communication
- Uses `@push.rocks/smartshell` `execStreaming()` to manage Firecracker child processes - Uses `@push.rocks/smartshell` `execStreaming()` to manage Firecracker child processes
- Uses `@push.rocks/smartexit` for cleanup on process exit - Uses `@push.rocks/smartexit` for cleanup on process exit
- `BaseImageManager` downloads Firecracker CI demo artifacts or hosted project manifests into `/tmp/.smartvm/base-images` by default
- Base image cache keeps 2 bundles by default and warns before evicting older bundles
- Hosted manifest examples live in `assets/base-images/`
- VM runtime files default to `/dev/shm/.smartvm/runtime` when available
- Writable drives are staged into per-VM runtime storage by default and removed during cleanup; use `ephemeral: false` only for explicit persistence
## Key API Patterns ## Key API Patterns
- SmartRequest: `SmartRequest.create().url(...).json(body).put()` — response has `.status`, `.ok`, `.json()` - SmartRequest: `SmartRequest.create().url(...).json(body).put()` — response has `.status`, `.ok`, `.json()`
@@ -16,3 +21,9 @@
- Start: PUT /actions { action_type: "InstanceStart" } - Start: PUT /actions { action_type: "InstanceStart" }
- Pause/Resume: PATCH /vm { state: "Paused" | "Resumed" } - Pause/Resume: PATCH /vm { state: "Paused" | "Resumed" }
- Stop: PUT /actions { action_type: "SendCtrlAltDel" } - Stop: PUT /actions { action_type: "SendCtrlAltDel" }
## Integration Testing
- Default `pnpm test` skips real Firecracker boot testing
- Set `SMARTVM_RUN_INTEGRATION=true` to run the opt-in boot test
- `SMARTVM_BASE_IMAGE_PRESET` supports `latest` and `lts`; default is `latest`
- Hosted/project-owned bundles use `baseImageManifestUrl`, `baseImageManifestPath`, `manifestUrl`, or `manifestPath`
+489 -282
View File
@@ -1,179 +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
Firecracker is a Linux/KVM technology. The package is TypeScript, but the runtime host must provide the VM substrate.
| Requirement | Why it matters |
|---|---|
| Linux with `/dev/kvm` | Firecracker needs KVM acceleration. |
| Firecracker binary | Downloaded by `ensureBinary()` or supplied through `firecrackerBinaryPath`. |
| 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.pause(); // state → 'paused'
await vm.resume(); // state → 'running'
// 7. Stop and clean up
await vm.stop(); await vm.stop();
}
await vm.cleanup(); await vm.cleanup();
await smartvm.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',
bridgeName: 'svbr0', // default: svbr0 arch: 'x86_64',
subnet: '172.30.0.0/24', // default: 172.30.0.0/24 firecrackerBinaryPath: '/usr/bin/firecracker',
}); bridgeName: 'svbr0',
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. |
| `createVM(config)` | Creates a `MicroVM` instance (not yet started). Returns the VM. | | `ensureBaseImage(options)` | Resolves/downloads a base-image bundle and returns kernel/rootfs paths plus boot args. |
| `getVM(id)` | Look up an active VM by ID. | | `createVM(config)` | Creates a `MicroVM` instance. It does not boot until `vm.start()`. |
| `listVMs()` | Returns an array of active VM IDs. | | `getRuntimeDir()` | Returns the active runtime directory used for per-VM tmpfs artifacts. |
| `vmCount` | Number of active VMs. | | `getVM(id)` | Looks up an active VM by ID. |
| `stopAll()` | Stops all running/paused VMs in parallel. | | `listVMs()` | Lists active VM IDs. |
| `cleanup()` | Stops all VMs, removes TAP devices and bridge. | | `vmCount` | Number of tracked VMs. |
| `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,
@@ -184,216 +346,184 @@ const vm = await smartvm.createVM({
version: 'V2', version: 'V2',
networkInterfaces: ['eth0'], networkInterfaces: ['eth0'],
}, },
logger: { });
logPath: '/tmp/firecracker.log',
level: 'Debug', await vm.start();
showLogOrigin: true, await vm.pause();
}, await vm.resume();
metrics: { await vm.stop();
metricsPath: '/tmp/firecracker-metrics.fifo', await vm.cleanup();
}, ```
| API | Valid state | Description |
|---|---|---|
| `start()` | `created` | Stages ephemeral drives, starts Firecracker, applies config, boots the VM. |
| `pause()` | `running` | Pauses execution. |
| `resume()` | `paused` | Resumes execution. |
| `stop()` | `running`, `paused` | Sends Ctrl+Alt+Del, waits briefly, then stops the process. |
| `cleanup()` | any | Stops process, deletes sockets/runtime dir, removes auto-created TAPs. |
| `getInfo()` | after start | Returns Firecracker instance info. |
| `getVersion()` | after start | Returns Firecracker version info. |
| `setMetadata(data)` | `running`, `paused` | Writes MMDS metadata. |
| `getMetadata()` | `running`, `paused` | Reads MMDS metadata. |
| `updateDrive(id, path)` | `running`, `paused` | Hot-updates a drive path. |
| `updateNetworkInterface(id, update)` | `running`, `paused` | Updates network interface config such as rate limiters. |
| `updateBalloon(mib)` | `running`, `paused` | Resizes the balloon device. |
| `createSnapshot(params)` | `paused` | Creates a Firecracker snapshot. |
| `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. |
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.
## 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
const vm = await smartvm.createVM({
bootSource: { kernelImagePath: baseImage.kernelImagePath, bootArgs: baseImage.bootArgs },
machineConfig: { vcpuCount: 1, memSizeMib: 256 },
drives: [{ driveId: 'rootfs', pathOnHost: baseImage.rootfsPath, isRootDevice: true, isReadOnly: baseImage.rootfsIsReadOnly }],
networkInterfaces: [{ ifaceId: 'eth0' }],
}); });
``` ```
| Method | Valid States | Description | Static-kernel-args mode:
|---|---|---|
| `start()` | `created` | Spawns Firecracker, applies config, boots the VM |
| `pause()` | `running` | Pauses VM execution |
| `resume()` | `paused` | Resumes a paused VM |
| `stop()` | `running`, `paused` | Graceful shutdown (Ctrl+Alt+Del), then force kill |
| `cleanup()` | any | Full cleanup: kill process, remove socket, remove TAPs |
| `getInfo()` | any (after start) | Returns Firecracker instance info |
| `getVersion()` | any (after start) | Returns Firecracker version |
| `createSnapshot(params)` | `paused` | Create a VM snapshot |
| `loadSnapshot(params)` | `created`, `configuring` | Load a VM from snapshot |
| `setMetadata(data)` | `running`, `paused` | Set MMDS metadata |
| `getMetadata()` | `running`, `paused` | Get MMDS metadata |
| `updateDrive(id, path)` | `running`, `paused` | Hot-update a drive path |
| `updateBalloon(mib)` | `running`, `paused` | Resize the balloon device |
| `getTapDevices()` | any | Returns TAP devices associated with this VM |
### `ImageManager` — Binary & Image Management ```typescript
const tap = await smartvm.networkManager.createTapDevice('net-vm', 'eth0');
Handles downloading and caching Firecracker binaries, kernels, and rootfs images. const vm = await smartvm.createVM({
id: 'net-vm',
bootSource: {
kernelImagePath: baseImage.kernelImagePath,
bootArgs: `${baseImage.bootArgs} ${smartvm.networkManager.getGuestNetworkBootArgs(tap)}`,
},
machineConfig: { vcpuCount: 1, memSizeMib: 256 },
drives: [{ driveId: 'rootfs', pathOnHost: baseImage.rootfsPath, isRootDevice: true, isReadOnly: baseImage.rootfsIsReadOnly }],
networkInterfaces: [
{
ifaceId: 'eth0',
hostDevName: tap.tapName,
guestMac: tap.mac,
},
],
});
```
Networking behavior:
- Default bridge: `svbr0`
- Default subnet: `172.30.0.0/24`
- Subnet input is normalized to the network address
- Prefix length must be `1-30`
- Gateway uses the first usable address
- 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
## ImageManager
`ImageManager` is the lower-level helper for Firecracker binaries and manually managed kernel/rootfs files.
```typescript ```typescript
const imageManager = smartvm.imageManager; const imageManager = smartvm.imageManager;
// Auto-download the latest Firecracker release await imageManager.ensureDirectories();
const version = await imageManager.getLatestVersion(); // e.g. 'v1.7.0'
const binaryPath = await imageManager.downloadFirecracker(version); const latest = await imageManager.getLatestVersion();
const firecrackerPath = await imageManager.downloadFirecracker(latest);
// Download kernel and rootfs images
const kernelPath = await imageManager.downloadKernel( const kernelPath = await imageManager.downloadKernel(
'https://example.com/vmlinux-5.10', 'https://example.com/vmlinux',
'vmlinux-5.10', 'vmlinux',
); );
const rootfsPath = await imageManager.downloadRootfs( const rootfsPath = await imageManager.downloadRootfs(
'https://example.com/ubuntu-22.04.ext4', 'https://example.com/rootfs.ext4',
'ubuntu-22.04.ext4', 'rootfs.ext4',
); );
// Create a blank rootfs or clone an existing one const blankRootfs = await imageManager.createBlankRootfs('scratch.ext4', 1024);
const blankPath = await imageManager.createBlankRootfs('scratch.ext4', 1024); const clonedRootfs = await imageManager.cloneRootfs(rootfsPath, 'vm-rootfs.ext4');
const clonePath = await imageManager.cloneRootfs(rootfsPath, 'ubuntu-clone.ext4');
``` ```
**Data directory layout:** Useful path helpers:
``` - `getBinDir()`
/tmp/.smartvm/ - `getKernelsDir()`
bin/<version>/firecracker - `getRootfsDir()`
bin/<version>/jailer - `getSocketsDir()`
kernels/<name> - `getFirecrackerPath(version)`
rootfs/<name> - `getJailerPath(version)`
sockets/<vmId>.sock - `getSocketPath(vmId)`
```
### `NetworkManager` — Host Networking Note: `SmartVM.createVM()` uses `runtimeDir/<vmId>/firecracker.sock` for new VM sockets by default. `ImageManager.getSocketPath()` remains available for lower-level/custom flows.
Automatically manages TAP devices, a Linux bridge, and iptables NAT masquerade rules so VMs get internet access out of the box. ## VMConfig
```typescript `VMConfig` validates `IMicroVMConfig` and transforms it into Firecracker API payloads.
const networkManager = smartvm.networkManager;
// Manually create a TAP device (usually handled by MicroVM.start())
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 `.1`
- Each VM gets a TAP device attached to the bridge
- Sequential IP allocation from `.2` onwards
- 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 |
| `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',
@@ -403,44 +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;
}
``` ```
## TypeScript Interfaces | 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. |
All configuration interfaces are fully exported for type-safe usage: ## Testing
Default tests are safe on machines without KVM or root privileges:
```bash
pnpm test
pnpm run build
```
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.
Opt into real Firecracker boot tests on a Linux/KVM host:
```bash
SMARTVM_RUN_INTEGRATION=true pnpm test
```
Accepted truthy values for `SMARTVM_RUN_INTEGRATION`: `1`, `true`, `yes`.
Useful integration-test environment variables:
| Variable | Purpose |
|---|---|
| `SMARTVM_BASE_IMAGE_PRESET` | `latest` or `lts`; default is `latest`. |
| `SMARTVM_BASE_IMAGE_MANIFEST_URL` | Hosted/project-owned base-image manifest URL. |
| `SMARTVM_BASE_IMAGE_MANIFEST_PATH` | Local hosted manifest path. |
| `SMARTVM_BASE_IMAGE_CACHE_DIR` | Override `/tmp/.smartvm/base-images`. |
| `SMARTVM_MAX_STORED_BASE_IMAGES` | Override default retention of `2`. |
| `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 Surface
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,
@@ -451,6 +651,11 @@ import type {
IMmdsConfig, IMmdsConfig,
ILoggerConfig, ILoggerConfig,
IMetricsConfig, IMetricsConfig,
IBaseImageManagerOptions,
IEnsureBaseImageOptions,
IBaseImageBundle,
IBaseImageHostedManifest,
IBaseImageArtifactManifest,
ISnapshotCreateParams, ISnapshotCreateParams,
ISnapshotLoadParams, ISnapshotLoadParams,
IRateLimiter, IRateLimiter,
@@ -463,6 +668,8 @@ import type {
TCacheType, TCacheType,
TSnapshotType, TSnapshotType,
TLogLevel, TLogLevel,
TBaseImagePreset,
TBaseImageRootfsType,
} from '@push.rocks/smartvm'; } from '@push.rocks/smartvm';
``` ```
+91
View File
@@ -0,0 +1,91 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { BaseImageManager, SmartVM } from '../ts/index.js';
import type { TBaseImagePreset, TFirecrackerArch } from '../ts/index.js';
const integrationEnabled = ['1', 'true', 'yes'].includes(
(process.env.SMARTVM_RUN_INTEGRATION || '').toLowerCase(),
);
function getHostArch(): TFirecrackerArch {
return process.arch === 'arm64' ? 'aarch64' : 'x86_64';
}
async function assertHostReady(): Promise<void> {
if (process.platform !== 'linux') {
throw new Error('Firecracker integration tests require Linux');
}
await fs.promises.access('/dev/kvm', fs.constants.R_OK | fs.constants.W_OK);
}
tap.test('SmartVM integration - boots a Firecracker CI base image when explicitly enabled', async () => {
if (!integrationEnabled) {
console.log('Skipping SmartVM integration test. Set SMARTVM_RUN_INTEGRATION=true to enable it.');
return;
}
await assertHostReady();
const arch = (process.env.SMARTVM_ARCH as TFirecrackerArch | undefined) || getHostArch();
const preset = (process.env.SMARTVM_BASE_IMAGE_PRESET as TBaseImagePreset | undefined) || 'latest';
const maxStoredBaseImages = process.env.SMARTVM_MAX_STORED_BASE_IMAGES
? Number(process.env.SMARTVM_MAX_STORED_BASE_IMAGES)
: undefined;
const baseImageManager = new BaseImageManager({
arch,
cacheDir: process.env.SMARTVM_BASE_IMAGE_CACHE_DIR,
maxStoredBaseImages,
hostedManifestUrl: process.env.SMARTVM_BASE_IMAGE_MANIFEST_URL,
hostedManifestPath: process.env.SMARTVM_BASE_IMAGE_MANIFEST_PATH,
});
const baseImage = await baseImageManager.ensureBaseImage({
preset,
manifestUrl: process.env.SMARTVM_BASE_IMAGE_MANIFEST_URL,
manifestPath: process.env.SMARTVM_BASE_IMAGE_MANIFEST_PATH,
});
const smartvm = new SmartVM({
arch,
dataDir: process.env.SMARTVM_INTEGRATION_DATA_DIR || path.join(os.tmpdir(), '.smartvm-integration'),
firecrackerVersion: process.env.SMARTVM_FIRECRACKER_VERSION || baseImage.firecrackerVersion,
baseImageCacheDir: process.env.SMARTVM_BASE_IMAGE_CACHE_DIR,
maxStoredBaseImages,
baseImageManifestUrl: process.env.SMARTVM_BASE_IMAGE_MANIFEST_URL,
baseImageManifestPath: process.env.SMARTVM_BASE_IMAGE_MANIFEST_PATH,
});
const vm = await smartvm.createVM({
id: `smartvm-it-${Date.now()}`,
bootSource: {
kernelImagePath: baseImage.kernelImagePath,
bootArgs: baseImage.bootArgs,
},
machineConfig: {
vcpuCount: 1,
memSizeMib: 256,
},
drives: [
{
driveId: 'rootfs',
pathOnHost: baseImage.rootfsPath,
isRootDevice: true,
isReadOnly: baseImage.rootfsIsReadOnly,
},
],
});
try {
await vm.start();
expect(vm.state).toEqual('running');
expect(await vm.getInfo()).toBeTruthy();
} finally {
if (vm.state === 'running' || vm.state === 'paused') {
await vm.stop();
}
await vm.cleanup();
await smartvm.cleanup();
}
});
export default tap.start();
+548 -1
View File
@@ -1,11 +1,31 @@
import * as fs from 'fs';
import * as crypto from 'crypto';
import * as os from 'os';
import * as path from 'path';
import { tap, expect } from '@git.zone/tstest/tapbundle'; import { tap, expect } from '@git.zone/tstest/tapbundle';
import { import {
BaseImageManager,
VMConfig, VMConfig,
SocketClient, SocketClient,
NetworkManager, NetworkManager,
MicroVM,
SmartVM, SmartVM,
SmartVMError,
} from '../ts/index.js'; } from '../ts/index.js';
import type { IMicroVMConfig } from '../ts/index.js'; import type { IBaseImageBundle, IBaseImageHostedManifest, IMicroVMConfig } from '../ts/index.js';
async function getRejectedError(promise: Promise<unknown>): Promise<unknown> {
try {
await promise;
} catch (err) {
return err;
}
return undefined;
}
function sha256Buffer(buffer: Buffer): string {
return crypto.createHash('sha256').update(buffer).digest('hex');
}
// ============================================================ // ============================================================
// VMConfig Tests // VMConfig Tests
@@ -87,6 +107,33 @@ tap.test('VMConfig - validate() should fail for multiple root drives', async ()
expect(result.valid).toBeFalse(); expect(result.valid).toBeFalse();
}); });
tap.test('VMConfig - validate() should fail for invalid vsock guestCid', async () => {
const vmConfig = new VMConfig({
bootSource: { kernelImagePath: '/vmlinux' },
machineConfig: { vcpuCount: 1, memSizeMib: 128 },
vsock: { guestCid: 2, udsPath: '/tmp/vsock.sock' },
});
const result = vmConfig.validate();
expect(result.valid).toBeFalse();
expect(result.errors).toContain('vsock.guestCid must be >= 3');
});
tap.test('VMConfig - constructor should not retain caller references', async () => {
const config: IMicroVMConfig = {
...sampleConfig,
networkInterfaces: [{ ifaceId: 'eth0', guestMac: '02:00:00:00:00:01' }],
mmds: { version: 'V2', networkInterfaces: ['eth0'] },
};
const vmConfig = new VMConfig(config);
config.networkInterfaces![0].guestMac = '02:00:00:00:00:02';
config.mmds!.networkInterfaces.push('eth1');
expect(vmConfig.toNetworkInterfacePayload(vmConfig.config.networkInterfaces![0]).guest_mac)
.toEqual('02:00:00:00:00:01');
expect(vmConfig.toMmdsConfigPayload()!.network_interfaces).toEqual(['eth0']);
});
tap.test('VMConfig - toBootSourcePayload() should generate correct snake_case', async () => { tap.test('VMConfig - toBootSourcePayload() should generate correct snake_case', async () => {
const vmConfig = new VMConfig(sampleConfig); const vmConfig = new VMConfig(sampleConfig);
const payload = vmConfig.toBootSourcePayload(); const payload = vmConfig.toBootSourcePayload();
@@ -144,6 +191,65 @@ tap.test('VMConfig - toBalloonPayload() should generate correct payload', async
expect(payload!.stats_polling_interval_s).toEqual(5); expect(payload!.stats_polling_interval_s).toEqual(5);
}); });
tap.test('VMConfig - toVsockPayload() should generate correct payload', async () => {
const config: IMicroVMConfig = {
...sampleConfig,
vsock: { guestCid: 3, udsPath: '/tmp/vsock.sock' },
};
const vmConfig = new VMConfig(config);
const payload = vmConfig.toVsockPayload();
expect(payload).not.toBeNull();
expect(payload!.guest_cid).toEqual(3);
expect(payload!.uds_path).toEqual('/tmp/vsock.sock');
});
tap.test('VMConfig - toMmdsConfigPayload() should generate correct payload', async () => {
const config: IMicroVMConfig = {
...sampleConfig,
mmds: { version: 'V2', networkInterfaces: ['eth0'] },
};
const vmConfig = new VMConfig(config);
const payload = vmConfig.toMmdsConfigPayload();
expect(payload).not.toBeNull();
expect(payload!.version).toEqual('V2');
expect(payload!.network_interfaces).toEqual(['eth0']);
});
tap.test('VMConfig - toMetricsPayload() should generate correct payload', async () => {
const config: IMicroVMConfig = {
...sampleConfig,
metrics: { metricsPath: '/tmp/firecracker-metrics.fifo' },
};
const vmConfig = new VMConfig(config);
const payload = vmConfig.toMetricsPayload();
expect(payload).not.toBeNull();
expect(payload!.metrics_path).toEqual('/tmp/firecracker-metrics.fifo');
});
tap.test('VMConfig - toDrivePayload() should include rate limiter payloads', async () => {
const vmConfig = new VMConfig(sampleConfig);
const payload = vmConfig.toDrivePayload({
driveId: 'data',
pathOnHost: '/path/to/data.ext4',
isRootDevice: false,
rateLimiter: {
bandwidth: { size: 1000, refillTime: 2000, oneTimeBurst: 3000 },
ops: { size: 10, refillTime: 20, oneTimeBurst: 30 },
},
});
expect(payload.rate_limiter.bandwidth).toEqual({
size: 1000,
refill_time: 2000,
one_time_burst: 3000,
});
expect(payload.rate_limiter.ops).toEqual({
size: 10,
refill_time: 20,
one_time_burst: 30,
});
});
tap.test('VMConfig - toLoggerPayload() should generate correct payload', async () => { tap.test('VMConfig - toLoggerPayload() should generate correct payload', async () => {
const config: IMicroVMConfig = { const config: IMicroVMConfig = {
...sampleConfig, ...sampleConfig,
@@ -167,6 +273,269 @@ tap.test('SocketClient - URL construction', async () => {
expect(client).toBeTruthy(); expect(client).toBeTruthy();
}); });
// ============================================================
// BaseImageManager Tests
// ============================================================
tap.test('BaseImageManager - instantiation with defaults', async () => {
const manager = new BaseImageManager();
expect(manager.getCacheDir()).toEqual(path.join(os.tmpdir(), '.smartvm', 'base-images'));
expect(manager.getMaxStoredBaseImages()).toEqual(2);
});
tap.test('BaseImageManager - rejects invalid maxStoredBaseImages', async () => {
let error: unknown;
try {
new BaseImageManager({ maxStoredBaseImages: 0 });
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(SmartVMError);
expect((error as SmartVMError).code).toEqual('INVALID_BASE_IMAGE_CACHE_LIMIT');
});
tap.test('BaseImageManager - pruneBaseImageCache() should evict old bundles', async () => {
const cacheDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-base-image-test-'));
const manager = new BaseImageManager({ cacheDir, maxStoredBaseImages: 2 });
const originalWarn = console.warn;
const warnings: string[] = [];
console.warn = (message?: any) => {
warnings.push(String(message));
};
const createManifest = async (bundleId: string, lastAccessedAt: string) => {
const bundleDir = path.join(cacheDir, bundleId);
await fs.promises.mkdir(bundleDir, { recursive: true });
const bundle: IBaseImageBundle = {
preset: 'lts',
arch: 'x86_64',
ciVersion: 'v1.7',
firecrackerVersion: 'v1.7.0',
bundleId,
bundleDir,
kernelImagePath: path.join(bundleDir, 'vmlinux'),
rootfsPath: path.join(bundleDir, 'rootfs.ext4'),
rootfsType: 'ext4',
rootfsIsReadOnly: false,
bootArgs: 'console=ttyS0 reboot=k panic=1 pci=off',
source: {
bucketUrl: 'https://s3.amazonaws.com/spec.ccfc.min',
kernelKey: 'kernel',
rootfsKey: 'rootfs',
},
createdAt: lastAccessedAt,
lastAccessedAt,
};
await fs.promises.writeFile(path.join(bundleDir, 'manifest.json'), `${JSON.stringify(bundle, null, 2)}\n`);
};
try {
await createManifest('old', '2024-01-01T00:00:00.000Z');
await createManifest('middle', '2024-01-02T00:00:00.000Z');
await createManifest('new', '2024-01-03T00:00:00.000Z');
const evicted = await manager.pruneBaseImageCache('new');
expect(evicted).toEqual(['old']);
expect(warnings.length).toEqual(1);
expect(warnings[0]).toInclude('Evicting old');
expect(fs.existsSync(path.join(cacheDir, 'old'))).toBeFalse();
expect(fs.existsSync(path.join(cacheDir, 'middle'))).toBeTrue();
expect(fs.existsSync(path.join(cacheDir, 'new'))).toBeTrue();
} finally {
console.warn = originalWarn;
await fs.promises.rm(cacheDir, { recursive: true, force: true });
}
});
tap.test('BaseImageManager - ensureBaseImage() should copy hosted manifest artifacts', async () => {
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-test-'));
const cacheDir = path.join(workDir, 'cache');
const assetsDir = path.join(workDir, 'assets');
await fs.promises.mkdir(assetsDir, { recursive: true });
const kernelBuffer = Buffer.from('fake-kernel');
const rootfsBuffer = Buffer.from('fake-rootfs');
const kernelPath = path.join(assetsDir, 'vmlinux-test');
const rootfsPath = path.join(assetsDir, 'rootfs-test.ext4');
await fs.promises.writeFile(kernelPath, kernelBuffer);
await fs.promises.writeFile(rootfsPath, rootfsBuffer);
const manifest: IBaseImageHostedManifest = {
schemaVersion: 1,
bundleId: 'smartvm-minimal-test',
name: 'SmartVM minimal test bundle',
arch: 'x86_64',
firecrackerVersion: 'v1.15.1',
rootfsType: 'ext4',
rootfsIsReadOnly: false,
bootArgs: 'console=ttyS0 reboot=k panic=1 pci=off',
kernel: {
path: kernelPath,
fileName: 'vmlinux',
sha256: sha256Buffer(kernelBuffer),
sizeBytes: kernelBuffer.length,
},
rootfs: {
path: rootfsPath,
fileName: 'rootfs.ext4',
sha256: sha256Buffer(rootfsBuffer),
sizeBytes: rootfsBuffer.length,
},
};
const manifestPath = path.join(workDir, 'manifest.json');
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
try {
const manager = new BaseImageManager({ cacheDir });
const bundle = await manager.ensureBaseImage({ manifestPath });
expect(bundle.preset).toEqual('hosted');
expect(bundle.bundleId).toEqual('smartvm-minimal-test');
expect(bundle.firecrackerVersion).toEqual('v1.15.1');
expect(bundle.source.type).toEqual('hosted-manifest');
expect(bundle.source.manifestPath).toEqual(manifestPath);
expect(fs.existsSync(bundle.kernelImagePath)).toBeTrue();
expect(fs.existsSync(bundle.rootfsPath)).toBeTrue();
expect(bundle.checksums!.kernelSha256).toEqual(sha256Buffer(kernelBuffer));
expect(bundle.checksums!.rootfsSha256).toEqual(sha256Buffer(rootfsBuffer));
} finally {
await fs.promises.rm(workDir, { recursive: true, force: true });
}
});
tap.test('BaseImageManager - ensureBaseImage() should redownload corrupted cached artifacts', async () => {
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-cache-test-'));
const cacheDir = path.join(workDir, 'cache');
const assetsDir = path.join(workDir, 'assets');
await fs.promises.mkdir(assetsDir, { recursive: true });
const kernelBuffer = Buffer.from('fresh-kernel');
const rootfsBuffer = Buffer.from('fresh-rootfs');
const kernelPath = path.join(assetsDir, 'vmlinux-test');
const rootfsPath = path.join(assetsDir, 'rootfs-test.ext4');
await fs.promises.writeFile(kernelPath, kernelBuffer);
await fs.promises.writeFile(rootfsPath, rootfsBuffer);
const manifest: IBaseImageHostedManifest = {
schemaVersion: 1,
bundleId: 'smartvm-corruption-test',
arch: 'x86_64',
firecrackerVersion: 'v1.15.1',
rootfsType: 'ext4',
kernel: {
path: kernelPath,
fileName: 'vmlinux',
sha256: sha256Buffer(kernelBuffer),
sizeBytes: kernelBuffer.length,
},
rootfs: {
path: rootfsPath,
fileName: 'rootfs.ext4',
sha256: sha256Buffer(rootfsBuffer),
sizeBytes: rootfsBuffer.length,
},
};
const manifestPath = path.join(workDir, 'manifest.json');
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
try {
const manager = new BaseImageManager({ cacheDir });
const firstBundle = await manager.ensureBaseImage({ manifestPath });
await fs.promises.writeFile(firstBundle.kernelImagePath, 'tampered-kernel');
const secondBundle = await manager.ensureBaseImage({ manifestPath });
expect(await fs.promises.readFile(secondBundle.kernelImagePath, 'utf8')).toEqual('fresh-kernel');
expect(secondBundle.checksums!.kernelSha256).toEqual(sha256Buffer(kernelBuffer));
} finally {
await fs.promises.rm(workDir, { recursive: true, force: true });
}
});
tap.test('BaseImageManager - ensureBaseImage() should reject hosted manifest arch mismatch', async () => {
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-invalid-test-'));
const manifestPath = path.join(workDir, 'manifest.json');
const manifest: IBaseImageHostedManifest = {
schemaVersion: 1,
bundleId: 'smartvm-invalid-arch-test',
arch: 'aarch64',
firecrackerVersion: 'v1.15.1',
rootfsType: 'ext4',
kernel: { path: path.join(workDir, 'vmlinux') },
rootfs: { path: path.join(workDir, 'rootfs.ext4') },
};
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
try {
const manager = new BaseImageManager({ cacheDir: path.join(workDir, 'cache'), arch: 'x86_64' });
const error = await getRejectedError(manager.ensureBaseImage({ manifestPath }));
expect(error).toBeInstanceOf(SmartVMError);
expect((error as SmartVMError).code).toEqual('INVALID_BASE_IMAGE_MANIFEST');
} finally {
await fs.promises.rm(workDir, { recursive: true, force: true });
}
});
tap.test('BaseImageManager - ensureBaseImage() should reject hosted manifest fileName traversal', async () => {
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-filename-test-'));
const manifestPath = path.join(workDir, 'manifest.json');
const manifest: IBaseImageHostedManifest = {
schemaVersion: 1,
bundleId: 'smartvm-invalid-filename-test',
arch: 'x86_64',
firecrackerVersion: 'v1.15.1',
rootfsType: 'ext4',
kernel: { path: path.join(workDir, 'vmlinux'), fileName: '../vmlinux' },
rootfs: { path: path.join(workDir, 'rootfs.ext4'), fileName: 'rootfs.ext4' },
};
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
try {
const manager = new BaseImageManager({ cacheDir: path.join(workDir, 'cache') });
const error = await getRejectedError(manager.ensureBaseImage({ manifestPath }));
expect(error).toBeInstanceOf(SmartVMError);
expect((error as SmartVMError).code).toEqual('INVALID_BASE_IMAGE_MANIFEST');
} finally {
await fs.promises.rm(workDir, { recursive: true, force: true });
}
});
tap.test('BaseImageManager - ensureBaseImage() should reject hosted URL artifacts without sha256', async () => {
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-url-test-'));
const manifestPath = path.join(workDir, 'manifest.json');
const manifest: IBaseImageHostedManifest = {
schemaVersion: 1,
bundleId: 'smartvm-invalid-url-test',
arch: 'x86_64',
firecrackerVersion: 'v1.15.1',
rootfsType: 'ext4',
kernel: { url: 'https://example.com/vmlinux' },
rootfs: { path: path.join(workDir, 'rootfs.ext4') },
};
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
try {
const manager = new BaseImageManager({ cacheDir: path.join(workDir, 'cache') });
const error = await getRejectedError(manager.ensureBaseImage({ manifestPath }));
expect(error).toBeInstanceOf(SmartVMError);
expect((error as SmartVMError).code).toEqual('INVALID_BASE_IMAGE_MANIFEST');
} finally {
await fs.promises.rm(workDir, { recursive: true, force: true });
}
});
tap.test('BaseImageManager - hosted preset should require a manifest', async () => {
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-missing-test-'));
try {
const manager = new BaseImageManager({ cacheDir: path.join(workDir, 'cache') });
const error = await getRejectedError(manager.ensureBaseImage({ preset: 'hosted' }));
expect(error).toBeInstanceOf(SmartVMError);
expect((error as SmartVMError).code).toEqual('BASE_IMAGE_MANIFEST_FAILED');
} finally {
await fs.promises.rm(workDir, { recursive: true, force: true });
}
});
// ============================================================ // ============================================================
// NetworkManager Tests // NetworkManager Tests
// ============================================================ // ============================================================
@@ -181,6 +550,65 @@ tap.test('NetworkManager - allocateIp() should allocate sequential IPs', async (
expect(ip3).toEqual('172.30.0.4'); expect(ip3).toEqual('172.30.0.4');
}); });
tap.test('NetworkManager - allocateIp() should normalize non-network CIDR input', async () => {
const nm = new NetworkManager({ subnet: '10.20.30.17/29' });
expect(nm.allocateIp()).toEqual('10.20.30.18');
expect(nm.allocateIp()).toEqual('10.20.30.19');
});
tap.test('NetworkManager - allocateIp() should fail when subnet is exhausted', async () => {
const nm = new NetworkManager({ subnet: '192.168.100.0/30' });
expect(nm.allocateIp()).toEqual('192.168.100.2');
let error: unknown;
try {
nm.allocateIp();
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(SmartVMError);
expect((error as SmartVMError).code).toEqual('IP_EXHAUSTED');
});
tap.test('NetworkManager - constructor should reject invalid subnet input', async () => {
let error: unknown;
try {
new NetworkManager({ subnet: '10.0.0.0/31' });
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(SmartVMError);
expect((error as SmartVMError).code).toEqual('INVALID_SUBNET');
});
tap.test('NetworkManager - constructor should reject malformed IPv4 octets', async () => {
for (const subnet of ['10..0.1/24', '10.0x10.0.1/24', '10.0.0.1 /24']) {
let error: unknown;
try {
new NetworkManager({ subnet });
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(SmartVMError);
expect((error as SmartVMError).code).toEqual('INVALID_SUBNET');
}
});
tap.test('NetworkManager - constructor should reject invalid bridge names', async () => {
let error: unknown;
try {
new NetworkManager({ bridgeName: 'bad bridge' });
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(SmartVMError);
expect((error as SmartVMError).code).toEqual('INVALID_INTERFACE_NAME');
});
tap.test('NetworkManager - generateMac() should generate locally-administered MACs', async () => { tap.test('NetworkManager - generateMac() should generate locally-administered MACs', async () => {
const nm = new NetworkManager(); const nm = new NetworkManager();
const mac1 = nm.generateMac('vm1', 'eth0'); const mac1 = nm.generateMac('vm1', 'eth0');
@@ -228,6 +656,106 @@ tap.test('NetworkManager - getGuestNetworkBootArgs() should format correctly', a
expect(bootArgs).toEqual('ip=172.30.0.2::172.30.0.1:255.255.255.0::eth0:off'); expect(bootArgs).toEqual('ip=172.30.0.2::172.30.0.1:255.255.255.0::eth0:off');
}); });
// ============================================================
// MicroVM Tests
// ============================================================
tap.test('MicroVM - invalid lifecycle calls should throw SmartVMError', async () => {
const vm = new MicroVM(
'lifecycle-vm',
sampleConfig,
'/bin/false',
'/tmp/smartvm-lifecycle.sock',
new NetworkManager(),
);
const pauseError = await getRejectedError(vm.pause());
expect(pauseError).toBeInstanceOf(SmartVMError);
expect((pauseError as SmartVMError).code).toEqual('INVALID_STATE');
const infoError = await getRejectedError(vm.getInfo());
expect(infoError).toBeInstanceOf(SmartVMError);
expect((infoError as SmartVMError).code).toEqual('NO_CLIENT');
});
tap.test('MicroVM - start() should stage writable drives ephemerally and clean them up on failure', async () => {
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-ephemeral-drive-test-'));
const runtimeDir = path.join(workDir, 'runtime');
const sourceRootfs = path.join(workDir, 'rootfs.ext4');
await fs.promises.writeFile(sourceRootfs, 'persistent-rootfs');
const config: IMicroVMConfig = {
...sampleConfig,
id: 'ephemeral-vm',
drives: [
{
driveId: 'rootfs',
pathOnHost: sourceRootfs,
isRootDevice: true,
isReadOnly: false,
},
],
networkInterfaces: [],
};
const vm = new MicroVM(
'ephemeral-vm',
config,
'/bin/false',
path.join(runtimeDir, 'ephemeral-vm', 'firecracker.sock'),
new NetworkManager(),
{ runtimeDir },
);
try {
const error = await getRejectedError(vm.start());
expect(error).toBeInstanceOf(SmartVMError);
expect(fs.existsSync(path.join(runtimeDir, 'ephemeral-vm'))).toBeFalse();
expect(await fs.promises.readFile(sourceRootfs, 'utf8')).toEqual('persistent-rootfs');
expect(vm.getVMConfig().config.drives![0].pathOnHost).not.toEqual(sourceRootfs);
} finally {
await fs.promises.rm(workDir, { recursive: true, force: true });
}
});
tap.test('MicroVM - start() should honor per-drive ephemeral opt-out', async () => {
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-persistent-drive-test-'));
const runtimeDir = path.join(workDir, 'runtime');
const sourceRootfs = path.join(workDir, 'rootfs.ext4');
await fs.promises.writeFile(sourceRootfs, 'persistent-rootfs');
const config: IMicroVMConfig = {
...sampleConfig,
id: 'persistent-vm',
drives: [
{
driveId: 'rootfs',
pathOnHost: sourceRootfs,
isRootDevice: true,
isReadOnly: false,
ephemeral: false,
},
],
networkInterfaces: [],
};
const vm = new MicroVM(
'persistent-vm',
config,
'/bin/false',
path.join(runtimeDir, 'persistent-vm', 'firecracker.sock'),
new NetworkManager(),
{ runtimeDir },
);
try {
const error = await getRejectedError(vm.start());
expect(error).toBeInstanceOf(SmartVMError);
expect(vm.getVMConfig().config.drives![0].pathOnHost).toEqual(sourceRootfs);
expect(fs.existsSync(path.join(runtimeDir, 'persistent-vm'))).toBeFalse();
} finally {
await fs.promises.rm(workDir, { recursive: true, force: true });
}
});
// ============================================================ // ============================================================
// SmartVM Tests // SmartVM Tests
// ============================================================ // ============================================================
@@ -236,7 +764,11 @@ tap.test('SmartVM - instantiation with defaults', async () => {
const smartvm = new SmartVM(); const smartvm = new SmartVM();
expect(smartvm).toBeTruthy(); expect(smartvm).toBeTruthy();
expect(smartvm.imageManager).toBeTruthy(); expect(smartvm.imageManager).toBeTruthy();
expect(smartvm.baseImageManager).toBeTruthy();
expect(smartvm.networkManager).toBeTruthy(); expect(smartvm.networkManager).toBeTruthy();
if (fs.existsSync('/dev/shm')) {
expect(smartvm.getRuntimeDir()).toEqual('/dev/shm/.smartvm/runtime');
}
expect(smartvm.vmCount).toEqual(0); expect(smartvm.vmCount).toEqual(0);
expect(smartvm.listVMs()).toHaveLength(0); expect(smartvm.listVMs()).toHaveLength(0);
}); });
@@ -251,4 +783,19 @@ tap.test('SmartVM - instantiation with custom options', async () => {
expect(smartvm).toBeTruthy(); expect(smartvm).toBeTruthy();
}); });
tap.test('SmartVM - createVM() should track created VMs', async () => {
const smartvm = new SmartVM({
dataDir: '/tmp/smartvm-test',
firecrackerBinaryPath: '/bin/false',
});
const vm = await smartvm.createVM(sampleConfig);
expect(smartvm.vmCount).toEqual(1);
expect(smartvm.getVM(vm.id)).toEqual(vm);
expect(smartvm.listVMs()).toEqual([vm.id]);
await smartvm.cleanup();
expect(smartvm.vmCount).toEqual(0);
});
export default tap.start(); export default tap.start();
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartvm', name: '@push.rocks/smartvm',
version: '1.1.1', 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'
} }
+713
View File
@@ -0,0 +1,713 @@
import * as plugins from './plugins.js';
import type {
IBaseImageArtifactManifest,
IBaseImageBundle,
IBaseImageHostedManifest,
IBaseImageManagerOptions,
IEnsureBaseImageOptions,
TBaseImagePreset,
TBaseImageRootfsType,
TFirecrackerArch,
} from './interfaces/index.js';
import { SmartVMError } from './interfaces/index.js';
const FIRECRACKER_CI_BUCKET_URL = 'https://s3.amazonaws.com/spec.ccfc.min';
const DEFAULT_MAX_STORED_BASE_IMAGES = 2;
const LTS_CI_VERSION = 'v1.7';
const LTS_FIRECRACKER_VERSION = 'v1.7.0';
type TShellExecResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawn']>>;
interface IResolvedBaseImageSource {
preset: TBaseImagePreset;
arch: TFirecrackerArch;
ciVersion: string;
firecrackerVersion: string;
kernelKey?: string;
rootfsKey?: string;
kernelUrl?: string;
rootfsUrl?: string;
kernelSourcePath?: string;
rootfsSourcePath?: string;
kernelFileName?: string;
rootfsFileName?: string;
expectedKernelSha256?: string;
expectedRootfsSha256?: string;
expectedKernelBytes?: number;
expectedRootfsBytes?: number;
rootfsType: TBaseImageRootfsType;
rootfsIsReadOnly: boolean;
bundleId: string;
bootArgs: string;
source: IBaseImageBundle['source'];
}
function getErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/**
* Downloads and retains Firecracker CI base images for integration testing.
*/
export class BaseImageManager {
private arch: TFirecrackerArch;
private cacheDir: string;
private maxStoredBaseImages: number;
private hostedManifestUrl?: string;
private hostedManifestPath?: string;
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
constructor(options: IBaseImageManagerOptions = {}) {
this.arch = options.arch || 'x86_64';
this.cacheDir = options.cacheDir || plugins.path.join(plugins.os.tmpdir(), '.smartvm', 'base-images');
this.maxStoredBaseImages = options.maxStoredBaseImages ?? DEFAULT_MAX_STORED_BASE_IMAGES;
this.hostedManifestUrl = options.hostedManifestUrl;
this.hostedManifestPath = options.hostedManifestPath;
if (!Number.isInteger(this.maxStoredBaseImages) || this.maxStoredBaseImages < 1) {
throw new SmartVMError(
'maxStoredBaseImages must be a positive integer',
'INVALID_BASE_IMAGE_CACHE_LIMIT',
);
}
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
}
public getCacheDir(): string {
return this.cacheDir;
}
public getMaxStoredBaseImages(): number {
return this.maxStoredBaseImages;
}
/**
* Ensure a base image bundle exists locally and return its paths.
*/
public async ensureBaseImage(options: IEnsureBaseImageOptions = {}): Promise<IBaseImageBundle> {
const source = await this.resolveBaseImageSource(options);
const bundleDir = plugins.path.join(this.cacheDir, source.bundleId);
const manifestPath = this.getManifestPath(bundleDir);
const cachedBundle = options.forceDownload ? undefined : await this.readCompleteBundle(bundleDir);
if (cachedBundle) {
const updatedBundle = {
...cachedBundle,
lastAccessedAt: new Date().toISOString(),
};
await this.writeBundleManifest(updatedBundle);
await this.pruneBaseImageCache(updatedBundle.bundleId);
return updatedBundle;
}
await plugins.fs.promises.mkdir(bundleDir, { recursive: true });
const kernelFileName = source.kernelFileName || this.getSourceFileName(source.kernelUrl || source.kernelSourcePath || source.kernelKey!, 'vmlinux');
const rootfsFileName = source.rootfsFileName || this.getSourceFileName(source.rootfsUrl || source.rootfsSourcePath || source.rootfsKey!, `rootfs.${source.rootfsType}`);
const kernelPath = this.resolveBundleFilePath(bundleDir, kernelFileName);
const rootfsPath = this.resolveBundleFilePath(bundleDir, rootfsFileName);
try {
await this.prepareArtifact({
url: source.kernelUrl || (source.kernelKey ? this.keyToUrl(source.kernelKey) : undefined),
sourcePath: source.kernelSourcePath,
targetPath: kernelPath,
expectedSha256: source.expectedKernelSha256,
expectedBytes: source.expectedKernelBytes,
});
await this.prepareArtifact({
url: source.rootfsUrl || (source.rootfsKey ? this.keyToUrl(source.rootfsKey) : undefined),
sourcePath: source.rootfsSourcePath,
targetPath: rootfsPath,
expectedSha256: source.expectedRootfsSha256,
expectedBytes: source.expectedRootfsBytes,
});
const now = new Date().toISOString();
const bundle: IBaseImageBundle = {
preset: source.preset,
arch: source.arch,
ciVersion: source.ciVersion,
firecrackerVersion: source.firecrackerVersion,
bundleId: source.bundleId,
bundleDir,
kernelImagePath: kernelPath,
rootfsPath,
rootfsType: source.rootfsType,
rootfsIsReadOnly: source.rootfsIsReadOnly,
bootArgs: source.bootArgs,
source: source.source,
checksums: {
kernelSha256: await this.sha256File(kernelPath),
rootfsSha256: await this.sha256File(rootfsPath),
},
sizes: {
kernelBytes: (await plugins.fs.promises.stat(kernelPath)).size,
rootfsBytes: (await plugins.fs.promises.stat(rootfsPath)).size,
},
createdAt: now,
lastAccessedAt: now,
};
await this.writeBundleManifest(bundle);
await this.pruneBaseImageCache(bundle.bundleId);
return bundle;
} catch (err) {
await plugins.fs.promises.rm(bundleDir, { recursive: true, force: true });
throw new SmartVMError(
`Failed to prepare base image bundle ${source.bundleId}: ${getErrorMessage(err)}`,
'BASE_IMAGE_PREPARE_FAILED',
);
}
}
/**
* Prune cached base image bundles according to the retention limit.
*/
public async pruneBaseImageCache(keepBundleId?: string): Promise<string[]> {
await plugins.fs.promises.mkdir(this.cacheDir, { recursive: true });
const bundles = await this.listCachedBundles();
bundles.sort((a, b) => {
if (keepBundleId) {
if (a.bundleId === keepBundleId) return -1;
if (b.bundleId === keepBundleId) return 1;
}
return Date.parse(b.lastAccessedAt) - Date.parse(a.lastAccessedAt);
});
const evicted: string[] = [];
for (const bundle of bundles.slice(this.maxStoredBaseImages)) {
console.warn(
`[smartvm] Base image cache stores at most ${this.maxStoredBaseImages} bundle(s). ` +
`Evicting ${bundle.bundleId} from ${bundle.bundleDir}. Configure maxStoredBaseImages to change this behavior.`,
);
await plugins.fs.promises.rm(bundle.bundleDir, { recursive: true, force: true });
evicted.push(bundle.bundleId);
}
return evicted;
}
private async resolveBaseImageSource(options: IEnsureBaseImageOptions): Promise<IResolvedBaseImageSource> {
const arch = options.arch || this.arch;
const manifestUrl = options.manifestUrl || this.hostedManifestUrl;
const manifestPath = options.manifestPath || this.hostedManifestPath;
if (manifestUrl || manifestPath) {
return this.resolveHostedManifestSource({ arch, manifestUrl, manifestPath });
}
const preset = options.preset || 'latest';
if (preset === 'hosted') {
throw new SmartVMError(
'The hosted base image preset requires manifestUrl, manifestPath, or a manager-level hosted manifest option',
'BASE_IMAGE_MANIFEST_FAILED',
);
}
const firecrackerVersion = preset === 'latest'
? await this.getLatestFirecrackerVersion()
: LTS_FIRECRACKER_VERSION;
const ciVersion = preset === 'latest'
? firecrackerVersion.split('.').slice(0, 2).join('.')
: LTS_CI_VERSION;
const keys = await this.listCiKeys(ciVersion, arch);
const kernelKey = this.selectKernelKey(keys);
const rootfsKey = this.selectRootfsKey(keys);
const rootfsType = rootfsKey.endsWith('.ext4') ? 'ext4' : 'squashfs';
const bundleId = this.buildBundleId(preset, ciVersion, arch, kernelKey, rootfsKey);
return {
preset,
arch,
ciVersion,
firecrackerVersion,
kernelKey,
rootfsKey,
rootfsType,
rootfsIsReadOnly: rootfsType === 'squashfs',
bundleId,
bootArgs: this.buildBootArgs(arch, rootfsType),
source: {
type: 'firecracker-ci',
bucketUrl: FIRECRACKER_CI_BUCKET_URL,
kernelKey,
rootfsKey,
},
};
}
private async resolveHostedManifestSource(options: {
arch: TFirecrackerArch;
manifestUrl?: string;
manifestPath?: string;
}): Promise<IResolvedBaseImageSource> {
const manifest = await this.loadHostedManifest(options);
this.validateHostedManifest(manifest, options.arch);
this.getArtifactSource(manifest.kernel, 'kernel');
this.getArtifactSource(manifest.rootfs, 'rootfs');
return {
preset: 'hosted',
arch: manifest.arch,
ciVersion: 'hosted',
firecrackerVersion: manifest.firecrackerVersion,
kernelUrl: manifest.kernel.url,
rootfsUrl: manifest.rootfs.url,
kernelSourcePath: manifest.kernel.path,
rootfsSourcePath: manifest.rootfs.path,
kernelFileName: manifest.kernel.fileName,
rootfsFileName: manifest.rootfs.fileName,
expectedKernelSha256: manifest.kernel.sha256,
expectedRootfsSha256: manifest.rootfs.sha256,
expectedKernelBytes: manifest.kernel.sizeBytes,
expectedRootfsBytes: manifest.rootfs.sizeBytes,
rootfsType: manifest.rootfsType,
rootfsIsReadOnly: manifest.rootfsIsReadOnly ?? manifest.rootfsType === 'squashfs',
bundleId: this.sanitizeBundleId(manifest.bundleId),
bootArgs: manifest.bootArgs || this.buildBootArgs(manifest.arch, manifest.rootfsType),
source: {
type: 'hosted-manifest',
manifestUrl: options.manifestUrl,
manifestPath: options.manifestPath,
kernelUrl: manifest.kernel.url,
rootfsUrl: manifest.rootfs.url,
kernelSourcePath: manifest.kernel.path,
rootfsSourcePath: manifest.rootfs.path,
},
};
}
private async getLatestFirecrackerVersion(): Promise<string> {
try {
const result = await this.shell.execSpawn('curl', [
'-fsSLI',
'-o',
'/dev/null',
'-w',
'%{url_effective}',
'https://github.com/firecracker-microvm/firecracker/releases/latest',
], { silent: true });
if (result.exitCode !== 0) {
const output = (result.stderr || result.stdout || '').trim();
throw new Error(`curl exited with code ${result.exitCode}${output ? `: ${output}` : ''}`);
}
const match = result.stdout.trim().match(/\/releases\/tag\/(v\d+\.\d+\.\d+)$/);
if (!match) {
throw new Error(`Could not parse latest Firecracker release URL: ${result.stdout.trim()}`);
}
return match[1];
} catch (err) {
throw new SmartVMError(
`Failed to resolve latest Firecracker version: ${getErrorMessage(err)}`,
'VERSION_FETCH_FAILED',
);
}
}
private async loadHostedManifest(options: {
manifestUrl?: string;
manifestPath?: string;
}): Promise<IBaseImageHostedManifest> {
try {
let raw: string;
if (options.manifestPath) {
raw = await plugins.fs.promises.readFile(options.manifestPath, 'utf8');
} else if (options.manifestUrl) {
const response = await plugins.SmartRequest.create()
.url(options.manifestUrl)
.get();
raw = await response.text();
} else {
throw new Error('manifestUrl or manifestPath is required');
}
return JSON.parse(raw) as IBaseImageHostedManifest;
} catch (err) {
throw new SmartVMError(
`Failed to load hosted base image manifest: ${getErrorMessage(err)}`,
'BASE_IMAGE_MANIFEST_FAILED',
);
}
}
private validateHostedManifest(manifest: IBaseImageHostedManifest, expectedArch: TFirecrackerArch): void {
if (manifest.schemaVersion !== 1) {
throw new SmartVMError(
'Hosted base image manifest schemaVersion must be 1',
'INVALID_BASE_IMAGE_MANIFEST',
);
}
if (!manifest.bundleId || !/^[a-zA-Z0-9._-]+$/.test(manifest.bundleId)) {
throw new SmartVMError(
'Hosted base image manifest bundleId must use only letters, numbers, dot, underscore, and dash',
'INVALID_BASE_IMAGE_MANIFEST',
);
}
if (manifest.arch !== expectedArch) {
throw new SmartVMError(
`Hosted base image arch '${manifest.arch}' does not match requested arch '${expectedArch}'`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
if (!manifest.firecrackerVersion || !/^v\d+\.\d+\.\d+$/.test(manifest.firecrackerVersion)) {
throw new SmartVMError(
'Hosted base image manifest firecrackerVersion must look like v1.15.1',
'INVALID_BASE_IMAGE_MANIFEST',
);
}
if (manifest.rootfsType !== 'ext4' && manifest.rootfsType !== 'squashfs') {
throw new SmartVMError(
'Hosted base image manifest rootfsType must be ext4 or squashfs',
'INVALID_BASE_IMAGE_MANIFEST',
);
}
this.validateArtifactManifest(manifest.kernel, 'kernel');
this.validateArtifactManifest(manifest.rootfs, 'rootfs');
}
private validateArtifactManifest(artifact: IBaseImageArtifactManifest, label: string): void {
this.getArtifactSource(artifact, label);
if (artifact.fileName !== undefined) {
this.validateArtifactFileName(artifact.fileName, label);
}
if (artifact.url && !artifact.sha256) {
throw new SmartVMError(
`Hosted base image manifest ${label} artifact with url requires sha256`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
if (artifact.sha256 !== undefined && !/^[a-fA-F0-9]{64}$/.test(artifact.sha256)) {
throw new SmartVMError(
`Hosted base image manifest ${label} artifact sha256 must be a 64 character hex string`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
if (
artifact.sizeBytes !== undefined &&
(!Number.isInteger(artifact.sizeBytes) || artifact.sizeBytes < 0)
) {
throw new SmartVMError(
`Hosted base image manifest ${label} artifact sizeBytes must be a non-negative integer`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
}
private validateArtifactFileName(fileName: string, label: string): void {
if (
!fileName ||
fileName === '.' ||
fileName === '..' ||
fileName !== plugins.path.basename(fileName) ||
!/^[a-zA-Z0-9._-]+$/.test(fileName)
) {
throw new SmartVMError(
`Hosted base image manifest ${label} artifact fileName must be a plain file name using letters, numbers, dot, underscore, and dash`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
}
private getArtifactSource(artifact: { url?: string; path?: string }, label: string): string {
if (!artifact.url && !artifact.path) {
throw new SmartVMError(
`Hosted base image manifest ${label} artifact requires url or path`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
if (artifact.url && artifact.path) {
throw new SmartVMError(
`Hosted base image manifest ${label} artifact must not set both url and path`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
return artifact.url || artifact.path!;
}
private getSourceFileName(source: string, fallback: string): string {
let fileName: string;
try {
fileName = plugins.path.basename(new URL(source).pathname);
} catch {
fileName = plugins.path.basename(source);
}
return this.sanitizeFileName(fileName || fallback);
}
private resolveBundleFilePath(bundleDir: string, fileName: string): string {
const resolvedBundleDir = plugins.path.resolve(bundleDir);
const resolvedFilePath = plugins.path.resolve(resolvedBundleDir, this.sanitizeFileName(fileName));
if (!this.isPathInside(resolvedBundleDir, resolvedFilePath)) {
throw new SmartVMError(
`Resolved base image artifact path escapes bundle directory: ${fileName}`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
return resolvedFilePath;
}
private sanitizeFileName(fileName: string): string {
const sanitized = plugins.path.basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '_');
if (!sanitized || sanitized === '.' || sanitized === '..') {
return 'artifact';
}
return sanitized;
}
private sanitizeBundleId(bundleId: string): string {
return bundleId.replace(/[^a-zA-Z0-9._-]/g, '_');
}
private async listCiKeys(ciVersion: string, arch: TFirecrackerArch): Promise<string[]> {
const prefix = `firecracker-ci/${ciVersion}/${arch}/`;
try {
const response = await plugins.SmartRequest.create()
.url(`${FIRECRACKER_CI_BUCKET_URL}/?prefix=${encodeURIComponent(prefix)}&list-type=2`)
.get();
const body = await response.text();
const keys = Array.from(body.matchAll(/<Key>([^<]+)<\/Key>/g)).map((match) => this.decodeXml(match[1]));
if (keys.length === 0) {
throw new Error(`No Firecracker CI artifacts found for ${ciVersion}/${arch}`);
}
return keys;
} catch (err) {
throw new SmartVMError(
`Failed to list Firecracker CI artifacts for ${ciVersion}/${arch}: ${getErrorMessage(err)}`,
'BASE_IMAGE_RESOLVE_FAILED',
);
}
}
private selectKernelKey(keys: string[]): string {
const kernelKeys = keys.filter((key) => /\/vmlinux-\d+\.\d+\.\d+$/.test(key) && !key.includes('/debug/'));
if (kernelKeys.length === 0) {
throw new SmartVMError('No suitable Firecracker CI kernel image found', 'BASE_IMAGE_RESOLVE_FAILED');
}
return kernelKeys.sort((a, b) => this.compareKernelKeys(a, b)).at(-1)!;
}
private selectRootfsKey(keys: string[]): string {
const squashfsKeys = keys.filter((key) => /\/ubuntu-[^/]+\.squashfs$/.test(key));
if (squashfsKeys.length > 0) {
return squashfsKeys.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1)!;
}
const ext4Keys = keys.filter((key) => /\/ubuntu-[^/]+\.ext4$/.test(key));
if (ext4Keys.length > 0) {
return ext4Keys.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1)!;
}
throw new SmartVMError('No suitable Firecracker CI rootfs image found', 'BASE_IMAGE_RESOLVE_FAILED');
}
private compareKernelKeys(a: string, b: string): number {
const aParts = this.extractKernelVersion(a);
const bParts = this.extractKernelVersion(b);
for (let i = 0; i < 3; i++) {
if (aParts[i] !== bParts[i]) {
return aParts[i] - bParts[i];
}
}
return a.localeCompare(b);
}
private extractKernelVersion(key: string): [number, number, number] {
const match = key.match(/vmlinux-(\d+)\.(\d+)\.(\d+)$/);
if (!match) {
return [0, 0, 0];
}
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
private buildBundleId(
preset: TBaseImagePreset,
ciVersion: string,
arch: TFirecrackerArch,
kernelKey: string,
rootfsKey: string,
): string {
const rawId = [
preset,
ciVersion,
arch,
plugins.path.basename(kernelKey),
plugins.path.basename(rootfsKey),
].join('-');
return this.sanitizeBundleId(rawId);
}
private buildBootArgs(arch: TFirecrackerArch, rootfsType: TBaseImageRootfsType): string {
const args = ['console=ttyS0', 'reboot=k', 'panic=1', 'pci=off'];
if (arch === 'aarch64') {
args.unshift('keep_bootcon');
}
if (rootfsType === 'squashfs') {
args.push('ro', 'rootfstype=squashfs');
}
return args.join(' ');
}
private keyToUrl(key: string): string {
return `${FIRECRACKER_CI_BUCKET_URL}/${key}`;
}
private async prepareArtifact(options: {
url?: string;
sourcePath?: string;
targetPath: string;
expectedSha256?: string;
expectedBytes?: number;
}): Promise<void> {
if (options.sourcePath) {
await plugins.fs.promises.copyFile(options.sourcePath, options.targetPath);
} else if (options.url) {
await this.downloadFile(options.url, options.targetPath);
} else {
throw new Error('Artifact requires url or sourcePath');
}
const stat = await plugins.fs.promises.stat(options.targetPath);
if (options.expectedBytes !== undefined && stat.size !== options.expectedBytes) {
throw new Error(
`Artifact ${options.targetPath} size mismatch: expected ${options.expectedBytes}, got ${stat.size}`,
);
}
if (options.expectedSha256) {
const actualSha256 = await this.sha256File(options.targetPath);
if (actualSha256.toLowerCase() !== options.expectedSha256.toLowerCase()) {
throw new Error(
`Artifact ${options.targetPath} SHA256 mismatch: expected ${options.expectedSha256}, got ${actualSha256}`,
);
}
}
}
private async downloadFile(url: string, targetPath: string): Promise<TShellExecResult> {
await plugins.fs.promises.mkdir(plugins.path.dirname(targetPath), { recursive: true });
const tempPath = `${targetPath}.download`;
await plugins.fs.promises.rm(tempPath, { force: true });
const result = await this.shell.execSpawn('curl', ['-fSL', '-o', tempPath, url], { silent: true });
if (result.exitCode !== 0) {
const output = (result.stderr || result.stdout || '').trim();
throw new Error(`curl failed for ${url} with code ${result.exitCode}${output ? `: ${output}` : ''}`);
}
await plugins.fs.promises.rename(tempPath, targetPath);
return result;
}
private async sha256File(filePath: string): Promise<string> {
const hash = plugins.crypto.createHash('sha256');
const stream = plugins.fs.createReadStream(filePath);
for await (const chunk of stream) {
hash.update(chunk);
}
return hash.digest('hex');
}
private async readCompleteBundle(bundleDir: string): Promise<IBaseImageBundle | undefined> {
const manifestPath = this.getManifestPath(bundleDir);
try {
const bundle = {
...await this.readBundleManifest(manifestPath),
bundleDir,
};
await this.verifyCachedBundle(bundle);
return bundle;
} catch {
return undefined;
}
}
private async verifyCachedBundle(bundle: IBaseImageBundle): Promise<void> {
if (!this.isPathInside(bundle.bundleDir, bundle.kernelImagePath)) {
throw new Error(`Cached kernel path escapes bundle directory: ${bundle.kernelImagePath}`);
}
if (!this.isPathInside(bundle.bundleDir, bundle.rootfsPath)) {
throw new Error(`Cached rootfs path escapes bundle directory: ${bundle.rootfsPath}`);
}
if (!bundle.checksums?.kernelSha256 || !bundle.checksums?.rootfsSha256) {
throw new Error(`Cached bundle ${bundle.bundleId} is missing checksums`);
}
if (bundle.sizes?.kernelBytes === undefined || bundle.sizes.rootfsBytes === undefined) {
throw new Error(`Cached bundle ${bundle.bundleId} is missing sizes`);
}
const [kernelStat, rootfsStat] = await Promise.all([
plugins.fs.promises.stat(bundle.kernelImagePath),
plugins.fs.promises.stat(bundle.rootfsPath),
]);
if (kernelStat.size !== bundle.sizes.kernelBytes) {
throw new Error(`Cached kernel size mismatch for bundle ${bundle.bundleId}`);
}
if (rootfsStat.size !== bundle.sizes.rootfsBytes) {
throw new Error(`Cached rootfs size mismatch for bundle ${bundle.bundleId}`);
}
const [kernelSha256, rootfsSha256] = await Promise.all([
this.sha256File(bundle.kernelImagePath),
this.sha256File(bundle.rootfsPath),
]);
if (kernelSha256.toLowerCase() !== bundle.checksums.kernelSha256.toLowerCase()) {
throw new Error(`Cached kernel SHA256 mismatch for bundle ${bundle.bundleId}`);
}
if (rootfsSha256.toLowerCase() !== bundle.checksums.rootfsSha256.toLowerCase()) {
throw new Error(`Cached rootfs SHA256 mismatch for bundle ${bundle.bundleId}`);
}
}
private isPathInside(baseDir: string, candidatePath: string): boolean {
const resolvedBase = plugins.path.resolve(baseDir);
const resolvedCandidate = plugins.path.resolve(candidatePath);
return resolvedCandidate === resolvedBase || resolvedCandidate.startsWith(`${resolvedBase}${plugins.path.sep}`);
}
private getManifestPath(bundleDir: string): string {
return plugins.path.join(bundleDir, 'manifest.json');
}
private async readBundleManifest(manifestPath: string): Promise<IBaseImageBundle> {
const raw = await plugins.fs.promises.readFile(manifestPath, 'utf8');
return JSON.parse(raw) as IBaseImageBundle;
}
private async writeBundleManifest(bundle: IBaseImageBundle): Promise<void> {
await plugins.fs.promises.mkdir(bundle.bundleDir, { recursive: true });
await plugins.fs.promises.writeFile(
this.getManifestPath(bundle.bundleDir),
`${JSON.stringify(bundle, null, 2)}\n`,
);
}
private async listCachedBundles(): Promise<IBaseImageBundle[]> {
let entries: string[];
try {
entries = await plugins.fs.promises.readdir(this.cacheDir);
} catch {
return [];
}
const bundles: IBaseImageBundle[] = [];
for (const entry of entries) {
const bundleDir = plugins.path.join(this.cacheDir, entry);
try {
const stat = await plugins.fs.promises.stat(bundleDir);
if (!stat.isDirectory()) {
continue;
}
const bundle = await this.readBundleManifest(this.getManifestPath(bundleDir));
bundles.push({
...bundle,
bundleDir,
});
} catch {
// Ignore incomplete cache entries.
}
}
return bundles;
}
private decodeXml(value: string): string {
return value
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
}
+70 -17
View File
@@ -3,14 +3,23 @@ import type { IFirecrackerProcessOptions } from './interfaces/index.js';
import { SmartVMError } from './interfaces/index.js'; import { SmartVMError } from './interfaces/index.js';
import { SocketClient } from './classes.socketclient.js'; import { SocketClient } from './classes.socketclient.js';
type TStreamingResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawnStreaming']>>;
type TExecResult = Awaited<TStreamingResult['finalPromise']>;
function getErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/** /**
* Manages a single Firecracker child process, including startup, readiness polling, and shutdown. * Manages a single Firecracker child process, including startup, readiness polling, and shutdown.
*/ */
export class FirecrackerProcess { export class FirecrackerProcess {
private options: IFirecrackerProcessOptions; private options: IFirecrackerProcessOptions;
private streaming: any | null = null; private streaming: TStreamingResult | null = null;
private shell: InstanceType<typeof plugins.smartshell.Smartshell>; private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
private smartExitInstance: InstanceType<typeof plugins.smartexit.SmartExit> | null = null; private smartExitInstance: InstanceType<typeof plugins.smartexit.SmartExit> | null = null;
private lastExitResult: TExecResult | null = null;
private lastExitError: string | null = null;
public socketClient: SocketClient; public socketClient: SocketClient;
constructor(options: IFirecrackerProcessOptions) { constructor(options: IFirecrackerProcessOptions) {
@@ -28,14 +37,21 @@ export class FirecrackerProcess {
plugins.fs.unlinkSync(this.options.socketPath); plugins.fs.unlinkSync(this.options.socketPath);
} }
// Build the command // Build the command args without a shell so paths are not interpreted.
let cmd = `${this.options.binaryPath} --api-sock ${this.options.socketPath}`; const args = ['--api-sock', this.options.socketPath];
if (this.options.logLevel) { if (this.options.logLevel) {
cmd += ` --level ${this.options.logLevel}`; args.push('--level', this.options.logLevel);
} }
// Spawn the process // Spawn the process
this.streaming = await this.shell.execStreaming(cmd, true); this.streaming = await this.shell.execSpawnStreaming(this.options.binaryPath, args, { silent: true });
this.streaming.finalPromise
.then((result) => {
this.lastExitResult = result;
})
.catch((err) => {
this.lastExitError = getErrorMessage(err);
});
// Register with smartexit for automatic cleanup // Register with smartexit for automatic cleanup
if (this.streaming?.childProcess) { if (this.streaming?.childProcess) {
@@ -46,9 +62,11 @@ export class FirecrackerProcess {
// Wait for the socket file to appear // Wait for the socket file to appear
const socketReady = await this.waitForSocket(10000); const socketReady = await this.waitForSocket(10000);
if (!socketReady) { if (!socketReady) {
const wasRunning = this.isRunning();
const diagnostics = this.formatDiagnostics();
await this.stop(); await this.stop();
throw new SmartVMError( throw new SmartVMError(
'Firecracker socket did not become ready within timeout', `Firecracker socket did not become ready within timeout${diagnostics || (wasRunning ? '' : this.formatDiagnostics())}`,
'SOCKET_TIMEOUT', 'SOCKET_TIMEOUT',
); );
} }
@@ -56,9 +74,10 @@ export class FirecrackerProcess {
// Wait for the API to be responsive // Wait for the API to be responsive
const apiReady = await this.socketClient.isReady(5000); const apiReady = await this.socketClient.isReady(5000);
if (!apiReady) { if (!apiReady) {
const diagnostics = this.formatDiagnostics();
await this.stop(); await this.stop();
throw new SmartVMError( throw new SmartVMError(
'Firecracker API did not become responsive within timeout', `Firecracker API did not become responsive within timeout${diagnostics}`,
'API_TIMEOUT', 'API_TIMEOUT',
); );
} }
@@ -73,36 +92,69 @@ export class FirecrackerProcess {
if (plugins.fs.existsSync(this.options.socketPath)) { if (plugins.fs.existsSync(this.options.socketPath)) {
return true; return true;
} }
if (this.streaming && !this.isRunning()) {
return false;
}
await plugins.smartdelay.delayFor(100); await plugins.smartdelay.delayFor(100);
} }
return false; return false;
} }
private async waitForExit(streaming: TStreamingResult, timeoutMs: number): Promise<boolean> {
return Promise.race([
streaming.finalPromise.then((result) => {
this.lastExitResult = result;
return true;
}).catch((err) => {
this.lastExitError = getErrorMessage(err);
return true;
}),
plugins.smartdelay.delayFor(timeoutMs).then(() => false),
]);
}
private formatDiagnostics(): string {
if (this.lastExitError) {
return `: ${this.lastExitError}`;
}
if (this.lastExitResult) {
const output = (this.lastExitResult.stderr || this.lastExitResult.stdout || '').trim();
return `: process exited with code ${this.lastExitResult.exitCode}${output ? `: ${output}` : ''}`;
}
return '';
}
/** /**
* Gracefully stop the Firecracker process with SIGTERM, then SIGKILL after timeout. * Gracefully stop the Firecracker process with SIGTERM, then SIGKILL after timeout.
*/ */
public async stop(): Promise<void> { public async stop(): Promise<void> {
if (!this.streaming) return; const streaming = this.streaming;
if (!streaming) return;
try { try {
// Try graceful termination first // Try graceful termination first
await this.streaming.terminate(); await streaming.terminate();
// Wait up to 5 seconds for the process to exit // Wait up to 5 seconds for the process to exit
const exitPromise = Promise.race([ const terminated = await this.waitForExit(streaming, 5000);
this.streaming.finalPromise, if (!terminated) {
plugins.smartdelay.delayFor(5000), await streaming.kill();
]); await this.waitForExit(streaming, 1000);
await exitPromise; }
} catch { } catch {
// If termination fails, force kill // If termination fails, force kill
try { try {
await this.streaming.kill(); await streaming.kill();
await this.waitForExit(streaming, 1000);
} catch { } catch {
// Process may already be dead // Process may already be dead
} }
} }
if (this.smartExitInstance) {
this.smartExitInstance.removeProcess(streaming.childProcess);
this.smartExitInstance = null;
}
this.streaming = null; this.streaming = null;
} }
@@ -122,10 +174,11 @@ export class FirecrackerProcess {
* Check if the process is currently running. * Check if the process is currently running.
*/ */
public isRunning(): boolean { public isRunning(): boolean {
if (!this.streaming?.childProcess) return false; const pid = this.streaming?.childProcess?.pid;
if (!pid) return false;
try { try {
// Sending signal 0 tests if process exists without actually sending a signal // Sending signal 0 tests if process exists without actually sending a signal
process.kill(this.streaming.childProcess.pid, 0); process.kill(pid, 0);
return true; return true;
} catch { } catch {
return false; return false;
+51 -27
View File
@@ -2,6 +2,12 @@ import * as plugins from './plugins.js';
import type { TFirecrackerArch } from './interfaces/index.js'; import type { TFirecrackerArch } from './interfaces/index.js';
import { SmartVMError } from './interfaces/index.js'; import { SmartVMError } from './interfaces/index.js';
type TShellExecResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawn']>>;
function getErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/** /**
* Helper to check if a file or directory exists. * Helper to check if a file or directory exists.
*/ */
@@ -21,10 +27,21 @@ async function pathExists(filePath: string): Promise<boolean> {
export class ImageManager { export class ImageManager {
private dataDir: string; private dataDir: string;
private arch: TFirecrackerArch; private arch: TFirecrackerArch;
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
constructor(dataDir: string, arch: TFirecrackerArch = 'x86_64') { constructor(dataDir: string, arch: TFirecrackerArch = 'x86_64') {
this.dataDir = dataDir; this.dataDir = dataDir;
this.arch = arch; this.arch = arch;
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
}
private async runChecked(command: string, args: string[]): Promise<TShellExecResult> {
const result = await this.shell.execSpawn(command, args, { silent: true });
if (result.exitCode !== 0) {
const output = (result.stderr || result.stdout || '').trim();
throw new Error(`${command} ${args.join(' ')} exited with code ${result.exitCode}${output ? `: ${output}` : ''}`);
}
return result;
} }
/** /**
@@ -89,14 +106,22 @@ export class ImageManager {
*/ */
public async getLatestVersion(): Promise<string> { public async getLatestVersion(): Promise<string> {
try { try {
const response = await plugins.SmartRequest.create() const result = await this.runChecked('curl', [
.url('https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest') '-fsSLI',
.get(); '-o',
const data = await response.json() as { tag_name: string }; '/dev/null',
return data.tag_name; '-w',
'%{url_effective}',
'https://github.com/firecracker-microvm/firecracker/releases/latest',
]);
const match = result.stdout.trim().match(/\/releases\/tag\/(v\d+\.\d+\.\d+)$/);
if (!match) {
throw new Error(`Could not parse latest Firecracker release URL: ${result.stdout.trim()}`);
}
return match[1];
} catch (err) { } catch (err) {
throw new SmartVMError( throw new SmartVMError(
`Failed to fetch latest Firecracker version: ${(err as Error).message}`, `Failed to fetch latest Firecracker version: ${getErrorMessage(err)}`,
'VERSION_FETCH_FAILED', 'VERSION_FETCH_FAILED',
); );
} }
@@ -119,11 +144,10 @@ export class ImageManager {
try { try {
// Download the archive // Download the archive
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' }); await this.runChecked('curl', ['-fSL', '-o', archivePath, downloadUrl]);
await shell.exec(`curl -fSL -o "${archivePath}" "${downloadUrl}"`);
// Extract the archive // Extract the archive
await shell.exec(`tar -xzf "${archivePath}" -C "${targetDir}"`); await this.runChecked('tar', ['-xzf', archivePath, '-C', targetDir]);
// Firecracker archives contain a directory like release-v1.5.0-x86_64/ // Firecracker archives contain a directory like release-v1.5.0-x86_64/
// with binaries named like firecracker-v1.5.0-x86_64 // with binaries named like firecracker-v1.5.0-x86_64
@@ -134,21 +158,25 @@ export class ImageManager {
const jailerDst = this.getJailerPath(version); const jailerDst = this.getJailerPath(version);
// Move binaries to expected paths // Move binaries to expected paths
await shell.exec(`mv "${firecrackerSrc}" "${firecrackerDst}"`); await plugins.fs.promises.rename(firecrackerSrc, firecrackerDst);
if (await pathExists(jailerSrc)) { if (await pathExists(jailerSrc)) {
await shell.exec(`mv "${jailerSrc}" "${jailerDst}"`); await plugins.fs.promises.rename(jailerSrc, jailerDst);
} }
// Make executable // Make executable
await shell.exec(`chmod +x "${firecrackerDst}"`); await plugins.fs.promises.chmod(firecrackerDst, 0o755);
if (await pathExists(jailerDst)) {
await plugins.fs.promises.chmod(jailerDst, 0o755);
}
// Clean up // Clean up
await shell.exec(`rm -rf "${archivePath}" "${extractedDir}"`); await plugins.fs.promises.rm(archivePath, { force: true });
await plugins.fs.promises.rm(extractedDir, { recursive: true, force: true });
return firecrackerDst; return firecrackerDst;
} catch (err) { } catch (err) {
throw new SmartVMError( throw new SmartVMError(
`Failed to download Firecracker ${version}: ${(err as Error).message}`, `Failed to download Firecracker ${version}: ${getErrorMessage(err)}`,
'DOWNLOAD_FAILED', 'DOWNLOAD_FAILED',
); );
} }
@@ -163,12 +191,11 @@ export class ImageManager {
const kernelPath = plugins.path.join(kernelsDir, name); const kernelPath = plugins.path.join(kernelsDir, name);
try { try {
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' }); await this.runChecked('curl', ['-fSL', '-o', kernelPath, url]);
await shell.exec(`curl -fSL -o "${kernelPath}" "${url}"`);
return kernelPath; return kernelPath;
} catch (err) { } catch (err) {
throw new SmartVMError( throw new SmartVMError(
`Failed to download kernel from ${url}: ${(err as Error).message}`, `Failed to download kernel from ${url}: ${getErrorMessage(err)}`,
'DOWNLOAD_FAILED', 'DOWNLOAD_FAILED',
); );
} }
@@ -183,12 +210,11 @@ export class ImageManager {
const rootfsPath = plugins.path.join(rootfsDir, name); const rootfsPath = plugins.path.join(rootfsDir, name);
try { try {
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' }); await this.runChecked('curl', ['-fSL', '-o', rootfsPath, url]);
await shell.exec(`curl -fSL -o "${rootfsPath}" "${url}"`);
return rootfsPath; return rootfsPath;
} catch (err) { } catch (err) {
throw new SmartVMError( throw new SmartVMError(
`Failed to download rootfs from ${url}: ${(err as Error).message}`, `Failed to download rootfs from ${url}: ${getErrorMessage(err)}`,
'DOWNLOAD_FAILED', 'DOWNLOAD_FAILED',
); );
} }
@@ -203,13 +229,12 @@ export class ImageManager {
const rootfsPath = plugins.path.join(rootfsDir, name); const rootfsPath = plugins.path.join(rootfsDir, name);
try { try {
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' }); await this.runChecked('dd', ['if=/dev/zero', `of=${rootfsPath}`, 'bs=1M', `count=${sizeMib}`]);
await shell.exec(`dd if=/dev/zero of="${rootfsPath}" bs=1M count=${sizeMib}`); await this.runChecked('mkfs.ext4', [rootfsPath]);
await shell.exec(`mkfs.ext4 "${rootfsPath}"`);
return rootfsPath; return rootfsPath;
} catch (err) { } catch (err) {
throw new SmartVMError( throw new SmartVMError(
`Failed to create blank rootfs: ${(err as Error).message}`, `Failed to create blank rootfs: ${getErrorMessage(err)}`,
'ROOTFS_CREATE_FAILED', 'ROOTFS_CREATE_FAILED',
); );
} }
@@ -224,12 +249,11 @@ export class ImageManager {
const targetPath = plugins.path.join(rootfsDir, targetName); const targetPath = plugins.path.join(rootfsDir, targetName);
try { try {
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' }); await plugins.fs.promises.copyFile(sourcePath, targetPath);
await shell.exec(`cp "${sourcePath}" "${targetPath}"`);
return targetPath; return targetPath;
} catch (err) { } catch (err) {
throw new SmartVMError( throw new SmartVMError(
`Failed to clone rootfs: ${(err as Error).message}`, `Failed to clone rootfs: ${getErrorMessage(err)}`,
'ROOTFS_CLONE_FAILED', 'ROOTFS_CLONE_FAILED',
); );
} }
+90 -11
View File
@@ -2,8 +2,10 @@ import * as plugins from './plugins.js';
import type { import type {
TVMState, TVMState,
IMicroVMConfig, IMicroVMConfig,
IMicroVMRuntimeOptions,
ISnapshotCreateParams, ISnapshotCreateParams,
ISnapshotLoadParams, ISnapshotLoadParams,
IDriveConfig,
ITapDevice, ITapDevice,
} from './interfaces/index.js'; } from './interfaces/index.js';
import { SmartVMError } from './interfaces/index.js'; import { SmartVMError } from './interfaces/index.js';
@@ -26,6 +28,9 @@ export class MicroVM {
private networkManager: NetworkManager; private networkManager: NetworkManager;
private binaryPath: string; private binaryPath: string;
private socketPath: string; private socketPath: string;
private runtimeDir: string;
private ephemeralWritableDrives: boolean;
private vmRuntimeDir: string | null = null;
private tapDevices: ITapDevice[] = []; private tapDevices: ITapDevice[] = [];
constructor( constructor(
@@ -34,12 +39,15 @@ export class MicroVM {
binaryPath: string, binaryPath: string,
socketPath: string, socketPath: string,
networkManager: NetworkManager, networkManager: NetworkManager,
runtimeOptions: IMicroVMRuntimeOptions = {},
) { ) {
this.id = id; this.id = id;
this.vmConfig = new VMConfig(config); this.vmConfig = new VMConfig(config);
this.binaryPath = binaryPath; this.binaryPath = binaryPath;
this.socketPath = socketPath; this.socketPath = socketPath;
this.networkManager = networkManager; this.networkManager = networkManager;
this.runtimeDir = runtimeOptions.runtimeDir || plugins.path.join(plugins.os.tmpdir(), '.smartvm', 'runtime');
this.ephemeralWritableDrives = runtimeOptions.ephemeralWritableDrives ?? true;
} }
/** /**
@@ -54,6 +62,16 @@ export class MicroVM {
} }
} }
private getSocketClient(operation: string): SocketClient {
if (!this.socketClient) {
throw new SmartVMError(
`Cannot ${operation}: socket client not initialized`,
'NO_CLIENT',
);
}
return this.socketClient;
}
/** /**
* Start the MicroVM. * Start the MicroVM.
* Validates config, starts the Firecracker process, applies pre-boot configuration, and boots the VM. * Validates config, starts the Firecracker process, applies pre-boot configuration, and boots the VM.
@@ -73,6 +91,9 @@ export class MicroVM {
this.state = 'configuring'; this.state = 'configuring';
try { try {
await this.ensureVMRuntimeDir();
await this.prepareEphemeralDrives();
// Start the Firecracker process // Start the Firecracker process
this.process = new FirecrackerProcess({ this.process = new FirecrackerProcess({
binaryPath: this.binaryPath, binaryPath: this.binaryPath,
@@ -244,7 +265,7 @@ export class MicroVM {
*/ */
public async getMetadata(): Promise<any> { public async getMetadata(): Promise<any> {
this.assertState(['running', 'paused'], 'getMetadata'); this.assertState(['running', 'paused'], 'getMetadata');
const response = await this.socketClient!.get('/mmds'); const response = await this.getSocketClient('getMetadata').get('/mmds');
return response.body; return response.body;
} }
@@ -282,7 +303,7 @@ export class MicroVM {
* Get VM instance info. * Get VM instance info.
*/ */
public async getInfo(): Promise<any> { public async getInfo(): Promise<any> {
const response = await this.socketClient!.get('/'); const response = await this.getSocketClient('getInfo').get('/');
return response.body; return response.body;
} }
@@ -290,7 +311,7 @@ export class MicroVM {
* Get Firecracker version info. * Get Firecracker version info.
*/ */
public async getVersion(): Promise<any> { public async getVersion(): Promise<any> {
const response = await this.socketClient!.get('/version'); const response = await this.getSocketClient('getVersion').get('/version');
return response.body; return response.body;
} }
@@ -308,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.
*/ */
@@ -324,20 +352,74 @@ export class MicroVM {
} }
this.tapDevices = []; this.tapDevices = [];
if (this.vmRuntimeDir) {
await plugins.fs.promises.rm(this.vmRuntimeDir, { recursive: true, force: true });
this.vmRuntimeDir = null;
}
this.socketClient = null; this.socketClient = null;
if (this.state !== 'error') { if (this.state !== 'error') {
this.state = 'stopped'; this.state = 'stopped';
} }
} }
private shouldStageDrive(drive: IDriveConfig): boolean {
if (!this.ephemeralWritableDrives) {
return false;
}
if (drive.ephemeral === false) {
return false;
}
if (drive.isReadOnly === true && drive.ephemeral !== true) {
return false;
}
return true;
}
private async ensureVMRuntimeDir(): Promise<string> {
if (!this.vmRuntimeDir) {
this.vmRuntimeDir = plugins.path.join(this.runtimeDir, this.sanitizePathPart(this.id));
}
await plugins.fs.promises.mkdir(this.vmRuntimeDir, { recursive: true });
return this.vmRuntimeDir;
}
private async prepareEphemeralDrives(): Promise<void> {
const drives = this.vmConfig.config.drives || [];
for (const drive of drives) {
if (!this.shouldStageDrive(drive)) {
continue;
}
const vmRuntimeDir = await this.ensureVMRuntimeDir();
const drivesDir = plugins.path.join(vmRuntimeDir, 'drives');
await plugins.fs.promises.mkdir(drivesDir, { recursive: true });
const sourcePath = drive.pathOnHost;
const sourceFileName = plugins.path.basename(sourcePath) || 'drive.img';
const stagedPath = plugins.path.join(
drivesDir,
`${this.sanitizePathPart(drive.driveId)}-${this.sanitizePathPart(sourceFileName)}`,
);
await plugins.fs.promises.copyFile(sourcePath, stagedPath);
drive.pathOnHost = stagedPath;
}
}
private sanitizePathPart(value: string): string {
const sanitized = value.replace(/[^a-zA-Z0-9._-]/g, '_');
if (!sanitized || sanitized === '.' || sanitized === '..') {
return 'item';
}
return sanitized;
}
/** /**
* Helper: PUT request with error handling. * Helper: PUT request with error handling.
*/ */
private async apiPut(path: string, body: Record<string, any>): Promise<void> { private async apiPut(path: string, body: Record<string, any>): Promise<void> {
if (!this.socketClient) { const response = await this.getSocketClient(`PUT ${path}`).put(path, body);
throw new SmartVMError('Socket client not initialized', 'NO_CLIENT');
}
const response = await this.socketClient.put(path, body);
if (!response.ok) { if (!response.ok) {
throw new SmartVMError( throw new SmartVMError(
`API PUT ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`, `API PUT ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`,
@@ -352,10 +434,7 @@ export class MicroVM {
* Helper: PATCH request with error handling. * Helper: PATCH request with error handling.
*/ */
private async apiPatch(path: string, body: Record<string, any>): Promise<void> { private async apiPatch(path: string, body: Record<string, any>): Promise<void> {
if (!this.socketClient) { const response = await this.getSocketClient(`PATCH ${path}`).patch(path, body);
throw new SmartVMError('Socket client not initialized', 'NO_CLIENT');
}
const response = await this.socketClient.patch(path, body);
if (!response.ok) { if (!response.ok) {
throw new SmartVMError( throw new SmartVMError(
`API PATCH ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`, `API PATCH ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`,
+190 -46
View File
@@ -2,6 +2,15 @@ import * as plugins from './plugins.js';
import type { INetworkManagerOptions, ITapDevice } from './interfaces/index.js'; import type { INetworkManagerOptions, ITapDevice } from './interfaces/index.js';
import { SmartVMError } from './interfaces/index.js'; import { SmartVMError } from './interfaces/index.js';
type TShellExecResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawn']>>;
interface IParsedSubnet {
networkAddress: number;
broadcastAddress: number;
cidr: number;
subnetMask: string;
}
/** /**
* Manages host networking for Firecracker VMs. * Manages host networking for Firecracker VMs.
* Creates TAP devices, Linux bridges, and configures NAT for VM internet access. * Creates TAP devices, Linux bridges, and configures NAT for VM internet access.
@@ -12,53 +21,121 @@ export class NetworkManager {
private subnetCidr: number; private subnetCidr: number;
private gatewayIp: string; private gatewayIp: string;
private subnetMask: string; private subnetMask: string;
private nextIpOctet: number; private nextIpAddress: number;
private lastUsableIpAddress: number;
private activeTaps: Map<string, ITapDevice> = new Map(); private activeTaps: Map<string, ITapDevice> = new Map();
private bridgeCreated: boolean = false; private bridgeCreated: boolean = false;
private defaultRouteInterface: string | null = null;
private shell: InstanceType<typeof plugins.smartshell.Smartshell>; private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
constructor(options: INetworkManagerOptions = {}) { constructor(options: INetworkManagerOptions = {}) {
this.bridgeName = options.bridgeName || 'svbr0'; this.bridgeName = options.bridgeName || 'svbr0';
this.validateInterfaceName(this.bridgeName, 'bridgeName');
const subnet = options.subnet || '172.30.0.0/24'; const subnet = options.subnet || '172.30.0.0/24';
const parsedSubnet = this.parseSubnet(subnet);
// Parse the subnet this.subnetBase = this.intToIp(parsedSubnet.networkAddress);
const [baseIp, cidrStr] = subnet.split('/'); this.subnetCidr = parsedSubnet.cidr;
this.subnetBase = baseIp; this.subnetMask = parsedSubnet.subnetMask;
this.subnetCidr = parseInt(cidrStr, 10); this.gatewayIp = this.intToIp(parsedSubnet.networkAddress + 1);
this.subnetMask = this.cidrToSubnetMask(this.subnetCidr); this.nextIpAddress = parsedSubnet.networkAddress + 2;
this.lastUsableIpAddress = parsedSubnet.broadcastAddress - 1;
// Gateway is .1 in the subnet
const parts = this.subnetBase.split('.').map(Number);
parts[3] = 1;
this.gatewayIp = parts.join('.');
// VMs start at .2
this.nextIpOctet = 2;
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' }); this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
} }
/** /**
* Convert a CIDR prefix length to a dotted-decimal subnet mask. * Parse an IPv4 CIDR subnet and ensure there is room for a gateway and guests.
*/ */
private cidrToSubnetMask(cidr: number): string { private parseSubnet(subnet: string): IParsedSubnet {
const mask = (0xffffffff << (32 - cidr)) >>> 0; const [ip, cidrText, extra] = subnet.split('/');
const cidr = Number(cidrText);
if (!ip || !cidrText || extra !== undefined || !Number.isInteger(cidr) || cidr < 1 || cidr > 30) {
throw new SmartVMError(
`Invalid subnet '${subnet}': expected IPv4 CIDR with prefix length 1-30`,
'INVALID_SUBNET',
);
}
const ipAddress = this.ipToInt(ip);
const mask = this.cidrToMask(cidr);
const networkAddress = (ipAddress & mask) >>> 0;
const hostCount = 2 ** (32 - cidr);
const broadcastAddress = networkAddress + hostCount - 1;
if (hostCount < 4) {
throw new SmartVMError(
`Invalid subnet '${subnet}': at least two usable host addresses are required`,
'INVALID_SUBNET',
);
}
return {
networkAddress,
broadcastAddress,
cidr,
subnetMask: this.intToIp(mask),
};
}
private ipToInt(ip: string): number {
const octets = ip.split('.');
if (octets.length !== 4) {
throw new SmartVMError(`Invalid IPv4 address '${ip}'`, 'INVALID_SUBNET');
}
if (octets.some((octet) => !/^[0-9]+$/.test(octet))) {
throw new SmartVMError(`Invalid IPv4 address '${ip}'`, 'INVALID_SUBNET');
}
const numbers = octets.map((octet) => Number(octet));
if (numbers.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
throw new SmartVMError(`Invalid IPv4 address '${ip}'`, 'INVALID_SUBNET');
}
return (
numbers[0] * 256 ** 3 +
numbers[1] * 256 ** 2 +
numbers[2] * 256 +
numbers[3]
) >>> 0;
}
private intToIp(address: number): string {
return [ return [
(mask >>> 24) & 0xff, Math.floor(address / 256 ** 3) % 256,
(mask >>> 16) & 0xff, Math.floor(address / 256 ** 2) % 256,
(mask >>> 8) & 0xff, Math.floor(address / 256) % 256,
mask & 0xff, address % 256,
].join('.'); ].join('.');
} }
private cidrToMask(cidr: number): number {
return (0xffffffff << (32 - cidr)) >>> 0;
}
private validateInterfaceName(name: string, fieldName: string): void {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,14}$/.test(name)) {
throw new SmartVMError(
`${fieldName} '${name}' is not a valid Linux interface name`,
'INVALID_INTERFACE_NAME',
);
}
}
/** /**
* Allocate the next available IP address in the subnet. * Allocate the next available IP address in the subnet.
*/ */
public allocateIp(): string { public allocateIp(): string {
const parts = this.subnetBase.split('.').map(Number); if (this.nextIpAddress > this.lastUsableIpAddress) {
parts[3] = this.nextIpOctet; throw new SmartVMError(
this.nextIpOctet++; `Subnet ${this.subnetBase}/${this.subnetCidr} has no available guest IP addresses`,
return parts.join('.'); 'IP_EXHAUSTED',
);
}
const ip = this.intToIp(this.nextIpAddress);
this.nextIpAddress++;
return ip;
} }
/** /**
@@ -102,6 +179,36 @@ export class NetworkManager {
return tapName.substring(0, 15); return tapName.substring(0, 15);
} }
private async run(command: string, args: string[]): Promise<TShellExecResult> {
return this.shell.execSpawn(command, args, { silent: true });
}
private async runChecked(command: string, args: string[]): Promise<TShellExecResult> {
const result = await this.run(command, args);
if (result.exitCode !== 0) {
const output = (result.stderr || result.stdout || '').trim();
throw new Error(`${command} ${args.join(' ')} exited with code ${result.exitCode}${output ? `: ${output}` : ''}`);
}
return result;
}
private async getDefaultRouteInterface(): Promise<string> {
if (this.defaultRouteInterface) {
return this.defaultRouteInterface;
}
const result = await this.runChecked('ip', ['route', 'show', 'default']);
const match = result.stdout.match(/\bdev\s+([^\s]+)/);
if (!match) {
throw new Error('Could not determine default route interface');
}
const iface = match[1];
this.validateInterfaceName(iface, 'default route interface');
this.defaultRouteInterface = iface;
return iface;
}
/** /**
* Ensure the Linux bridge is created and configured. * Ensure the Linux bridge is created and configured.
*/ */
@@ -110,25 +217,45 @@ export class NetworkManager {
try { try {
// Check if bridge already exists // Check if bridge already exists
const result = await this.shell.exec(`ip link show ${this.bridgeName} 2>/dev/null`); const result = await this.run('ip', ['link', 'show', this.bridgeName]);
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
// Create bridge // Create bridge
await this.shell.exec(`ip link add ${this.bridgeName} type bridge`); await this.runChecked('ip', ['link', 'add', this.bridgeName, 'type', 'bridge']);
await this.shell.exec(`ip addr add ${this.gatewayIp}/${this.subnetCidr} dev ${this.bridgeName}`); await this.runChecked('ip', ['addr', 'add', `${this.gatewayIp}/${this.subnetCidr}`, 'dev', this.bridgeName]);
await this.shell.exec(`ip link set ${this.bridgeName} up`); await this.runChecked('ip', ['link', 'set', this.bridgeName, 'up']);
} }
// Enable IP forwarding // Enable IP forwarding
await this.shell.exec('sysctl -w net.ipv4.ip_forward=1'); await this.runChecked('sysctl', ['-w', 'net.ipv4.ip_forward=1']);
// Set up NAT masquerade (idempotent with -C check) // Set up NAT masquerade (idempotent with -C check)
const checkResult = await this.shell.exec( const defaultIface = await this.getDefaultRouteInterface();
`iptables -t nat -C POSTROUTING -s ${this.subnetBase}/${this.subnetCidr} -o $(ip route | grep default | awk '{print $5}' | head -1) -j MASQUERADE 2>/dev/null`, const natArgs = [
); '-t',
'nat',
'-C',
'POSTROUTING',
'-s',
`${this.subnetBase}/${this.subnetCidr}`,
'-o',
defaultIface,
'-j',
'MASQUERADE',
];
const checkResult = await this.run('iptables', natArgs);
if (checkResult.exitCode !== 0) { if (checkResult.exitCode !== 0) {
await this.shell.exec( await this.runChecked('iptables', [
`iptables -t nat -A POSTROUTING -s ${this.subnetBase}/${this.subnetCidr} -o $(ip route | grep default | awk '{print $5}' | head -1) -j MASQUERADE`, '-t',
); 'nat',
'-A',
'POSTROUTING',
'-s',
`${this.subnetBase}/${this.subnetCidr}`,
'-o',
defaultIface,
'-j',
'MASQUERADE',
]);
} }
this.bridgeCreated = true; this.bridgeCreated = true;
@@ -148,16 +275,19 @@ export class NetworkManager {
await this.ensureBridge(); await this.ensureBridge();
const tapName = this.generateTapName(vmId, ifaceId); const tapName = this.generateTapName(vmId, ifaceId);
this.validateInterfaceName(tapName, 'tapName');
const guestIp = this.allocateIp(); const guestIp = this.allocateIp();
const mac = this.generateMac(vmId, ifaceId); const mac = this.generateMac(vmId, ifaceId);
let tapCreated = false;
try { try {
// Create TAP device // Create TAP device
await this.shell.exec(`ip tuntap add dev ${tapName} mode tap`); await this.runChecked('ip', ['tuntap', 'add', 'dev', tapName, 'mode', 'tap']);
tapCreated = true;
// Attach to bridge // Attach to bridge
await this.shell.exec(`ip link set ${tapName} master ${this.bridgeName}`); await this.runChecked('ip', ['link', 'set', tapName, 'master', this.bridgeName]);
// Bring TAP device up // Bring TAP device up
await this.shell.exec(`ip link set ${tapName} up`); await this.runChecked('ip', ['link', 'set', tapName, 'up']);
const tap: ITapDevice = { const tap: ITapDevice = {
tapName, tapName,
@@ -170,6 +300,9 @@ export class NetworkManager {
this.activeTaps.set(tapName, tap); this.activeTaps.set(tapName, tap);
return tap; return tap;
} catch (err) { } catch (err) {
if (tapCreated) {
await this.removeTapDevice(tapName);
}
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
throw new SmartVMError( throw new SmartVMError(
`Failed to create TAP device ${tapName}: ${message}`, `Failed to create TAP device ${tapName}: ${message}`,
@@ -182,8 +315,9 @@ export class NetworkManager {
* Remove a TAP device and free its resources. * Remove a TAP device and free its resources.
*/ */
public async removeTapDevice(tapName: string): Promise<void> { public async removeTapDevice(tapName: string): Promise<void> {
this.validateInterfaceName(tapName, 'tapName');
try { try {
await this.shell.exec(`ip link del ${tapName} 2>/dev/null`); await this.run('ip', ['link', 'del', tapName]);
this.activeTaps.delete(tapName); this.activeTaps.delete(tapName);
} catch { } catch {
// Device may already be gone // Device may already be gone
@@ -211,24 +345,34 @@ export class NetworkManager {
*/ */
public async cleanup(): Promise<void> { public async cleanup(): Promise<void> {
// Remove all TAP devices // Remove all TAP devices
for (const tapName of this.activeTaps.keys()) { for (const tapName of Array.from(this.activeTaps.keys())) {
await this.removeTapDevice(tapName); await this.removeTapDevice(tapName);
} }
// Remove bridge if we created it // Remove bridge if we created it
if (this.bridgeCreated) { if (this.bridgeCreated) {
try { try {
await this.shell.exec(`ip link set ${this.bridgeName} down 2>/dev/null`); await this.run('ip', ['link', 'set', this.bridgeName, 'down']);
await this.shell.exec(`ip link del ${this.bridgeName} 2>/dev/null`); await this.run('ip', ['link', 'del', this.bridgeName]);
} catch { } catch {
// Bridge may already be gone // Bridge may already be gone
} }
// Remove NAT rule // Remove NAT rule
try { try {
await this.shell.exec( const defaultIface = this.defaultRouteInterface || await this.getDefaultRouteInterface();
`iptables -t nat -D POSTROUTING -s ${this.subnetBase}/${this.subnetCidr} -o $(ip route | grep default | awk '{print $5}' | head -1) -j MASQUERADE 2>/dev/null`, await this.run('iptables', [
); '-t',
'nat',
'-D',
'POSTROUTING',
'-s',
`${this.subnetBase}/${this.subnetCidr}`,
'-o',
defaultIface,
'-j',
'MASQUERADE',
]);
} catch { } catch {
// Rule may not exist // Rule may not exist
} }
+56 -5
View File
@@ -1,16 +1,22 @@
import * as plugins from './plugins.js'; import * as plugins from './plugins.js';
import type { ISmartVMOptions, IMicroVMConfig } from './interfaces/index.js'; import type { IBaseImageBundle, IEnsureBaseImageOptions, ISmartVMOptions, IMicroVMConfig } from './interfaces/index.js';
import { SmartVMError } from './interfaces/index.js'; import { SmartVMError } from './interfaces/index.js';
import { ImageManager } from './classes.imagemanager.js'; import { ImageManager } from './classes.imagemanager.js';
import { BaseImageManager } from './classes.baseimagemanager.js';
import { NetworkManager } from './classes.networkmanager.js'; import { NetworkManager } from './classes.networkmanager.js';
import { MicroVM } from './classes.microvm.js'; import { MicroVM } from './classes.microvm.js';
function getErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/** /**
* Top-level orchestrator for creating and managing Firecracker MicroVMs. * Top-level orchestrator for creating and managing Firecracker MicroVMs.
*/ */
export class SmartVM { export class SmartVM {
private options: ISmartVMOptions; private options: ISmartVMOptions;
public imageManager: ImageManager; public imageManager: ImageManager;
public baseImageManager: BaseImageManager;
public networkManager: NetworkManager; public networkManager: NetworkManager;
private activeVMs: Map<string, MicroVM> = new Map(); private activeVMs: Map<string, MicroVM> = new Map();
private smartExitInstance: InstanceType<typeof plugins.smartexit.SmartExit>; private smartExitInstance: InstanceType<typeof plugins.smartexit.SmartExit>;
@@ -20,6 +26,8 @@ export class SmartVM {
constructor(options: ISmartVMOptions = {}) { constructor(options: ISmartVMOptions = {}) {
this.options = { this.options = {
dataDir: options.dataDir || '/tmp/.smartvm', dataDir: options.dataDir || '/tmp/.smartvm',
runtimeDir: options.runtimeDir || this.getDefaultRuntimeDir(),
ephemeralWritableDrives: options.ephemeralWritableDrives ?? true,
arch: options.arch || 'x86_64', arch: options.arch || 'x86_64',
bridgeName: options.bridgeName || 'svbr0', bridgeName: options.bridgeName || 'svbr0',
subnet: options.subnet || '172.30.0.0/24', subnet: options.subnet || '172.30.0.0/24',
@@ -27,6 +35,13 @@ export class SmartVM {
}; };
this.imageManager = new ImageManager(this.options.dataDir!, this.options.arch); this.imageManager = new ImageManager(this.options.dataDir!, this.options.arch);
this.baseImageManager = new BaseImageManager({
arch: this.options.arch,
cacheDir: this.options.baseImageCacheDir,
maxStoredBaseImages: this.options.maxStoredBaseImages,
hostedManifestUrl: this.options.baseImageManifestUrl,
hostedManifestPath: this.options.baseImageManifestPath,
});
this.networkManager = new NetworkManager({ this.networkManager = new NetworkManager({
bridgeName: this.options.bridgeName, bridgeName: this.options.bridgeName,
subnet: this.options.subnet, subnet: this.options.subnet,
@@ -44,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.
@@ -97,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(
@@ -107,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
@@ -115,6 +159,13 @@ export class SmartVM {
return vm; return vm;
} }
/**
* Ensure a Firecracker CI base image bundle is available locally.
*/
public async ensureBaseImage(options: IEnsureBaseImageOptions = {}): Promise<IBaseImageBundle> {
return this.baseImageManager.ensureBaseImage(options);
}
/** /**
* Get an active VM by ID. * Get an active VM by ID.
*/ */
@@ -145,7 +196,7 @@ export class SmartVM {
if (vm.state === 'running' || vm.state === 'paused') { if (vm.state === 'running' || vm.state === 'paused') {
stopPromises.push( stopPromises.push(
vm.stop().catch((err) => { vm.stop().catch((err) => {
console.error(`Failed to stop VM ${vm.id}: ${err.message}`); console.error(`Failed to stop VM ${vm.id}: ${getErrorMessage(err)}`);
}), }),
); );
} }
@@ -162,7 +213,7 @@ export class SmartVM {
for (const vm of this.activeVMs.values()) { for (const vm of this.activeVMs.values()) {
cleanupPromises.push( cleanupPromises.push(
vm.cleanup().catch((err) => { vm.cleanup().catch((err) => {
console.error(`Failed to clean up VM ${vm.id}: ${err.message}`); console.error(`Failed to clean up VM ${vm.id}: ${getErrorMessage(err)}`);
}), }),
); );
} }
+37 -26
View File
@@ -2,6 +2,10 @@ import * as plugins from './plugins.js';
import type { ISocketClientOptions, IApiResponse } from './interfaces/index.js'; import type { ISocketClientOptions, IApiResponse } from './interfaces/index.js';
import { SmartVMError } from './interfaces/index.js'; import { SmartVMError } from './interfaces/index.js';
function getErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/** /**
* HTTP client that communicates with Firecracker over a Unix domain socket. * HTTP client that communicates with Firecracker over a Unix domain socket.
* Uses @push.rocks/smartrequest with the `http://unix:<socket>:<path>` URL format. * Uses @push.rocks/smartrequest with the `http://unix:<socket>:<path>` URL format.
@@ -20,6 +24,22 @@ export class SocketClient {
return `http://unix:${this.socketPath}:${apiPath}`; return `http://unix:${this.socketPath}:${apiPath}`;
} }
private async parseResponseBody<T>(response: any): Promise<T> {
try {
const text = await response.text();
if (!text) {
return undefined as T;
}
try {
return JSON.parse(text) as T;
} catch {
return text as T;
}
} catch {
return undefined as T;
}
}
/** /**
* Perform a GET request. * Perform a GET request.
*/ */
@@ -31,12 +51,7 @@ export class SocketClient {
.get(); .get();
const statusCode = response.status; const statusCode = response.status;
let body: T; const body = await this.parseResponseBody<T>(response);
try {
body = await response.json() as T;
} catch {
body = undefined as any;
}
return { return {
statusCode, statusCode,
body, body,
@@ -44,7 +59,7 @@ export class SocketClient {
}; };
} catch (err) { } catch (err) {
throw new SmartVMError( throw new SmartVMError(
`GET ${apiPath} failed: ${(err as Error).message}`, `GET ${apiPath} failed: ${getErrorMessage(err)}`,
'SOCKET_REQUEST_FAILED', 'SOCKET_REQUEST_FAILED',
); );
} }
@@ -58,17 +73,15 @@ export class SocketClient {
try { try {
let request = plugins.SmartRequest.create().url(url); let request = plugins.SmartRequest.create().url(url);
if (body !== undefined) { if (body !== undefined) {
request = request.json(body); const bodyBuffer = Buffer.from(JSON.stringify(body));
request = request
.buffer(bodyBuffer, 'application/json')
.header('Content-Length', String(bodyBuffer.length));
} }
const response = await request.put(); const response = await request.put();
const statusCode = response.status; const statusCode = response.status;
let responseBody: T; const responseBody = await this.parseResponseBody<T>(response);
try {
responseBody = await response.json() as T;
} catch {
responseBody = undefined as any;
}
return { return {
statusCode, statusCode,
body: responseBody, body: responseBody,
@@ -76,7 +89,7 @@ export class SocketClient {
}; };
} catch (err) { } catch (err) {
throw new SmartVMError( throw new SmartVMError(
`PUT ${apiPath} failed: ${(err as Error).message}`, `PUT ${apiPath} failed: ${getErrorMessage(err)}`,
'SOCKET_REQUEST_FAILED', 'SOCKET_REQUEST_FAILED',
); );
} }
@@ -90,17 +103,15 @@ export class SocketClient {
try { try {
let request = plugins.SmartRequest.create().url(url); let request = plugins.SmartRequest.create().url(url);
if (body !== undefined) { if (body !== undefined) {
request = request.json(body); const bodyBuffer = Buffer.from(JSON.stringify(body));
request = request
.buffer(bodyBuffer, 'application/json')
.header('Content-Length', String(bodyBuffer.length));
} }
const response = await request.patch(); const response = await request.patch();
const statusCode = response.status; const statusCode = response.status;
let responseBody: T; const responseBody = await this.parseResponseBody<T>(response);
try {
responseBody = await response.json() as T;
} catch {
responseBody = undefined as any;
}
return { return {
statusCode, statusCode,
body: responseBody, body: responseBody,
@@ -108,21 +119,21 @@ export class SocketClient {
}; };
} catch (err) { } catch (err) {
throw new SmartVMError( throw new SmartVMError(
`PATCH ${apiPath} failed: ${(err as Error).message}`, `PATCH ${apiPath} failed: ${getErrorMessage(err)}`,
'SOCKET_REQUEST_FAILED', 'SOCKET_REQUEST_FAILED',
); );
} }
} }
/** /**
* Check if the Firecracker API socket is ready by polling GET /. * Check if the Firecracker API socket is ready by polling GET /version.
*/ */
public async isReady(timeoutMs: number = 5000): Promise<boolean> { public async isReady(timeoutMs: number = 5000): Promise<boolean> {
const start = Date.now(); const start = Date.now();
while (Date.now() - start < timeoutMs) { while (Date.now() - start < timeoutMs) {
try { try {
const response = await this.get('/'); const response = await this.get('/version');
if (response.ok || response.statusCode === 200 || response.statusCode === 400) { if (response.ok) {
return true; return true;
} }
} catch { } catch {
+39 -1
View File
@@ -12,7 +12,45 @@ export class VMConfig {
public config: IMicroVMConfig; public config: IMicroVMConfig;
constructor(config: IMicroVMConfig) { constructor(config: IMicroVMConfig) {
this.config = config; this.config = this.cloneConfig(config);
}
/**
* Keep internal normalization from mutating the caller's config object.
*/
private cloneConfig(config: IMicroVMConfig): IMicroVMConfig {
return {
...config,
bootSource: config.bootSource ? { ...config.bootSource } : config.bootSource,
machineConfig: config.machineConfig ? { ...config.machineConfig } : config.machineConfig,
drives: config.drives?.map((drive) => ({
...drive,
rateLimiter: drive.rateLimiter ? this.cloneRateLimiter(drive.rateLimiter) : undefined,
ephemeral: drive.ephemeral,
})),
networkInterfaces: config.networkInterfaces?.map((iface) => ({
...iface,
rxRateLimiter: iface.rxRateLimiter ? this.cloneRateLimiter(iface.rxRateLimiter) : undefined,
txRateLimiter: iface.txRateLimiter ? this.cloneRateLimiter(iface.txRateLimiter) : undefined,
})),
vsock: config.vsock ? { ...config.vsock } : undefined,
balloon: config.balloon ? { ...config.balloon } : undefined,
mmds: config.mmds ? {
...config.mmds,
networkInterfaces: config.mmds.networkInterfaces
? [...config.mmds.networkInterfaces]
: config.mmds.networkInterfaces,
} : undefined,
logger: config.logger ? { ...config.logger } : undefined,
metrics: config.metrics ? { ...config.metrics } : undefined,
};
}
private cloneRateLimiter(rateLimiter: IRateLimiter): IRateLimiter {
return {
bandwidth: rateLimiter.bandwidth ? { ...rateLimiter.bandwidth } : undefined,
ops: rateLimiter.ops ? { ...rateLimiter.ops } : undefined,
};
} }
/** /**
+1
View File
@@ -2,6 +2,7 @@ export * from './interfaces/index.js';
export { VMConfig } from './classes.vmconfig.js'; export { VMConfig } from './classes.vmconfig.js';
export { SocketClient } from './classes.socketclient.js'; export { SocketClient } from './classes.socketclient.js';
export { ImageManager } from './classes.imagemanager.js'; export { ImageManager } from './classes.imagemanager.js';
export { BaseImageManager } from './classes.baseimagemanager.js';
export { FirecrackerProcess } from './classes.firecrackerprocess.js'; export { FirecrackerProcess } from './classes.firecrackerprocess.js';
export { NetworkManager } from './classes.networkmanager.js'; export { NetworkManager } from './classes.networkmanager.js';
export { MicroVM } from './classes.microvm.js'; export { MicroVM } from './classes.microvm.js';
+139 -2
View File
@@ -6,6 +6,10 @@ import type { TFirecrackerArch, TCacheType, TSnapshotType, TLogLevel } from './c
export interface ISmartVMOptions { export interface ISmartVMOptions {
/** Directory for storing binaries, kernels, rootfs images, and sockets. Defaults to /tmp/.smartvm */ /** Directory for storing binaries, kernels, rootfs images, and sockets. Defaults to /tmp/.smartvm */
dataDir?: string; dataDir?: string;
/** Directory for VM sockets and ephemeral per-VM files. Defaults to /dev/shm/.smartvm/runtime on Linux when available. */
runtimeDir?: string;
/** Copy writable drives into the VM runtime directory before boot and delete them on cleanup. Defaults to true. */
ephemeralWritableDrives?: boolean;
/** Firecracker version to use. Defaults to latest. */ /** Firecracker version to use. Defaults to latest. */
firecrackerVersion?: string; firecrackerVersion?: string;
/** Target architecture. Defaults to x86_64. */ /** Target architecture. Defaults to x86_64. */
@@ -16,6 +20,137 @@ export interface ISmartVMOptions {
bridgeName?: string; bridgeName?: string;
/** Network subnet in CIDR notation. Defaults to '172.30.0.0/24'. */ /** Network subnet in CIDR notation. Defaults to '172.30.0.0/24'. */
subnet?: string; subnet?: string;
/** Directory for cached base images. Defaults to /tmp/.smartvm/base-images. */
baseImageCacheDir?: string;
/** Maximum number of cached base image bundles. Defaults to 2. */
maxStoredBaseImages?: number;
/** Hosted/project-owned base image manifest URL. */
baseImageManifestUrl?: string;
/** Local hosted/project-owned base image manifest path for development and tests. */
baseImageManifestPath?: string;
}
/**
* Predefined base image sources for integration testing and quick starts.
*/
export type TBaseImagePreset = 'latest' | 'lts' | 'hosted';
/**
* Root filesystem image type used by a base image bundle.
*/
export type TBaseImageRootfsType = 'ext4' | 'squashfs';
/**
* Options for the BaseImageManager.
*/
export interface IBaseImageManagerOptions {
/** Architecture to resolve. Defaults to x86_64. */
arch?: TFirecrackerArch;
/** Directory for cached base image bundles. Defaults to /tmp/.smartvm/base-images. */
cacheDir?: string;
/** Maximum number of cached base image bundles. Defaults to 2. */
maxStoredBaseImages?: number;
/** Hosted base image manifest URL for project-owned bundles. */
hostedManifestUrl?: string;
/** Local hosted base image manifest path for development and tests. */
hostedManifestPath?: string;
}
/**
* Options when resolving or downloading a base image bundle.
*/
export interface IEnsureBaseImageOptions {
/** Preset to use. Defaults to latest. */
preset?: TBaseImagePreset;
/** Architecture to resolve. Defaults to manager architecture. */
arch?: TFirecrackerArch;
/** Redownload even if the bundle already exists locally. */
forceDownload?: boolean;
/** Hosted base image manifest URL. Overrides preset resolution. */
manifestUrl?: string;
/** Local hosted base image manifest path. Overrides preset resolution. */
manifestPath?: string;
}
/**
* Single hosted base image artifact in a manifest.
*/
export interface IBaseImageArtifactManifest {
/** Public URL for hosted artifacts. */
url?: string;
/** Local path for development/tests. */
path?: string;
/** Optional plain output filename. Defaults to basename of url/path. */
fileName?: string;
/** Expected SHA256 for verification. Required when url is used. */
sha256?: string;
/** Expected file size in bytes. */
sizeBytes?: number;
}
/**
* Hosted/project-owned base image manifest format.
*/
export interface IBaseImageHostedManifest {
schemaVersion: 1;
bundleId: string;
name?: string;
arch: TFirecrackerArch;
firecrackerVersion: string;
rootfsType: TBaseImageRootfsType;
rootfsIsReadOnly?: boolean;
bootArgs?: string;
kernel: IBaseImageArtifactManifest;
rootfs: IBaseImageArtifactManifest;
}
/**
* Cached base image bundle metadata.
*/
export interface IBaseImageBundle {
preset: TBaseImagePreset;
arch: TFirecrackerArch;
ciVersion: string;
firecrackerVersion: string;
bundleId: string;
bundleDir: string;
kernelImagePath: string;
rootfsPath: string;
rootfsType: TBaseImageRootfsType;
rootfsIsReadOnly: boolean;
bootArgs: string;
source: {
type?: 'firecracker-ci' | 'hosted-manifest';
bucketUrl?: string;
kernelKey?: string;
rootfsKey?: string;
manifestUrl?: string;
manifestPath?: string;
kernelUrl?: string;
rootfsUrl?: string;
kernelSourcePath?: string;
rootfsSourcePath?: string;
};
checksums?: {
kernelSha256?: string;
rootfsSha256?: string;
};
sizes?: {
kernelBytes?: number;
rootfsBytes?: number;
};
createdAt: string;
lastAccessedAt: string;
}
/**
* Runtime behavior for a MicroVM instance.
*/
export interface IMicroVMRuntimeOptions {
/** Directory for VM sockets and ephemeral per-VM files. */
runtimeDir?: string;
/** Copy writable drives into runtimeDir before boot and delete them on cleanup. Defaults to true. */
ephemeralWritableDrives?: boolean;
} }
/** /**
@@ -84,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;
} }
/** /**
@@ -142,9 +279,9 @@ export interface ILoggerConfig {
logPath: string; logPath: string;
/** Log level. */ /** Log level. */
level?: TLogLevel; level?: TLogLevel;
/** Whether to show log origin (file, line). */
showLevel?: boolean;
/** Whether to show log level. */ /** Whether to show log level. */
showLevel?: boolean;
/** Whether to show log origin (file, line). */
showLogOrigin?: boolean; showLogOrigin?: boolean;
} }
+2 -1
View File
@@ -2,8 +2,9 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
import * as crypto from 'crypto';
export { fs, path, os }; export { fs, path, os, crypto };
// @push.rocks scope // @push.rocks scope
import * as smartdelay from '@push.rocks/smartdelay'; import * as smartdelay from '@push.rocks/smartdelay';