initial
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist_*/
|
||||
.nogit/
|
||||
.claude/
|
||||
.serena/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
24
npmextra.json
Normal file
24
npmextra.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"@git.zone/cli": {
|
||||
"projectType": "npm",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
"gitscope": "push.rocks",
|
||||
"gitrepo": "smartvm",
|
||||
"description": "A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs",
|
||||
"npmPackagename": "@push.rocks/smartvm",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"firecracker",
|
||||
"microvm",
|
||||
"virtualization",
|
||||
"container",
|
||||
"sandbox",
|
||||
"typescript",
|
||||
"vm management",
|
||||
"unix socket",
|
||||
"cloud native"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
43
package.json
Normal file
43
package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@push.rocks/smartvm",
|
||||
"version": "1.0.0",
|
||||
"private": false,
|
||||
"description": "A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs",
|
||||
"type": "module",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist_ts/index.js",
|
||||
"types": "./dist_ts/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "(tstest test/ --verbose)",
|
||||
"build": "(tsbuild --web --allowimplicitany)"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://code.foss.global/push.rocks/smartvm.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
"@push.rocks/smartexit": "^1.0.22",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartshell": "^3.2.3",
|
||||
"@push.rocks/smartunique": "^3.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.1.2",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tstest": "^3.1.8",
|
||||
"@types/node": "^25.2.2"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"dist_ts/**/*",
|
||||
"assets/**/*",
|
||||
"readme.md"
|
||||
]
|
||||
}
|
||||
8330
pnpm-lock.yaml
generated
Normal file
8330
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
readme.hints.md
Normal file
18
readme.hints.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Project Hints
|
||||
|
||||
## Architecture
|
||||
- Wraps Amazon Firecracker VMM using HTTP-over-Unix-socket API
|
||||
- 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/smartexit` for cleanup on process exit
|
||||
|
||||
## Key API Patterns
|
||||
- SmartRequest: `SmartRequest.create().url(...).json(body).put()` — response has `.status`, `.ok`, `.json()`
|
||||
- Smartshell: `shell.execStreaming(cmd)` returns `{ childProcess, terminate(), kill(), finalPromise }`
|
||||
- SmartExit: `smartExit.addProcess(child)`, `smartExit.addCleanupFunction(fn)`, `smartExit.killAll()`
|
||||
|
||||
## Firecracker API
|
||||
- Pre-boot config: PUT /boot-source, PUT /machine-config, PUT /drives/{id}, PUT /network-interfaces/{id}
|
||||
- Start: PUT /actions { action_type: "InstanceStart" }
|
||||
- Pause/Resume: PATCH /vm { state: "Paused" | "Resumed" }
|
||||
- Stop: PUT /actions { action_type: "SendCtrlAltDel" }
|
||||
488
readme.md
Normal file
488
readme.md
Normal file
@@ -0,0 +1,488 @@
|
||||
# @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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm install @push.rocks/smartvm
|
||||
```
|
||||
|
||||
> ⚡ **Prerequisites**: Firecracker requires a Linux host with KVM support (`/dev/kvm`). Networking features (TAP devices, bridges, NAT) require root privileges.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { SmartVM } from '@push.rocks/smartvm';
|
||||
|
||||
// 1. Create the orchestrator
|
||||
const smartvm = new SmartVM({
|
||||
dataDir: '/opt/smartvm', // where binaries, kernels, rootfs are cached
|
||||
firecrackerVersion: 'v1.7.0', // or omit for latest
|
||||
arch: 'x86_64',
|
||||
});
|
||||
|
||||
// 2. Download Firecracker if not already present
|
||||
await smartvm.ensureBinary();
|
||||
|
||||
// 3. Create a MicroVM
|
||||
const vm = await smartvm.createVM({
|
||||
bootSource: {
|
||||
kernelImagePath: '/opt/smartvm/kernels/vmlinux',
|
||||
bootArgs: 'console=ttyS0 reboot=k panic=1 pci=off',
|
||||
},
|
||||
machineConfig: {
|
||||
vcpuCount: 2,
|
||||
memSizeMib: 256,
|
||||
},
|
||||
drives: [
|
||||
{
|
||||
driveId: 'rootfs',
|
||||
pathOnHost: '/opt/smartvm/rootfs/ubuntu.ext4',
|
||||
isRootDevice: true,
|
||||
isReadOnly: false,
|
||||
},
|
||||
],
|
||||
networkInterfaces: [
|
||||
{ ifaceId: 'eth0' }, // TAP device and MAC auto-generated
|
||||
],
|
||||
});
|
||||
|
||||
// 4. Start it 🚀
|
||||
await vm.start();
|
||||
|
||||
// 5. Inspect
|
||||
console.log(vm.state); // 'running'
|
||||
console.log(await vm.getInfo()); // Firecracker instance info
|
||||
|
||||
// 6. Pause / Resume
|
||||
await vm.pause(); // state → 'paused'
|
||||
await vm.resume(); // state → 'running'
|
||||
|
||||
// 7. Stop and clean up
|
||||
await vm.stop();
|
||||
await vm.cleanup();
|
||||
await smartvm.cleanup();
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ SmartVM │ ← Top-level orchestrator
|
||||
│ ┌──────────────┐ ┌────────────────────┐ │
|
||||
│ │ ImageManager │ │ NetworkManager │ │
|
||||
│ │ (binaries, │ │ (TAP, bridge, │ │
|
||||
│ │ kernels, │ │ NAT, IP alloc) │ │
|
||||
│ │ rootfs) │ │ │ │
|
||||
│ └──────────────┘ └────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────── MicroVM ────────────────┐ │
|
||||
│ │ state: created → configuring → │ │
|
||||
│ │ running → paused → stopped │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────────────────────┐ │ │
|
||||
│ │ │ FirecrackerProcess │ │ │
|
||||
│ │ │ (child process management) │ │ │
|
||||
│ │ └──────────────────────────────┘ │ │
|
||||
│ │ ┌──────────────────────────────┐ │ │
|
||||
│ │ │ SocketClient │ │ │
|
||||
│ │ │ (HTTP over Unix socket) │ │ │
|
||||
│ │ └──────────────────────────────┘ │ │
|
||||
│ │ ┌──────────────────────────────┐ │ │
|
||||
│ │ │ VMConfig │ │ │
|
||||
│ │ │ (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.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SmartVM` — The Orchestrator
|
||||
|
||||
The entry point for everything. Manages binary downloads, VM creation, and global cleanup.
|
||||
|
||||
```typescript
|
||||
import { SmartVM } from '@push.rocks/smartvm';
|
||||
import type { ISmartVMOptions } from '@push.rocks/smartvm';
|
||||
|
||||
const smartvm = new SmartVM({
|
||||
dataDir: '/tmp/.smartvm', // default: /tmp/.smartvm
|
||||
firecrackerVersion: 'v1.7.0', // default: latest from GitHub
|
||||
arch: 'x86_64', // default: x86_64 (also: aarch64)
|
||||
firecrackerBinaryPath: '/usr/bin/firecracker', // optional: skip download
|
||||
bridgeName: 'svbr0', // default: svbr0
|
||||
subnet: '172.30.0.0/24', // default: 172.30.0.0/24
|
||||
});
|
||||
```
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `ensureBinary()` | Downloads Firecracker from GitHub if not cached. Returns path to binary. |
|
||||
| `createVM(config)` | Creates a `MicroVM` instance (not yet started). Returns the VM. |
|
||||
| `getVM(id)` | Look up an active VM by ID. |
|
||||
| `listVMs()` | Returns an array of active VM IDs. |
|
||||
| `vmCount` | Number of active VMs. |
|
||||
| `stopAll()` | Stops all running/paused VMs in parallel. |
|
||||
| `cleanup()` | Stops all VMs, removes TAP devices and bridge. |
|
||||
|
||||
### `MicroVM` — VM Lifecycle
|
||||
|
||||
Each VM follows a strict state machine: **created → configuring → running → paused → stopped**.
|
||||
|
||||
```typescript
|
||||
const vm = await smartvm.createVM({
|
||||
id: 'my-vm', // optional, auto-generated UUID if omitted
|
||||
bootSource: {
|
||||
kernelImagePath: '/path/to/vmlinux',
|
||||
bootArgs: 'console=ttyS0 reboot=k panic=1',
|
||||
initrdPath: '/path/to/initrd', // optional
|
||||
},
|
||||
machineConfig: {
|
||||
vcpuCount: 4,
|
||||
memSizeMib: 512,
|
||||
smt: false,
|
||||
cpuTemplate: 'T2', // optional: C3, T2, T2S, T2CL, T2A, V1N1
|
||||
trackDirtyPages: true,
|
||||
},
|
||||
drives: [
|
||||
{
|
||||
driveId: 'rootfs',
|
||||
pathOnHost: '/path/to/rootfs.ext4',
|
||||
isRootDevice: true,
|
||||
isReadOnly: false,
|
||||
cacheType: 'Unsafe', // or 'Writeback'
|
||||
rateLimiter: {
|
||||
bandwidth: { size: 100_000_000, refillTime: 1_000_000_000 },
|
||||
ops: { size: 1000, refillTime: 1_000_000_000 },
|
||||
},
|
||||
},
|
||||
],
|
||||
networkInterfaces: [
|
||||
{
|
||||
ifaceId: 'eth0',
|
||||
// hostDevName and guestMac auto-generated if omitted
|
||||
},
|
||||
],
|
||||
vsock: {
|
||||
guestCid: 3,
|
||||
udsPath: '/tmp/vsock.sock',
|
||||
},
|
||||
balloon: {
|
||||
amountMib: 128,
|
||||
deflateOnOom: true,
|
||||
statsPollingIntervalS: 5,
|
||||
},
|
||||
mmds: {
|
||||
version: 'V2',
|
||||
networkInterfaces: ['eth0'],
|
||||
},
|
||||
logger: {
|
||||
logPath: '/tmp/firecracker.log',
|
||||
level: 'Debug',
|
||||
showLogOrigin: true,
|
||||
},
|
||||
metrics: {
|
||||
metricsPath: '/tmp/firecracker-metrics.fifo',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
| Method | Valid States | Description |
|
||||
|---|---|---|
|
||||
| `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
|
||||
|
||||
Handles downloading and caching Firecracker binaries, kernels, and rootfs images.
|
||||
|
||||
```typescript
|
||||
const imageManager = smartvm.imageManager;
|
||||
|
||||
// Auto-download the latest Firecracker release
|
||||
const version = await imageManager.getLatestVersion(); // e.g. 'v1.7.0'
|
||||
const binaryPath = await imageManager.downloadFirecracker(version);
|
||||
|
||||
// Download kernel and rootfs images
|
||||
const kernelPath = await imageManager.downloadKernel(
|
||||
'https://example.com/vmlinux-5.10',
|
||||
'vmlinux-5.10',
|
||||
);
|
||||
const rootfsPath = await imageManager.downloadRootfs(
|
||||
'https://example.com/ubuntu-22.04.ext4',
|
||||
'ubuntu-22.04.ext4',
|
||||
);
|
||||
|
||||
// Create a blank rootfs or clone an existing one
|
||||
const blankPath = await imageManager.createBlankRootfs('scratch.ext4', 1024);
|
||||
const clonePath = await imageManager.cloneRootfs(rootfsPath, 'ubuntu-clone.ext4');
|
||||
```
|
||||
|
||||
**Data directory layout:**
|
||||
|
||||
```
|
||||
/tmp/.smartvm/
|
||||
bin/<version>/firecracker
|
||||
bin/<version>/jailer
|
||||
kernels/<name>
|
||||
rootfs/<name>
|
||||
sockets/<vmId>.sock
|
||||
```
|
||||
|
||||
### `NetworkManager` — Host Networking
|
||||
|
||||
Automatically manages TAP devices, a Linux bridge, and iptables NAT masquerade rules so VMs get internet access out of the box.
|
||||
|
||||
```typescript
|
||||
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
|
||||
import { VMConfig } from '@push.rocks/smartvm';
|
||||
|
||||
const vmConfig = new VMConfig({
|
||||
bootSource: { kernelImagePath: '/path/to/vmlinux' },
|
||||
machineConfig: { vcpuCount: 2, memSizeMib: 256 },
|
||||
});
|
||||
|
||||
// Validate
|
||||
const result = vmConfig.validate();
|
||||
// { valid: true, errors: [] }
|
||||
|
||||
// Generate API payloads
|
||||
vmConfig.toBootSourcePayload();
|
||||
// { kernel_image_path: '/path/to/vmlinux' }
|
||||
|
||||
vmConfig.toMachineConfigPayload();
|
||||
// { vcpu_count: 2, mem_size_mib: 256 }
|
||||
```
|
||||
|
||||
### `SocketClient` — Low-Level HTTP Client
|
||||
|
||||
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.
|
||||
|
||||
```typescript
|
||||
import { SocketClient } from '@push.rocks/smartvm';
|
||||
|
||||
const client = new SocketClient({ socketPath: '/tmp/firecracker.sock' });
|
||||
|
||||
const info = await client.get('/');
|
||||
const putResult = await client.put('/machine-config', { vcpu_count: 2, mem_size_mib: 256 });
|
||||
const patchResult = await client.patch('/vm', { state: 'Paused' });
|
||||
|
||||
// Check if socket is alive (polls with timeout)
|
||||
const ready = await client.isReady(5000);
|
||||
```
|
||||
|
||||
### `SmartVMError` — Error Handling
|
||||
|
||||
All errors thrown by this module are `SmartVMError` instances with structured error codes.
|
||||
|
||||
```typescript
|
||||
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
|
||||
const vm = await smartvm.createVM({
|
||||
bootSource: { /* ... */ },
|
||||
machineConfig: { /* ... */ },
|
||||
networkInterfaces: [{ ifaceId: 'eth0' }],
|
||||
mmds: {
|
||||
version: 'V2',
|
||||
networkInterfaces: ['eth0'],
|
||||
},
|
||||
});
|
||||
|
||||
await vm.start();
|
||||
|
||||
// Set metadata from host
|
||||
await vm.setMetadata({
|
||||
instance: { id: 'my-instance', region: 'eu-central-1' },
|
||||
secrets: { apiKey: 'sk-...' },
|
||||
});
|
||||
|
||||
// Guest can access via: curl http://169.254.169.254/latest/meta-data/
|
||||
const data = await vm.getMetadata();
|
||||
```
|
||||
|
||||
## Graceful Cleanup
|
||||
|
||||
The module registers cleanup handlers via `@push.rocks/smartexit` so resources are released even if your process crashes:
|
||||
|
||||
- 🔌 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
|
||||
// Stop one VM
|
||||
await vm.stop();
|
||||
await vm.cleanup();
|
||||
|
||||
// Stop all VMs and clean everything
|
||||
await smartvm.stopAll();
|
||||
await smartvm.cleanup();
|
||||
```
|
||||
|
||||
## TypeScript Interfaces
|
||||
|
||||
All configuration interfaces are fully exported for type-safe usage:
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
ISmartVMOptions,
|
||||
IMicroVMConfig,
|
||||
IBootSource,
|
||||
IMachineConfig,
|
||||
IDriveConfig,
|
||||
INetworkInterfaceConfig,
|
||||
IVsockConfig,
|
||||
IBalloonConfig,
|
||||
IMmdsConfig,
|
||||
ILoggerConfig,
|
||||
IMetricsConfig,
|
||||
ISnapshotCreateParams,
|
||||
ISnapshotLoadParams,
|
||||
IRateLimiter,
|
||||
INetworkManagerOptions,
|
||||
ITapDevice,
|
||||
ISocketClientOptions,
|
||||
IApiResponse,
|
||||
TVMState,
|
||||
TFirecrackerArch,
|
||||
TCacheType,
|
||||
TSnapshotType,
|
||||
TLogLevel,
|
||||
} from '@push.rocks/smartvm';
|
||||
```
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
254
test/test.ts
Normal file
254
test/test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
VMConfig,
|
||||
SocketClient,
|
||||
NetworkManager,
|
||||
SmartVM,
|
||||
} from '../ts/index.js';
|
||||
import type { IMicroVMConfig } from '../ts/index.js';
|
||||
|
||||
// ============================================================
|
||||
// VMConfig Tests
|
||||
// ============================================================
|
||||
|
||||
const sampleConfig: IMicroVMConfig = {
|
||||
id: 'test-vm-1',
|
||||
bootSource: {
|
||||
kernelImagePath: '/path/to/vmlinux',
|
||||
bootArgs: 'console=ttyS0 reboot=k panic=1',
|
||||
},
|
||||
machineConfig: {
|
||||
vcpuCount: 2,
|
||||
memSizeMib: 256,
|
||||
smt: false,
|
||||
},
|
||||
drives: [
|
||||
{
|
||||
driveId: 'rootfs',
|
||||
pathOnHost: '/path/to/rootfs.ext4',
|
||||
isRootDevice: true,
|
||||
isReadOnly: false,
|
||||
cacheType: 'Unsafe',
|
||||
},
|
||||
],
|
||||
networkInterfaces: [
|
||||
{
|
||||
ifaceId: 'eth0',
|
||||
hostDevName: 'tap0',
|
||||
guestMac: '02:00:00:00:00:01',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
tap.test('VMConfig - validate() should pass for valid config', async () => {
|
||||
const vmConfig = new VMConfig(sampleConfig);
|
||||
const result = vmConfig.validate();
|
||||
expect(result.valid).toBeTrue();
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
tap.test('VMConfig - validate() should fail for missing bootSource', async () => {
|
||||
const vmConfig = new VMConfig({
|
||||
machineConfig: { vcpuCount: 1, memSizeMib: 128 },
|
||||
} as any);
|
||||
const result = vmConfig.validate();
|
||||
expect(result.valid).toBeFalse();
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('VMConfig - validate() should fail for invalid vcpuCount', async () => {
|
||||
const vmConfig = new VMConfig({
|
||||
bootSource: { kernelImagePath: '/vmlinux' },
|
||||
machineConfig: { vcpuCount: 0, memSizeMib: 128 },
|
||||
});
|
||||
const result = vmConfig.validate();
|
||||
expect(result.valid).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('VMConfig - validate() should fail for vcpuCount > 32', async () => {
|
||||
const vmConfig = new VMConfig({
|
||||
bootSource: { kernelImagePath: '/vmlinux' },
|
||||
machineConfig: { vcpuCount: 64, memSizeMib: 128 },
|
||||
});
|
||||
const result = vmConfig.validate();
|
||||
expect(result.valid).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('VMConfig - validate() should fail for multiple root drives', async () => {
|
||||
const vmConfig = new VMConfig({
|
||||
bootSource: { kernelImagePath: '/vmlinux' },
|
||||
machineConfig: { vcpuCount: 1, memSizeMib: 128 },
|
||||
drives: [
|
||||
{ driveId: 'rootfs1', pathOnHost: '/rootfs1', isRootDevice: true },
|
||||
{ driveId: 'rootfs2', pathOnHost: '/rootfs2', isRootDevice: true },
|
||||
],
|
||||
});
|
||||
const result = vmConfig.validate();
|
||||
expect(result.valid).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('VMConfig - toBootSourcePayload() should generate correct snake_case', async () => {
|
||||
const vmConfig = new VMConfig(sampleConfig);
|
||||
const payload = vmConfig.toBootSourcePayload();
|
||||
expect(payload.kernel_image_path).toEqual('/path/to/vmlinux');
|
||||
expect(payload.boot_args).toEqual('console=ttyS0 reboot=k panic=1');
|
||||
expect(payload.initrd_path).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('VMConfig - toMachineConfigPayload() should generate correct payload', async () => {
|
||||
const vmConfig = new VMConfig(sampleConfig);
|
||||
const payload = vmConfig.toMachineConfigPayload();
|
||||
expect(payload.vcpu_count).toEqual(2);
|
||||
expect(payload.mem_size_mib).toEqual(256);
|
||||
expect(payload.smt).toEqual(false);
|
||||
});
|
||||
|
||||
tap.test('VMConfig - toDrivePayload() should generate correct payload', async () => {
|
||||
const vmConfig = new VMConfig(sampleConfig);
|
||||
const payload = vmConfig.toDrivePayload(sampleConfig.drives![0]);
|
||||
expect(payload.drive_id).toEqual('rootfs');
|
||||
expect(payload.path_on_host).toEqual('/path/to/rootfs.ext4');
|
||||
expect(payload.is_root_device).toEqual(true);
|
||||
expect(payload.is_read_only).toEqual(false);
|
||||
expect(payload.cache_type).toEqual('Unsafe');
|
||||
});
|
||||
|
||||
tap.test('VMConfig - toNetworkInterfacePayload() should generate correct payload', async () => {
|
||||
const vmConfig = new VMConfig(sampleConfig);
|
||||
const payload = vmConfig.toNetworkInterfacePayload(sampleConfig.networkInterfaces![0]);
|
||||
expect(payload.iface_id).toEqual('eth0');
|
||||
expect(payload.host_dev_name).toEqual('tap0');
|
||||
expect(payload.guest_mac).toEqual('02:00:00:00:00:01');
|
||||
});
|
||||
|
||||
tap.test('VMConfig - toVsockPayload() should return null when no vsock configured', async () => {
|
||||
const vmConfig = new VMConfig(sampleConfig);
|
||||
expect(vmConfig.toVsockPayload()).toBeNull();
|
||||
});
|
||||
|
||||
tap.test('VMConfig - toBalloonPayload() should return null when no balloon configured', async () => {
|
||||
const vmConfig = new VMConfig(sampleConfig);
|
||||
expect(vmConfig.toBalloonPayload()).toBeNull();
|
||||
});
|
||||
|
||||
tap.test('VMConfig - toBalloonPayload() should generate correct payload', async () => {
|
||||
const config: IMicroVMConfig = {
|
||||
...sampleConfig,
|
||||
balloon: { amountMib: 64, deflateOnOom: true, statsPollingIntervalS: 5 },
|
||||
};
|
||||
const vmConfig = new VMConfig(config);
|
||||
const payload = vmConfig.toBalloonPayload();
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload!.amount_mib).toEqual(64);
|
||||
expect(payload!.deflate_on_oom).toEqual(true);
|
||||
expect(payload!.stats_polling_interval_s).toEqual(5);
|
||||
});
|
||||
|
||||
tap.test('VMConfig - toLoggerPayload() should generate correct payload', async () => {
|
||||
const config: IMicroVMConfig = {
|
||||
...sampleConfig,
|
||||
logger: { logPath: '/tmp/fc.log', level: 'Debug', showLogOrigin: true },
|
||||
};
|
||||
const vmConfig = new VMConfig(config);
|
||||
const payload = vmConfig.toLoggerPayload();
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload!.log_path).toEqual('/tmp/fc.log');
|
||||
expect(payload!.level).toEqual('Debug');
|
||||
expect(payload!.show_log_origin).toEqual(true);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// SocketClient Tests
|
||||
// ============================================================
|
||||
|
||||
tap.test('SocketClient - URL construction', async () => {
|
||||
const client = new SocketClient({ socketPath: '/tmp/test.sock' });
|
||||
// We can't directly access buildUrl, but we can verify the client instantiates
|
||||
expect(client).toBeTruthy();
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// NetworkManager Tests
|
||||
// ============================================================
|
||||
|
||||
tap.test('NetworkManager - allocateIp() should allocate sequential IPs', async () => {
|
||||
const nm = new NetworkManager({ subnet: '172.30.0.0/24' });
|
||||
const ip1 = nm.allocateIp();
|
||||
const ip2 = nm.allocateIp();
|
||||
const ip3 = nm.allocateIp();
|
||||
expect(ip1).toEqual('172.30.0.2');
|
||||
expect(ip2).toEqual('172.30.0.3');
|
||||
expect(ip3).toEqual('172.30.0.4');
|
||||
});
|
||||
|
||||
tap.test('NetworkManager - generateMac() should generate locally-administered MACs', async () => {
|
||||
const nm = new NetworkManager();
|
||||
const mac1 = nm.generateMac('vm1', 'eth0');
|
||||
const mac2 = nm.generateMac('vm2', 'eth0');
|
||||
const mac3 = nm.generateMac('vm1', 'eth0');
|
||||
|
||||
// Should start with 02: (locally administered)
|
||||
expect(mac1.startsWith('02:')).toBeTrue();
|
||||
expect(mac2.startsWith('02:')).toBeTrue();
|
||||
|
||||
// Same inputs should produce same MAC (deterministic)
|
||||
expect(mac1).toEqual(mac3);
|
||||
|
||||
// Different inputs should produce different MACs
|
||||
expect(mac1).not.toEqual(mac2);
|
||||
|
||||
// Should be valid MAC format (6 hex pairs separated by colons)
|
||||
expect(mac1).toMatch(/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/);
|
||||
});
|
||||
|
||||
tap.test('NetworkManager - generateTapName() should respect IFNAMSIZ limit', async () => {
|
||||
const nm = new NetworkManager();
|
||||
const name1 = nm.generateTapName('abcdefgh-1234-5678', 'eth0');
|
||||
const name2 = nm.generateTapName('short', 'eth0');
|
||||
|
||||
// Should not exceed 15 characters
|
||||
expect(name1.length).toBeLessThanOrEqual(15);
|
||||
expect(name2.length).toBeLessThanOrEqual(15);
|
||||
|
||||
// Should start with 'sv' prefix
|
||||
expect(name1.startsWith('sv')).toBeTrue();
|
||||
expect(name2.startsWith('sv')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('NetworkManager - getGuestNetworkBootArgs() should format correctly', async () => {
|
||||
const nm = new NetworkManager();
|
||||
const tap = {
|
||||
tapName: 'svtest0eth0',
|
||||
guestIp: '172.30.0.2',
|
||||
gatewayIp: '172.30.0.1',
|
||||
subnetMask: '255.255.255.0',
|
||||
mac: '02:00:00:00:00:01',
|
||||
};
|
||||
const bootArgs = nm.getGuestNetworkBootArgs(tap);
|
||||
expect(bootArgs).toEqual('ip=172.30.0.2::172.30.0.1:255.255.255.0::eth0:off');
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// SmartVM Tests
|
||||
// ============================================================
|
||||
|
||||
tap.test('SmartVM - instantiation with defaults', async () => {
|
||||
const smartvm = new SmartVM();
|
||||
expect(smartvm).toBeTruthy();
|
||||
expect(smartvm.imageManager).toBeTruthy();
|
||||
expect(smartvm.networkManager).toBeTruthy();
|
||||
expect(smartvm.vmCount).toEqual(0);
|
||||
expect(smartvm.listVMs()).toHaveLength(0);
|
||||
});
|
||||
|
||||
tap.test('SmartVM - instantiation with custom options', async () => {
|
||||
const smartvm = new SmartVM({
|
||||
dataDir: '/tmp/smartvm-test',
|
||||
arch: 'aarch64',
|
||||
bridgeName: 'testbr0',
|
||||
subnet: '10.0.0.0/24',
|
||||
});
|
||||
expect(smartvm).toBeTruthy();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
8
ts/00_commitinfo_data.ts
Normal file
8
ts/00_commitinfo_data.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* autocreated commitance data during CI/CD
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartvm',
|
||||
version: '1.0.0',
|
||||
description: 'A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs',
|
||||
};
|
||||
141
ts/classes.firecrackerprocess.ts
Normal file
141
ts/classes.firecrackerprocess.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import type { IFirecrackerProcessOptions } from './interfaces/index.js';
|
||||
import { SmartVMError } from './interfaces/index.js';
|
||||
import { SocketClient } from './classes.socketclient.js';
|
||||
|
||||
/**
|
||||
* Manages a single Firecracker child process, including startup, readiness polling, and shutdown.
|
||||
*/
|
||||
export class FirecrackerProcess {
|
||||
private options: IFirecrackerProcessOptions;
|
||||
private streaming: any | null = null;
|
||||
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
||||
private smartExitInstance: InstanceType<typeof plugins.smartexit.SmartExit> | null = null;
|
||||
public socketClient: SocketClient;
|
||||
|
||||
constructor(options: IFirecrackerProcessOptions) {
|
||||
this.options = options;
|
||||
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||
this.socketClient = new SocketClient({ socketPath: options.socketPath });
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the Firecracker process and wait for the API socket to become ready.
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
// Remove any stale socket file
|
||||
if (plugins.fs.existsSync(this.options.socketPath)) {
|
||||
plugins.fs.unlinkSync(this.options.socketPath);
|
||||
}
|
||||
|
||||
// Build the command
|
||||
let cmd = `${this.options.binaryPath} --api-sock ${this.options.socketPath}`;
|
||||
if (this.options.logLevel) {
|
||||
cmd += ` --level ${this.options.logLevel}`;
|
||||
}
|
||||
|
||||
// Spawn the process
|
||||
this.streaming = await this.shell.execStreaming(cmd, true);
|
||||
|
||||
// Register with smartexit for automatic cleanup
|
||||
if (this.streaming?.childProcess) {
|
||||
this.smartExitInstance = new plugins.smartexit.SmartExit({ silent: true });
|
||||
this.smartExitInstance.addProcess(this.streaming.childProcess);
|
||||
}
|
||||
|
||||
// Wait for the socket file to appear
|
||||
const socketReady = await this.waitForSocket(10000);
|
||||
if (!socketReady) {
|
||||
await this.stop();
|
||||
throw new SmartVMError(
|
||||
'Firecracker socket did not become ready within timeout',
|
||||
'SOCKET_TIMEOUT',
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for the API to be responsive
|
||||
const apiReady = await this.socketClient.isReady(5000);
|
||||
if (!apiReady) {
|
||||
await this.stop();
|
||||
throw new SmartVMError(
|
||||
'Firecracker API did not become responsive within timeout',
|
||||
'API_TIMEOUT',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for the socket file to appear on disk.
|
||||
*/
|
||||
private async waitForSocket(timeoutMs: number): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (plugins.fs.existsSync(this.options.socketPath)) {
|
||||
return true;
|
||||
}
|
||||
await plugins.smartdelay.delayFor(100);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully stop the Firecracker process with SIGTERM, then SIGKILL after timeout.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.streaming) return;
|
||||
|
||||
try {
|
||||
// Try graceful termination first
|
||||
await this.streaming.terminate();
|
||||
|
||||
// Wait up to 5 seconds for the process to exit
|
||||
const exitPromise = Promise.race([
|
||||
this.streaming.finalPromise,
|
||||
plugins.smartdelay.delayFor(5000),
|
||||
]);
|
||||
await exitPromise;
|
||||
} catch {
|
||||
// If termination fails, force kill
|
||||
try {
|
||||
await this.streaming.kill();
|
||||
} catch {
|
||||
// Process may already be dead
|
||||
}
|
||||
}
|
||||
|
||||
this.streaming = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up the socket file.
|
||||
*/
|
||||
public async cleanup(): Promise<void> {
|
||||
await this.stop();
|
||||
|
||||
// Remove the socket file
|
||||
if (plugins.fs.existsSync(this.options.socketPath)) {
|
||||
plugins.fs.unlinkSync(this.options.socketPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the process is currently running.
|
||||
*/
|
||||
public isRunning(): boolean {
|
||||
if (!this.streaming?.childProcess) return false;
|
||||
try {
|
||||
// Sending signal 0 tests if process exists without actually sending a signal
|
||||
process.kill(this.streaming.childProcess.pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the child process PID.
|
||||
*/
|
||||
public getPid(): number | null {
|
||||
return this.streaming?.childProcess?.pid ?? null;
|
||||
}
|
||||
}
|
||||
244
ts/classes.imagemanager.ts
Normal file
244
ts/classes.imagemanager.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import type { TFirecrackerArch } from './interfaces/index.js';
|
||||
import { SmartVMError } from './interfaces/index.js';
|
||||
|
||||
/**
|
||||
* Helper to check if a file or directory exists.
|
||||
*/
|
||||
async function pathExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await plugins.fs.promises.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages Firecracker binaries, kernel images, and rootfs images.
|
||||
* Downloads and caches them in a local data directory.
|
||||
*/
|
||||
export class ImageManager {
|
||||
private dataDir: string;
|
||||
private arch: TFirecrackerArch;
|
||||
|
||||
constructor(dataDir: string, arch: TFirecrackerArch = 'x86_64') {
|
||||
this.dataDir = dataDir;
|
||||
this.arch = arch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all required directories exist.
|
||||
*/
|
||||
public async ensureDirectories(): Promise<void> {
|
||||
const dirs = [
|
||||
this.getBinDir(),
|
||||
this.getKernelsDir(),
|
||||
this.getRootfsDir(),
|
||||
this.getSocketsDir(),
|
||||
];
|
||||
for (const dir of dirs) {
|
||||
await plugins.fs.promises.mkdir(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Base directory for firecracker binaries. */
|
||||
public getBinDir(): string {
|
||||
return plugins.path.join(this.dataDir, 'bin');
|
||||
}
|
||||
|
||||
/** Directory for kernel images. */
|
||||
public getKernelsDir(): string {
|
||||
return plugins.path.join(this.dataDir, 'kernels');
|
||||
}
|
||||
|
||||
/** Directory for rootfs images. */
|
||||
public getRootfsDir(): string {
|
||||
return plugins.path.join(this.dataDir, 'rootfs');
|
||||
}
|
||||
|
||||
/** Directory for Unix sockets. */
|
||||
public getSocketsDir(): string {
|
||||
return plugins.path.join(this.dataDir, 'sockets');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the firecracker binary for a given version.
|
||||
*/
|
||||
public getFirecrackerPath(version: string): string {
|
||||
return plugins.path.join(this.getBinDir(), version, 'firecracker');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the jailer binary for a given version.
|
||||
*/
|
||||
public getJailerPath(version: string): string {
|
||||
return plugins.path.join(this.getBinDir(), version, 'jailer');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Firecracker binary for the given version exists.
|
||||
*/
|
||||
public async hasBinary(version: string): Promise<boolean> {
|
||||
const binPath = this.getFirecrackerPath(version);
|
||||
return pathExists(binPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the GitHub API for the latest Firecracker release version tag.
|
||||
*/
|
||||
public async getLatestVersion(): Promise<string> {
|
||||
try {
|
||||
const response = await plugins.SmartRequest.create()
|
||||
.url('https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest')
|
||||
.get();
|
||||
const data = await response.json() as { tag_name: string };
|
||||
return data.tag_name;
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`Failed to fetch latest Firecracker version: ${(err as Error).message}`,
|
||||
'VERSION_FETCH_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and extract the Firecracker binary for a given version.
|
||||
*/
|
||||
public async downloadFirecracker(version: string): Promise<string> {
|
||||
const targetDir = plugins.path.join(this.getBinDir(), version);
|
||||
await plugins.fs.promises.mkdir(targetDir, { recursive: true });
|
||||
|
||||
// Firecracker release tarball naming:
|
||||
// firecracker-v1.5.0-x86_64.tgz containing release-v1.5.0-x86_64/firecracker-v1.5.0-x86_64
|
||||
const tag = version.startsWith('v') ? version : `v${version}`;
|
||||
const archiveName = `firecracker-${tag}-${this.arch}.tgz`;
|
||||
const downloadUrl = `https://github.com/firecracker-microvm/firecracker/releases/download/${tag}/${archiveName}`;
|
||||
|
||||
const archivePath = plugins.path.join(targetDir, archiveName);
|
||||
|
||||
try {
|
||||
// Download the archive
|
||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||
await shell.exec(`curl -fSL -o "${archivePath}" "${downloadUrl}"`);
|
||||
|
||||
// Extract the archive
|
||||
await shell.exec(`tar -xzf "${archivePath}" -C "${targetDir}"`);
|
||||
|
||||
// Firecracker archives contain a directory like release-v1.5.0-x86_64/
|
||||
// with binaries named like firecracker-v1.5.0-x86_64
|
||||
const extractedDir = plugins.path.join(targetDir, `release-${tag}-${this.arch}`);
|
||||
const firecrackerSrc = plugins.path.join(extractedDir, `firecracker-${tag}-${this.arch}`);
|
||||
const jailerSrc = plugins.path.join(extractedDir, `jailer-${tag}-${this.arch}`);
|
||||
const firecrackerDst = this.getFirecrackerPath(version);
|
||||
const jailerDst = this.getJailerPath(version);
|
||||
|
||||
// Move binaries to expected paths
|
||||
await shell.exec(`mv "${firecrackerSrc}" "${firecrackerDst}"`);
|
||||
if (await pathExists(jailerSrc)) {
|
||||
await shell.exec(`mv "${jailerSrc}" "${jailerDst}"`);
|
||||
}
|
||||
|
||||
// Make executable
|
||||
await shell.exec(`chmod +x "${firecrackerDst}"`);
|
||||
|
||||
// Clean up
|
||||
await shell.exec(`rm -rf "${archivePath}" "${extractedDir}"`);
|
||||
|
||||
return firecrackerDst;
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`Failed to download Firecracker ${version}: ${(err as Error).message}`,
|
||||
'DOWNLOAD_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a kernel image from a URL.
|
||||
*/
|
||||
public async downloadKernel(url: string, name: string): Promise<string> {
|
||||
const kernelsDir = this.getKernelsDir();
|
||||
await plugins.fs.promises.mkdir(kernelsDir, { recursive: true });
|
||||
const kernelPath = plugins.path.join(kernelsDir, name);
|
||||
|
||||
try {
|
||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||
await shell.exec(`curl -fSL -o "${kernelPath}" "${url}"`);
|
||||
return kernelPath;
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`Failed to download kernel from ${url}: ${(err as Error).message}`,
|
||||
'DOWNLOAD_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a rootfs image from a URL.
|
||||
*/
|
||||
public async downloadRootfs(url: string, name: string): Promise<string> {
|
||||
const rootfsDir = this.getRootfsDir();
|
||||
await plugins.fs.promises.mkdir(rootfsDir, { recursive: true });
|
||||
const rootfsPath = plugins.path.join(rootfsDir, name);
|
||||
|
||||
try {
|
||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||
await shell.exec(`curl -fSL -o "${rootfsPath}" "${url}"`);
|
||||
return rootfsPath;
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`Failed to download rootfs from ${url}: ${(err as Error).message}`,
|
||||
'DOWNLOAD_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a blank ext4 rootfs image.
|
||||
*/
|
||||
public async createBlankRootfs(name: string, sizeMib: number): Promise<string> {
|
||||
const rootfsDir = this.getRootfsDir();
|
||||
await plugins.fs.promises.mkdir(rootfsDir, { recursive: true });
|
||||
const rootfsPath = plugins.path.join(rootfsDir, name);
|
||||
|
||||
try {
|
||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||
await shell.exec(`dd if=/dev/zero of="${rootfsPath}" bs=1M count=${sizeMib}`);
|
||||
await shell.exec(`mkfs.ext4 "${rootfsPath}"`);
|
||||
return rootfsPath;
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`Failed to create blank rootfs: ${(err as Error).message}`,
|
||||
'ROOTFS_CREATE_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a rootfs image for a specific VM.
|
||||
*/
|
||||
public async cloneRootfs(sourcePath: string, targetName: string): Promise<string> {
|
||||
const rootfsDir = this.getRootfsDir();
|
||||
await plugins.fs.promises.mkdir(rootfsDir, { recursive: true });
|
||||
const targetPath = plugins.path.join(rootfsDir, targetName);
|
||||
|
||||
try {
|
||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||
await shell.exec(`cp "${sourcePath}" "${targetPath}"`);
|
||||
return targetPath;
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`Failed to clone rootfs: ${(err as Error).message}`,
|
||||
'ROOTFS_CLONE_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique socket path for a VM.
|
||||
*/
|
||||
public getSocketPath(vmId: string): string {
|
||||
return plugins.path.join(this.getSocketsDir(), `${vmId}.sock`);
|
||||
}
|
||||
}
|
||||
367
ts/classes.microvm.ts
Normal file
367
ts/classes.microvm.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import type {
|
||||
TVMState,
|
||||
IMicroVMConfig,
|
||||
ISnapshotCreateParams,
|
||||
ISnapshotLoadParams,
|
||||
ITapDevice,
|
||||
} from './interfaces/index.js';
|
||||
import { SmartVMError } from './interfaces/index.js';
|
||||
import { VMConfig } from './classes.vmconfig.js';
|
||||
import { SocketClient } from './classes.socketclient.js';
|
||||
import { FirecrackerProcess } from './classes.firecrackerprocess.js';
|
||||
import { NetworkManager } from './classes.networkmanager.js';
|
||||
|
||||
/**
|
||||
* Represents a single Firecracker MicroVM with full lifecycle management.
|
||||
* State machine: created → configuring → running → paused → stopped
|
||||
*/
|
||||
export class MicroVM {
|
||||
public readonly id: string;
|
||||
public state: TVMState = 'created';
|
||||
|
||||
private vmConfig: VMConfig;
|
||||
private process: FirecrackerProcess | null = null;
|
||||
private socketClient: SocketClient | null = null;
|
||||
private networkManager: NetworkManager;
|
||||
private binaryPath: string;
|
||||
private socketPath: string;
|
||||
private tapDevices: ITapDevice[] = [];
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
config: IMicroVMConfig,
|
||||
binaryPath: string,
|
||||
socketPath: string,
|
||||
networkManager: NetworkManager,
|
||||
) {
|
||||
this.id = id;
|
||||
this.vmConfig = new VMConfig(config);
|
||||
this.binaryPath = binaryPath;
|
||||
this.socketPath = socketPath;
|
||||
this.networkManager = networkManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the VM is in one of the expected states.
|
||||
*/
|
||||
private assertState(expected: TVMState[], operation: string): void {
|
||||
if (!expected.includes(this.state)) {
|
||||
throw new SmartVMError(
|
||||
`Cannot ${operation}: VM is in state '${this.state}', expected one of [${expected.join(', ')}]`,
|
||||
'INVALID_STATE',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the MicroVM.
|
||||
* Validates config, starts the Firecracker process, applies pre-boot configuration, and boots the VM.
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
this.assertState(['created'], 'start');
|
||||
|
||||
// Validate configuration
|
||||
const validation = this.vmConfig.validate();
|
||||
if (!validation.valid) {
|
||||
throw new SmartVMError(
|
||||
`Invalid VM configuration: ${validation.errors.join('; ')}`,
|
||||
'INVALID_CONFIG',
|
||||
);
|
||||
}
|
||||
|
||||
this.state = 'configuring';
|
||||
|
||||
try {
|
||||
// Start the Firecracker process
|
||||
this.process = new FirecrackerProcess({
|
||||
binaryPath: this.binaryPath,
|
||||
socketPath: this.socketPath,
|
||||
});
|
||||
await this.process.start();
|
||||
this.socketClient = this.process.socketClient;
|
||||
|
||||
// Apply pre-boot configuration in order
|
||||
|
||||
// 1. Logger (optional, must be first)
|
||||
const loggerPayload = this.vmConfig.toLoggerPayload();
|
||||
if (loggerPayload) {
|
||||
await this.apiPut('/logger', loggerPayload);
|
||||
}
|
||||
|
||||
// 2. Metrics (optional)
|
||||
const metricsPayload = this.vmConfig.toMetricsPayload();
|
||||
if (metricsPayload) {
|
||||
await this.apiPut('/metrics', metricsPayload);
|
||||
}
|
||||
|
||||
// 3. Machine config
|
||||
await this.apiPut('/machine-config', this.vmConfig.toMachineConfigPayload());
|
||||
|
||||
// 4. Boot source
|
||||
await this.apiPut('/boot-source', this.vmConfig.toBootSourcePayload());
|
||||
|
||||
// 5. Drives
|
||||
if (this.vmConfig.config.drives) {
|
||||
for (const drive of this.vmConfig.config.drives) {
|
||||
const payload = this.vmConfig.toDrivePayload(drive);
|
||||
await this.apiPut(`/drives/${drive.driveId}`, payload);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Network interfaces
|
||||
if (this.vmConfig.config.networkInterfaces) {
|
||||
for (const iface of this.vmConfig.config.networkInterfaces) {
|
||||
// Create TAP device if hostDevName not manually specified
|
||||
if (!iface.hostDevName) {
|
||||
const tap = await this.networkManager.createTapDevice(this.id, iface.ifaceId);
|
||||
this.tapDevices.push(tap);
|
||||
iface.hostDevName = tap.tapName;
|
||||
if (!iface.guestMac) {
|
||||
iface.guestMac = tap.mac;
|
||||
}
|
||||
}
|
||||
const payload = this.vmConfig.toNetworkInterfacePayload(iface);
|
||||
await this.apiPut(`/network-interfaces/${iface.ifaceId}`, payload);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Vsock (optional)
|
||||
const vsockPayload = this.vmConfig.toVsockPayload();
|
||||
if (vsockPayload) {
|
||||
await this.apiPut('/vsock', vsockPayload);
|
||||
}
|
||||
|
||||
// 8. Balloon (optional)
|
||||
const balloonPayload = this.vmConfig.toBalloonPayload();
|
||||
if (balloonPayload) {
|
||||
await this.apiPut('/balloon', balloonPayload);
|
||||
}
|
||||
|
||||
// 9. MMDS config (optional)
|
||||
const mmdsPayload = this.vmConfig.toMmdsConfigPayload();
|
||||
if (mmdsPayload) {
|
||||
await this.apiPut('/mmds/config', mmdsPayload);
|
||||
}
|
||||
|
||||
// Boot the VM
|
||||
await this.apiPut('/actions', { action_type: 'InstanceStart' });
|
||||
|
||||
this.state = 'running';
|
||||
} catch (err) {
|
||||
this.state = 'error';
|
||||
// Clean up on failure
|
||||
await this.cleanup();
|
||||
if (err instanceof SmartVMError) {
|
||||
throw err;
|
||||
}
|
||||
throw new SmartVMError(
|
||||
`Failed to start VM ${this.id}: ${err.message}`,
|
||||
'START_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the running VM.
|
||||
*/
|
||||
public async pause(): Promise<void> {
|
||||
this.assertState(['running'], 'pause');
|
||||
await this.apiPatch('/vm', { state: 'Paused' });
|
||||
this.state = 'paused';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a paused VM.
|
||||
*/
|
||||
public async resume(): Promise<void> {
|
||||
this.assertState(['paused'], 'resume');
|
||||
await this.apiPatch('/vm', { state: 'Resumed' });
|
||||
this.state = 'running';
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the VM.
|
||||
* Sends SendCtrlAltDel first for graceful shutdown, then kills the process if needed.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
this.assertState(['running', 'paused'], 'stop');
|
||||
|
||||
try {
|
||||
// Try graceful shutdown via SendCtrlAltDel
|
||||
await this.apiPut('/actions', { action_type: 'SendCtrlAltDel' });
|
||||
// Wait a bit for the VM to shut down
|
||||
await plugins.smartdelay.delayFor(2000);
|
||||
} catch {
|
||||
// SendCtrlAltDel may fail if the VM is already stopping
|
||||
}
|
||||
|
||||
// Force stop the process
|
||||
if (this.process) {
|
||||
await this.process.stop();
|
||||
}
|
||||
|
||||
this.state = 'stopped';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a snapshot of the VM.
|
||||
*/
|
||||
public async createSnapshot(params: ISnapshotCreateParams): Promise<void> {
|
||||
this.assertState(['paused'], 'createSnapshot');
|
||||
await this.apiPut('/snapshot/create', {
|
||||
snapshot_path: params.snapshotPath,
|
||||
mem_file_path: params.memFilePath,
|
||||
snapshot_type: params.snapshotType || 'Full',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a snapshot into the VM.
|
||||
*/
|
||||
public async loadSnapshot(params: ISnapshotLoadParams): Promise<void> {
|
||||
this.assertState(['created', 'configuring'], 'loadSnapshot');
|
||||
await this.apiPut('/snapshot/load', {
|
||||
snapshot_path: params.snapshotPath,
|
||||
mem_file_path: params.memFilePath,
|
||||
enable_diff_snapshots: params.enableDiffSnapshots ?? false,
|
||||
resume_vm: params.resumeVm ?? false,
|
||||
});
|
||||
this.state = params.resumeVm ? 'running' : 'paused';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set MMDS metadata.
|
||||
*/
|
||||
public async setMetadata(data: Record<string, any>): Promise<void> {
|
||||
this.assertState(['running', 'paused'], 'setMetadata');
|
||||
await this.apiPut('/mmds', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MMDS metadata.
|
||||
*/
|
||||
public async getMetadata(): Promise<any> {
|
||||
this.assertState(['running', 'paused'], 'getMetadata');
|
||||
const response = await this.socketClient!.get('/mmds');
|
||||
return response.body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a drive on the running VM (hot-plug path update).
|
||||
*/
|
||||
public async updateDrive(driveId: string, pathOnHost: string): Promise<void> {
|
||||
this.assertState(['running', 'paused'], 'updateDrive');
|
||||
await this.apiPatch(`/drives/${driveId}`, {
|
||||
drive_id: driveId,
|
||||
path_on_host: pathOnHost,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a network interface rate limiter on the running VM.
|
||||
*/
|
||||
public async updateNetworkInterface(
|
||||
ifaceId: string,
|
||||
update: Record<string, any>,
|
||||
): Promise<void> {
|
||||
this.assertState(['running', 'paused'], 'updateNetworkInterface');
|
||||
await this.apiPatch(`/network-interfaces/${ifaceId}`, update);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update balloon device on the running VM.
|
||||
*/
|
||||
public async updateBalloon(amountMib: number): Promise<void> {
|
||||
this.assertState(['running', 'paused'], 'updateBalloon');
|
||||
await this.apiPatch('/balloon', { amount_mib: amountMib });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VM instance info.
|
||||
*/
|
||||
public async getInfo(): Promise<any> {
|
||||
const response = await this.socketClient!.get('/');
|
||||
return response.body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Firecracker version info.
|
||||
*/
|
||||
public async getVersion(): Promise<any> {
|
||||
const response = await this.socketClient!.get('/version');
|
||||
return response.body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the TAP devices associated with this VM.
|
||||
*/
|
||||
public getTapDevices(): ITapDevice[] {
|
||||
return [...this.tapDevices];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the VMConfig instance for payload inspection.
|
||||
*/
|
||||
public getVMConfig(): VMConfig {
|
||||
return this.vmConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full cleanup: stop process, remove socket, remove TAP devices.
|
||||
*/
|
||||
public async cleanup(): Promise<void> {
|
||||
// Stop process
|
||||
if (this.process) {
|
||||
await this.process.cleanup();
|
||||
this.process = null;
|
||||
}
|
||||
|
||||
// Remove TAP devices
|
||||
for (const tap of this.tapDevices) {
|
||||
await this.networkManager.removeTapDevice(tap.tapName);
|
||||
}
|
||||
this.tapDevices = [];
|
||||
|
||||
this.socketClient = null;
|
||||
if (this.state !== 'error') {
|
||||
this.state = 'stopped';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: PUT request with error handling.
|
||||
*/
|
||||
private async apiPut(path: string, body: Record<string, any>): Promise<void> {
|
||||
if (!this.socketClient) {
|
||||
throw new SmartVMError('Socket client not initialized', 'NO_CLIENT');
|
||||
}
|
||||
const response = await this.socketClient.put(path, body);
|
||||
if (!response.ok) {
|
||||
throw new SmartVMError(
|
||||
`API PUT ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`,
|
||||
'API_ERROR',
|
||||
response.statusCode,
|
||||
response.body,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: PATCH request with error handling.
|
||||
*/
|
||||
private async apiPatch(path: string, body: Record<string, any>): Promise<void> {
|
||||
if (!this.socketClient) {
|
||||
throw new SmartVMError('Socket client not initialized', 'NO_CLIENT');
|
||||
}
|
||||
const response = await this.socketClient.patch(path, body);
|
||||
if (!response.ok) {
|
||||
throw new SmartVMError(
|
||||
`API PATCH ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`,
|
||||
'API_ERROR',
|
||||
response.statusCode,
|
||||
response.body,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
237
ts/classes.networkmanager.ts
Normal file
237
ts/classes.networkmanager.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import type { INetworkManagerOptions, ITapDevice } from './interfaces/index.js';
|
||||
import { SmartVMError } from './interfaces/index.js';
|
||||
|
||||
/**
|
||||
* Manages host networking for Firecracker VMs.
|
||||
* Creates TAP devices, Linux bridges, and configures NAT for VM internet access.
|
||||
*/
|
||||
export class NetworkManager {
|
||||
private bridgeName: string;
|
||||
private subnetBase: string;
|
||||
private subnetCidr: number;
|
||||
private gatewayIp: string;
|
||||
private subnetMask: string;
|
||||
private nextIpOctet: number;
|
||||
private activeTaps: Map<string, ITapDevice> = new Map();
|
||||
private bridgeCreated: boolean = false;
|
||||
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
||||
|
||||
constructor(options: INetworkManagerOptions = {}) {
|
||||
this.bridgeName = options.bridgeName || 'svbr0';
|
||||
const subnet = options.subnet || '172.30.0.0/24';
|
||||
|
||||
// Parse the subnet
|
||||
const [baseIp, cidrStr] = subnet.split('/');
|
||||
this.subnetBase = baseIp;
|
||||
this.subnetCidr = parseInt(cidrStr, 10);
|
||||
this.subnetMask = this.cidrToSubnetMask(this.subnetCidr);
|
||||
|
||||
// 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' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a CIDR prefix length to a dotted-decimal subnet mask.
|
||||
*/
|
||||
private cidrToSubnetMask(cidr: number): string {
|
||||
const mask = (0xffffffff << (32 - cidr)) >>> 0;
|
||||
return [
|
||||
(mask >>> 24) & 0xff,
|
||||
(mask >>> 16) & 0xff,
|
||||
(mask >>> 8) & 0xff,
|
||||
mask & 0xff,
|
||||
].join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate the next available IP address in the subnet.
|
||||
*/
|
||||
public allocateIp(): string {
|
||||
const parts = this.subnetBase.split('.').map(Number);
|
||||
parts[3] = this.nextIpOctet;
|
||||
this.nextIpOctet++;
|
||||
return parts.join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a deterministic locally-administered MAC address.
|
||||
*/
|
||||
public generateMac(vmId: string, ifaceId: string): string {
|
||||
// Create a simple hash from vmId + ifaceId for deterministic MAC generation
|
||||
const input = `${vmId}:${ifaceId}`;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const char = input.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
|
||||
// Ensure hash is positive
|
||||
const h = Math.abs(hash);
|
||||
|
||||
// Generate MAC octets from hash, using locally-administered prefix (02:xx:xx:xx:xx:xx)
|
||||
const mac = [
|
||||
0x02,
|
||||
(h >> 0) & 0xff,
|
||||
(h >> 8) & 0xff,
|
||||
(h >> 16) & 0xff,
|
||||
(h >> 24) & 0xff,
|
||||
((h >> 4) ^ (h >> 12)) & 0xff,
|
||||
];
|
||||
|
||||
return mac.map((b) => b.toString(16).padStart(2, '0')).join(':');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a TAP device name that fits within IFNAMSIZ (15 chars).
|
||||
* Format: sv<4charVmId><ifaceId truncated>
|
||||
*/
|
||||
public generateTapName(vmId: string, ifaceId: string): string {
|
||||
const vmPart = vmId.replace(/-/g, '').substring(0, 4);
|
||||
const ifacePart = ifaceId.substring(0, 6);
|
||||
const tapName = `sv${vmPart}${ifacePart}`;
|
||||
// Ensure max 15 chars (Linux IFNAMSIZ)
|
||||
return tapName.substring(0, 15);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the Linux bridge is created and configured.
|
||||
*/
|
||||
public async ensureBridge(): Promise<void> {
|
||||
if (this.bridgeCreated) return;
|
||||
|
||||
try {
|
||||
// Check if bridge already exists
|
||||
const result = await this.shell.exec(`ip link show ${this.bridgeName} 2>/dev/null`);
|
||||
if (result.exitCode !== 0) {
|
||||
// Create bridge
|
||||
await this.shell.exec(`ip link add ${this.bridgeName} type bridge`);
|
||||
await this.shell.exec(`ip addr add ${this.gatewayIp}/${this.subnetCidr} dev ${this.bridgeName}`);
|
||||
await this.shell.exec(`ip link set ${this.bridgeName} up`);
|
||||
}
|
||||
|
||||
// Enable IP forwarding
|
||||
await this.shell.exec('sysctl -w net.ipv4.ip_forward=1');
|
||||
|
||||
// Set up NAT masquerade (idempotent with -C check)
|
||||
const checkResult = await this.shell.exec(
|
||||
`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`,
|
||||
);
|
||||
if (checkResult.exitCode !== 0) {
|
||||
await this.shell.exec(
|
||||
`iptables -t nat -A POSTROUTING -s ${this.subnetBase}/${this.subnetCidr} -o $(ip route | grep default | awk '{print $5}' | head -1) -j MASQUERADE`,
|
||||
);
|
||||
}
|
||||
|
||||
this.bridgeCreated = true;
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`Failed to set up network bridge: ${err.message}`,
|
||||
'BRIDGE_SETUP_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a TAP device for a VM and attach it to the bridge.
|
||||
*/
|
||||
public async createTapDevice(vmId: string, ifaceId: string): Promise<ITapDevice> {
|
||||
await this.ensureBridge();
|
||||
|
||||
const tapName = this.generateTapName(vmId, ifaceId);
|
||||
const guestIp = this.allocateIp();
|
||||
const mac = this.generateMac(vmId, ifaceId);
|
||||
|
||||
try {
|
||||
// Create TAP device
|
||||
await this.shell.exec(`ip tuntap add dev ${tapName} mode tap`);
|
||||
// Attach to bridge
|
||||
await this.shell.exec(`ip link set ${tapName} master ${this.bridgeName}`);
|
||||
// Bring TAP device up
|
||||
await this.shell.exec(`ip link set ${tapName} up`);
|
||||
|
||||
const tap: ITapDevice = {
|
||||
tapName,
|
||||
guestIp,
|
||||
gatewayIp: this.gatewayIp,
|
||||
subnetMask: this.subnetMask,
|
||||
mac,
|
||||
};
|
||||
|
||||
this.activeTaps.set(tapName, tap);
|
||||
return tap;
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`Failed to create TAP device ${tapName}: ${err.message}`,
|
||||
'TAP_CREATE_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a TAP device and free its resources.
|
||||
*/
|
||||
public async removeTapDevice(tapName: string): Promise<void> {
|
||||
try {
|
||||
await this.shell.exec(`ip link del ${tapName} 2>/dev/null`);
|
||||
this.activeTaps.delete(tapName);
|
||||
} catch {
|
||||
// Device may already be gone
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate kernel boot args for guest networking.
|
||||
* Returns the `ip=` parameter for the kernel command line.
|
||||
*/
|
||||
public getGuestNetworkBootArgs(tap: ITapDevice): string {
|
||||
// Format: ip=<client-ip>:<server-ip>:<gw-ip>:<netmask>::<device>:off
|
||||
return `ip=${tap.guestIp}::${tap.gatewayIp}:${tap.subnetMask}::eth0:off`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active TAP devices.
|
||||
*/
|
||||
public getActiveTaps(): ITapDevice[] {
|
||||
return Array.from(this.activeTaps.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all TAP devices and the bridge.
|
||||
*/
|
||||
public async cleanup(): Promise<void> {
|
||||
// Remove all TAP devices
|
||||
for (const tapName of this.activeTaps.keys()) {
|
||||
await this.removeTapDevice(tapName);
|
||||
}
|
||||
|
||||
// Remove bridge if we created it
|
||||
if (this.bridgeCreated) {
|
||||
try {
|
||||
await this.shell.exec(`ip link set ${this.bridgeName} down 2>/dev/null`);
|
||||
await this.shell.exec(`ip link del ${this.bridgeName} 2>/dev/null`);
|
||||
} catch {
|
||||
// Bridge may already be gone
|
||||
}
|
||||
|
||||
// Remove NAT rule
|
||||
try {
|
||||
await this.shell.exec(
|
||||
`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`,
|
||||
);
|
||||
} catch {
|
||||
// Rule may not exist
|
||||
}
|
||||
|
||||
this.bridgeCreated = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
182
ts/classes.smartvm.ts
Normal file
182
ts/classes.smartvm.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import type { ISmartVMOptions, IMicroVMConfig } from './interfaces/index.js';
|
||||
import { SmartVMError } from './interfaces/index.js';
|
||||
import { ImageManager } from './classes.imagemanager.js';
|
||||
import { NetworkManager } from './classes.networkmanager.js';
|
||||
import { MicroVM } from './classes.microvm.js';
|
||||
|
||||
/**
|
||||
* Top-level orchestrator for creating and managing Firecracker MicroVMs.
|
||||
*/
|
||||
export class SmartVM {
|
||||
private options: ISmartVMOptions;
|
||||
public imageManager: ImageManager;
|
||||
public networkManager: NetworkManager;
|
||||
private activeVMs: Map<string, MicroVM> = new Map();
|
||||
private smartExitInstance: InstanceType<typeof plugins.smartexit.SmartExit>;
|
||||
private firecrackerVersion: string | null = null;
|
||||
private firecrackerBinaryPath: string | null = null;
|
||||
|
||||
constructor(options: ISmartVMOptions = {}) {
|
||||
this.options = {
|
||||
dataDir: options.dataDir || '/tmp/.smartvm',
|
||||
arch: options.arch || 'x86_64',
|
||||
bridgeName: options.bridgeName || 'svbr0',
|
||||
subnet: options.subnet || '172.30.0.0/24',
|
||||
...options,
|
||||
};
|
||||
|
||||
this.imageManager = new ImageManager(this.options.dataDir!, this.options.arch);
|
||||
this.networkManager = new NetworkManager({
|
||||
bridgeName: this.options.bridgeName,
|
||||
subnet: this.options.subnet,
|
||||
});
|
||||
|
||||
// If a custom binary path is provided, use it directly
|
||||
if (this.options.firecrackerBinaryPath) {
|
||||
this.firecrackerBinaryPath = this.options.firecrackerBinaryPath;
|
||||
}
|
||||
|
||||
// Register global cleanup
|
||||
this.smartExitInstance = new plugins.smartexit.SmartExit({ silent: true });
|
||||
this.smartExitInstance.addCleanupFunction(async () => {
|
||||
await this.cleanup();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the Firecracker binary is available.
|
||||
* Downloads it if not present.
|
||||
*/
|
||||
public async ensureBinary(): Promise<string> {
|
||||
// If custom binary path is set, just verify it exists
|
||||
if (this.firecrackerBinaryPath) {
|
||||
try {
|
||||
await plugins.fs.promises.access(this.firecrackerBinaryPath);
|
||||
return this.firecrackerBinaryPath;
|
||||
} catch {
|
||||
// File doesn't exist, fall through to error
|
||||
}
|
||||
throw new SmartVMError(
|
||||
`Firecracker binary not found at ${this.firecrackerBinaryPath}`,
|
||||
'BINARY_NOT_FOUND',
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure data directories exist
|
||||
await this.imageManager.ensureDirectories();
|
||||
|
||||
// Determine version
|
||||
let version = this.options.firecrackerVersion;
|
||||
if (!version) {
|
||||
version = await this.imageManager.getLatestVersion();
|
||||
}
|
||||
this.firecrackerVersion = version;
|
||||
|
||||
// Check if binary exists
|
||||
if (await this.imageManager.hasBinary(version)) {
|
||||
this.firecrackerBinaryPath = this.imageManager.getFirecrackerPath(version);
|
||||
return this.firecrackerBinaryPath;
|
||||
}
|
||||
|
||||
// Download the binary
|
||||
this.firecrackerBinaryPath = await this.imageManager.downloadFirecracker(version);
|
||||
return this.firecrackerBinaryPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new MicroVM with the given configuration.
|
||||
* Returns the MicroVM instance (not yet started).
|
||||
*/
|
||||
public async createVM(config: IMicroVMConfig): Promise<MicroVM> {
|
||||
// Ensure binary is available
|
||||
if (!this.firecrackerBinaryPath) {
|
||||
await this.ensureBinary();
|
||||
}
|
||||
|
||||
// Generate VM ID if not provided
|
||||
const vmId = config.id || plugins.smartunique.uuid4();
|
||||
|
||||
// Generate socket path
|
||||
const socketPath = this.imageManager.getSocketPath(vmId);
|
||||
|
||||
// Create MicroVM instance
|
||||
const vm = new MicroVM(
|
||||
vmId,
|
||||
config,
|
||||
this.firecrackerBinaryPath!,
|
||||
socketPath,
|
||||
this.networkManager,
|
||||
);
|
||||
|
||||
// Register in active VMs
|
||||
this.activeVMs.set(vmId, vm);
|
||||
|
||||
return vm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an active VM by ID.
|
||||
*/
|
||||
public getVM(id: string): MicroVM | undefined {
|
||||
return this.activeVMs.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all active VM IDs.
|
||||
*/
|
||||
public listVMs(): string[] {
|
||||
return Array.from(this.activeVMs.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of active VMs.
|
||||
*/
|
||||
public get vmCount(): number {
|
||||
return this.activeVMs.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all running VMs.
|
||||
*/
|
||||
public async stopAll(): Promise<void> {
|
||||
const stopPromises: Promise<void>[] = [];
|
||||
for (const vm of this.activeVMs.values()) {
|
||||
if (vm.state === 'running' || vm.state === 'paused') {
|
||||
stopPromises.push(
|
||||
vm.stop().catch((err) => {
|
||||
console.error(`Failed to stop VM ${vm.id}: ${err.message}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
await Promise.all(stopPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all VMs and networking resources.
|
||||
*/
|
||||
public async cleanup(): Promise<void> {
|
||||
// Clean up all VMs
|
||||
const cleanupPromises: Promise<void>[] = [];
|
||||
for (const vm of this.activeVMs.values()) {
|
||||
cleanupPromises.push(
|
||||
vm.cleanup().catch((err) => {
|
||||
console.error(`Failed to clean up VM ${vm.id}: ${err.message}`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
await Promise.all(cleanupPromises);
|
||||
this.activeVMs.clear();
|
||||
|
||||
// Clean up networking
|
||||
await this.networkManager.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a VM from the active list (after it's been cleaned up).
|
||||
*/
|
||||
public removeVM(id: string): boolean {
|
||||
return this.activeVMs.delete(id);
|
||||
}
|
||||
}
|
||||
135
ts/classes.socketclient.ts
Normal file
135
ts/classes.socketclient.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import type { ISocketClientOptions, IApiResponse } from './interfaces/index.js';
|
||||
import { SmartVMError } from './interfaces/index.js';
|
||||
|
||||
/**
|
||||
* HTTP client that communicates with Firecracker over a Unix domain socket.
|
||||
* Uses @push.rocks/smartrequest with the `http://unix:<socket>:<path>` URL format.
|
||||
*/
|
||||
export class SocketClient {
|
||||
private socketPath: string;
|
||||
|
||||
constructor(options: ISocketClientOptions) {
|
||||
this.socketPath = options.socketPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Unix socket URL for a given API path.
|
||||
*/
|
||||
private buildUrl(apiPath: string): string {
|
||||
return `http://unix:${this.socketPath}:${apiPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a GET request.
|
||||
*/
|
||||
public async get<T = any>(apiPath: string): Promise<IApiResponse<T>> {
|
||||
const url = this.buildUrl(apiPath);
|
||||
try {
|
||||
const response = await plugins.SmartRequest.create()
|
||||
.url(url)
|
||||
.get();
|
||||
|
||||
const statusCode = response.status;
|
||||
let body: T;
|
||||
try {
|
||||
body = await response.json() as T;
|
||||
} catch {
|
||||
body = undefined as any;
|
||||
}
|
||||
return {
|
||||
statusCode,
|
||||
body,
|
||||
ok: response.ok,
|
||||
};
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`GET ${apiPath} failed: ${(err as Error).message}`,
|
||||
'SOCKET_REQUEST_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a PUT request with a JSON body.
|
||||
*/
|
||||
public async put<T = any>(apiPath: string, body?: Record<string, any>): Promise<IApiResponse<T>> {
|
||||
const url = this.buildUrl(apiPath);
|
||||
try {
|
||||
let request = plugins.SmartRequest.create().url(url);
|
||||
if (body !== undefined) {
|
||||
request = request.json(body);
|
||||
}
|
||||
const response = await request.put();
|
||||
|
||||
const statusCode = response.status;
|
||||
let responseBody: T;
|
||||
try {
|
||||
responseBody = await response.json() as T;
|
||||
} catch {
|
||||
responseBody = undefined as any;
|
||||
}
|
||||
return {
|
||||
statusCode,
|
||||
body: responseBody,
|
||||
ok: response.ok,
|
||||
};
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`PUT ${apiPath} failed: ${(err as Error).message}`,
|
||||
'SOCKET_REQUEST_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a PATCH request with a JSON body.
|
||||
*/
|
||||
public async patch<T = any>(apiPath: string, body?: Record<string, any>): Promise<IApiResponse<T>> {
|
||||
const url = this.buildUrl(apiPath);
|
||||
try {
|
||||
let request = plugins.SmartRequest.create().url(url);
|
||||
if (body !== undefined) {
|
||||
request = request.json(body);
|
||||
}
|
||||
const response = await request.patch();
|
||||
|
||||
const statusCode = response.status;
|
||||
let responseBody: T;
|
||||
try {
|
||||
responseBody = await response.json() as T;
|
||||
} catch {
|
||||
responseBody = undefined as any;
|
||||
}
|
||||
return {
|
||||
statusCode,
|
||||
body: responseBody,
|
||||
ok: response.ok,
|
||||
};
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`PATCH ${apiPath} failed: ${(err as Error).message}`,
|
||||
'SOCKET_REQUEST_FAILED',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the Firecracker API socket is ready by polling GET /.
|
||||
*/
|
||||
public async isReady(timeoutMs: number = 5000): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const response = await this.get('/');
|
||||
if (response.ok || response.statusCode === 200 || response.statusCode === 400) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Socket not ready yet
|
||||
}
|
||||
await plugins.smartdelay.delayFor(100);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
251
ts/classes.vmconfig.ts
Normal file
251
ts/classes.vmconfig.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import type {
|
||||
IMicroVMConfig,
|
||||
IDriveConfig,
|
||||
INetworkInterfaceConfig,
|
||||
IRateLimiter,
|
||||
} from './interfaces/index.js';
|
||||
|
||||
/**
|
||||
* Transforms a camelCase IMicroVMConfig into snake_case Firecracker API payloads.
|
||||
*/
|
||||
export class VMConfig {
|
||||
public config: IMicroVMConfig;
|
||||
|
||||
constructor(config: IMicroVMConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the configuration for required fields and constraints.
|
||||
*/
|
||||
public validate(): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!this.config.bootSource) {
|
||||
errors.push('bootSource is required');
|
||||
} else if (!this.config.bootSource.kernelImagePath) {
|
||||
errors.push('bootSource.kernelImagePath is required');
|
||||
}
|
||||
|
||||
if (!this.config.machineConfig) {
|
||||
errors.push('machineConfig is required');
|
||||
} else {
|
||||
if (!this.config.machineConfig.vcpuCount || this.config.machineConfig.vcpuCount < 1) {
|
||||
errors.push('machineConfig.vcpuCount must be at least 1');
|
||||
}
|
||||
if (this.config.machineConfig.vcpuCount > 32) {
|
||||
errors.push('machineConfig.vcpuCount must be at most 32');
|
||||
}
|
||||
if (!this.config.machineConfig.memSizeMib || this.config.machineConfig.memSizeMib < 1) {
|
||||
errors.push('machineConfig.memSizeMib must be at least 1');
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.drives) {
|
||||
const rootDrives = this.config.drives.filter((d) => d.isRootDevice);
|
||||
if (rootDrives.length > 1) {
|
||||
errors.push('Only one root drive is allowed');
|
||||
}
|
||||
for (const drive of this.config.drives) {
|
||||
if (!drive.driveId) {
|
||||
errors.push('Each drive must have a driveId');
|
||||
}
|
||||
if (!drive.pathOnHost) {
|
||||
errors.push(`Drive ${drive.driveId}: pathOnHost is required`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.vsock) {
|
||||
if (this.config.vsock.guestCid < 3) {
|
||||
errors.push('vsock.guestCid must be >= 3');
|
||||
}
|
||||
if (!this.config.vsock.udsPath) {
|
||||
errors.push('vsock.udsPath is required');
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the boot source PUT payload.
|
||||
*/
|
||||
public toBootSourcePayload(): Record<string, any> {
|
||||
const bs = this.config.bootSource;
|
||||
const payload: Record<string, any> = {
|
||||
kernel_image_path: bs.kernelImagePath,
|
||||
};
|
||||
if (bs.bootArgs !== undefined) {
|
||||
payload.boot_args = bs.bootArgs;
|
||||
}
|
||||
if (bs.initrdPath !== undefined) {
|
||||
payload.initrd_path = bs.initrdPath;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the machine config PUT payload.
|
||||
*/
|
||||
public toMachineConfigPayload(): Record<string, any> {
|
||||
const mc = this.config.machineConfig;
|
||||
const payload: Record<string, any> = {
|
||||
vcpu_count: mc.vcpuCount,
|
||||
mem_size_mib: mc.memSizeMib,
|
||||
};
|
||||
if (mc.smt !== undefined) {
|
||||
payload.smt = mc.smt;
|
||||
}
|
||||
if (mc.cpuTemplate !== undefined) {
|
||||
payload.cpu_template = mc.cpuTemplate;
|
||||
}
|
||||
if (mc.trackDirtyPages !== undefined) {
|
||||
payload.track_dirty_pages = mc.trackDirtyPages;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a drive PUT payload.
|
||||
*/
|
||||
public toDrivePayload(drive: IDriveConfig): Record<string, any> {
|
||||
const payload: Record<string, any> = {
|
||||
drive_id: drive.driveId,
|
||||
path_on_host: drive.pathOnHost,
|
||||
is_root_device: drive.isRootDevice,
|
||||
is_read_only: drive.isReadOnly ?? false,
|
||||
};
|
||||
if (drive.partUuid !== undefined) {
|
||||
payload.partuuid = drive.partUuid;
|
||||
}
|
||||
if (drive.cacheType !== undefined) {
|
||||
payload.cache_type = drive.cacheType;
|
||||
}
|
||||
if (drive.rateLimiter) {
|
||||
payload.rate_limiter = this.toRateLimiterPayload(drive.rateLimiter);
|
||||
}
|
||||
if (drive.ioEngine !== undefined) {
|
||||
payload.io_engine = drive.ioEngine;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a network interface PUT payload.
|
||||
*/
|
||||
public toNetworkInterfacePayload(iface: INetworkInterfaceConfig): Record<string, any> {
|
||||
const payload: Record<string, any> = {
|
||||
iface_id: iface.ifaceId,
|
||||
};
|
||||
if (iface.hostDevName !== undefined) {
|
||||
payload.host_dev_name = iface.hostDevName;
|
||||
}
|
||||
if (iface.guestMac !== undefined) {
|
||||
payload.guest_mac = iface.guestMac;
|
||||
}
|
||||
if (iface.rxRateLimiter) {
|
||||
payload.rx_rate_limiter = this.toRateLimiterPayload(iface.rxRateLimiter);
|
||||
}
|
||||
if (iface.txRateLimiter) {
|
||||
payload.tx_rate_limiter = this.toRateLimiterPayload(iface.txRateLimiter);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the vsock PUT payload.
|
||||
*/
|
||||
public toVsockPayload(): Record<string, any> | null {
|
||||
if (!this.config.vsock) return null;
|
||||
return {
|
||||
guest_cid: this.config.vsock.guestCid,
|
||||
uds_path: this.config.vsock.udsPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the balloon PUT payload.
|
||||
*/
|
||||
public toBalloonPayload(): Record<string, any> | null {
|
||||
if (!this.config.balloon) return null;
|
||||
const payload: Record<string, any> = {
|
||||
amount_mib: this.config.balloon.amountMib,
|
||||
deflate_on_oom: this.config.balloon.deflateOnOom,
|
||||
};
|
||||
if (this.config.balloon.statsPollingIntervalS !== undefined) {
|
||||
payload.stats_polling_interval_s = this.config.balloon.statsPollingIntervalS;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the MMDS config PUT payload.
|
||||
*/
|
||||
public toMmdsConfigPayload(): Record<string, any> | null {
|
||||
if (!this.config.mmds) return null;
|
||||
const payload: Record<string, any> = {
|
||||
network_interfaces: this.config.mmds.networkInterfaces,
|
||||
};
|
||||
if (this.config.mmds.version !== undefined) {
|
||||
payload.version = this.config.mmds.version;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the logger PUT payload.
|
||||
*/
|
||||
public toLoggerPayload(): Record<string, any> | null {
|
||||
if (!this.config.logger) return null;
|
||||
const payload: Record<string, any> = {
|
||||
log_path: this.config.logger.logPath,
|
||||
};
|
||||
if (this.config.logger.level !== undefined) {
|
||||
payload.level = this.config.logger.level;
|
||||
}
|
||||
if (this.config.logger.showLevel !== undefined) {
|
||||
payload.show_level = this.config.logger.showLevel;
|
||||
}
|
||||
if (this.config.logger.showLogOrigin !== undefined) {
|
||||
payload.show_log_origin = this.config.logger.showLogOrigin;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the metrics PUT payload.
|
||||
*/
|
||||
public toMetricsPayload(): Record<string, any> | null {
|
||||
if (!this.config.metrics) return null;
|
||||
return {
|
||||
metrics_path: this.config.metrics.metricsPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a rate limiter config to a Firecracker API payload.
|
||||
*/
|
||||
private toRateLimiterPayload(rl: IRateLimiter): Record<string, any> {
|
||||
const payload: Record<string, any> = {};
|
||||
if (rl.bandwidth) {
|
||||
payload.bandwidth = {
|
||||
size: rl.bandwidth.size,
|
||||
refill_time: rl.bandwidth.refillTime,
|
||||
};
|
||||
if (rl.bandwidth.oneTimeBurst !== undefined) {
|
||||
payload.bandwidth.one_time_burst = rl.bandwidth.oneTimeBurst;
|
||||
}
|
||||
}
|
||||
if (rl.ops) {
|
||||
payload.ops = {
|
||||
size: rl.ops.size,
|
||||
refill_time: rl.ops.refillTime,
|
||||
};
|
||||
if (rl.ops.oneTimeBurst !== undefined) {
|
||||
payload.ops.one_time_burst = rl.ops.oneTimeBurst;
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
8
ts/index.ts
Normal file
8
ts/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './interfaces/index.js';
|
||||
export { VMConfig } from './classes.vmconfig.js';
|
||||
export { SocketClient } from './classes.socketclient.js';
|
||||
export { ImageManager } from './classes.imagemanager.js';
|
||||
export { FirecrackerProcess } from './classes.firecrackerprocess.js';
|
||||
export { NetworkManager } from './classes.networkmanager.js';
|
||||
export { MicroVM } from './classes.microvm.js';
|
||||
export { SmartVM } from './classes.smartvm.js';
|
||||
48
ts/interfaces/api.ts
Normal file
48
ts/interfaces/api.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Options for the SocketClient.
|
||||
*/
|
||||
export interface ISocketClientOptions {
|
||||
/** Path to the Firecracker Unix domain socket. */
|
||||
socketPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized API response from the socket client.
|
||||
*/
|
||||
export interface IApiResponse<T = any> {
|
||||
/** HTTP status code. */
|
||||
statusCode: number;
|
||||
/** Parsed response body. */
|
||||
body: T;
|
||||
/** Whether the request was successful (2xx). */
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for spawning a Firecracker process.
|
||||
*/
|
||||
export interface IFirecrackerProcessOptions {
|
||||
/** Path to the firecracker binary. */
|
||||
binaryPath: string;
|
||||
/** Path for the API Unix domain socket. */
|
||||
socketPath: string;
|
||||
/** Log level for Firecracker. */
|
||||
logLevel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error class for SmartVM operations.
|
||||
*/
|
||||
export class SmartVMError extends Error {
|
||||
public code: string;
|
||||
public statusCode?: number;
|
||||
public details?: any;
|
||||
|
||||
constructor(message: string, code: string, statusCode?: number, details?: any) {
|
||||
super(message);
|
||||
this.name = 'SmartVMError';
|
||||
this.code = code;
|
||||
this.statusCode = statusCode;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
24
ts/interfaces/common.ts
Normal file
24
ts/interfaces/common.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* State machine states for a MicroVM lifecycle.
|
||||
*/
|
||||
export type TVMState = 'created' | 'configuring' | 'running' | 'paused' | 'stopped' | 'error';
|
||||
|
||||
/**
|
||||
* Supported Firecracker architectures.
|
||||
*/
|
||||
export type TFirecrackerArch = 'x86_64' | 'aarch64';
|
||||
|
||||
/**
|
||||
* Disk cache types supported by Firecracker.
|
||||
*/
|
||||
export type TCacheType = 'Unsafe' | 'Writeback';
|
||||
|
||||
/**
|
||||
* Snapshot types for creating snapshots.
|
||||
*/
|
||||
export type TSnapshotType = 'Full' | 'Diff';
|
||||
|
||||
/**
|
||||
* Log levels for Firecracker logger.
|
||||
*/
|
||||
export type TLogLevel = 'Error' | 'Warning' | 'Info' | 'Debug';
|
||||
235
ts/interfaces/config.ts
Normal file
235
ts/interfaces/config.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import type { TFirecrackerArch, TCacheType, TSnapshotType, TLogLevel } from './common.js';
|
||||
|
||||
/**
|
||||
* Top-level options for the SmartVM orchestrator.
|
||||
*/
|
||||
export interface ISmartVMOptions {
|
||||
/** Directory for storing binaries, kernels, rootfs images, and sockets. Defaults to /tmp/.smartvm */
|
||||
dataDir?: string;
|
||||
/** Firecracker version to use. Defaults to latest. */
|
||||
firecrackerVersion?: string;
|
||||
/** Target architecture. Defaults to x86_64. */
|
||||
arch?: TFirecrackerArch;
|
||||
/** Custom path to firecracker binary (overrides version-based lookup). */
|
||||
firecrackerBinaryPath?: string;
|
||||
/** Network bridge name. Defaults to 'svbr0'. */
|
||||
bridgeName?: string;
|
||||
/** Network subnet in CIDR notation. Defaults to '172.30.0.0/24'. */
|
||||
subnet?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Firecracker boot source configuration.
|
||||
*/
|
||||
export interface IBootSource {
|
||||
/** Path to the kernel image on the host. */
|
||||
kernelImagePath: string;
|
||||
/** Kernel boot arguments. */
|
||||
bootArgs?: string;
|
||||
/** Path to initrd image (optional). */
|
||||
initrdPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Machine hardware configuration.
|
||||
*/
|
||||
export interface IMachineConfig {
|
||||
/** Number of vCPUs (1-32). */
|
||||
vcpuCount: number;
|
||||
/** Memory size in MiB. */
|
||||
memSizeMib: number;
|
||||
/** Enable SMT (simultaneous multi-threading). Defaults to false. */
|
||||
smt?: boolean;
|
||||
/** Enable CPU template for security (C3, T2, T2S, T2CL, T2A, V1N1, None). */
|
||||
cpuTemplate?: string;
|
||||
/** Whether to track dirty pages for incremental snapshots. */
|
||||
trackDirtyPages?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiter configuration for drives and network interfaces.
|
||||
*/
|
||||
export interface IRateLimiter {
|
||||
/** Bandwidth limit. */
|
||||
bandwidth?: {
|
||||
size: number;
|
||||
oneTimeBurst?: number;
|
||||
refillTime: number;
|
||||
};
|
||||
/** Operations per second limit. */
|
||||
ops?: {
|
||||
size: number;
|
||||
oneTimeBurst?: number;
|
||||
refillTime: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Block device (drive) configuration.
|
||||
*/
|
||||
export interface IDriveConfig {
|
||||
/** Unique drive identifier. */
|
||||
driveId: string;
|
||||
/** Path to the disk image on the host. */
|
||||
pathOnHost: string;
|
||||
/** Whether this is the root device. */
|
||||
isRootDevice: boolean;
|
||||
/** Whether the drive is read-only. */
|
||||
isReadOnly?: boolean;
|
||||
/** Partition UUID (optional). */
|
||||
partUuid?: string;
|
||||
/** Cache type (Unsafe or Writeback). */
|
||||
cacheType?: TCacheType;
|
||||
/** Rate limiter for the drive. */
|
||||
rateLimiter?: IRateLimiter;
|
||||
/** Path to a file that backs the device for I/O. */
|
||||
ioEngine?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Network interface configuration.
|
||||
*/
|
||||
export interface INetworkInterfaceConfig {
|
||||
/** Unique interface identifier (e.g., 'eth0'). */
|
||||
ifaceId: string;
|
||||
/** TAP device name on the host. Automatically set by NetworkManager if not provided. */
|
||||
hostDevName?: string;
|
||||
/** Guest MAC address. Automatically generated if not provided. */
|
||||
guestMac?: string;
|
||||
/** Rate limiter for RX traffic. */
|
||||
rxRateLimiter?: IRateLimiter;
|
||||
/** Rate limiter for TX traffic. */
|
||||
txRateLimiter?: IRateLimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vsock device configuration.
|
||||
*/
|
||||
export interface IVsockConfig {
|
||||
/** Guest CID (Context Identifier). Must be >= 3. */
|
||||
guestCid: number;
|
||||
/** Path to the Unix domain socket on the host. */
|
||||
udsPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Balloon device configuration for dynamic memory management.
|
||||
*/
|
||||
export interface IBalloonConfig {
|
||||
/** Target balloon size in MiB. */
|
||||
amountMib: number;
|
||||
/** Whether to deflate on OOM. */
|
||||
deflateOnOom: boolean;
|
||||
/** Polling interval for balloon stats in seconds. */
|
||||
statsPollingIntervalS?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* MMDS (Microvm Metadata Service) configuration.
|
||||
*/
|
||||
export interface IMmdsConfig {
|
||||
/** MMDS version (V1 or V2). */
|
||||
version?: 'V1' | 'V2';
|
||||
/** Network interfaces that MMDS traffic is allowed on. */
|
||||
networkInterfaces: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger configuration for Firecracker.
|
||||
*/
|
||||
export interface ILoggerConfig {
|
||||
/** Path to the log file. */
|
||||
logPath: string;
|
||||
/** Log level. */
|
||||
level?: TLogLevel;
|
||||
/** Whether to show log origin (file, line). */
|
||||
showLevel?: boolean;
|
||||
/** Whether to show log level. */
|
||||
showLogOrigin?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metrics configuration for Firecracker.
|
||||
*/
|
||||
export interface IMetricsConfig {
|
||||
/** Path to the metrics file (FIFO). */
|
||||
metricsPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot creation parameters.
|
||||
*/
|
||||
export interface ISnapshotCreateParams {
|
||||
/** Path to save the snapshot file. */
|
||||
snapshotPath: string;
|
||||
/** Path to save the memory file. */
|
||||
memFilePath: string;
|
||||
/** Snapshot type (Full or Diff). */
|
||||
snapshotType?: TSnapshotType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot loading parameters.
|
||||
*/
|
||||
export interface ISnapshotLoadParams {
|
||||
/** Path to the snapshot file. */
|
||||
snapshotPath: string;
|
||||
/** Path to the memory file. */
|
||||
memFilePath: string;
|
||||
/** Whether to enable diff snapshots after loading. */
|
||||
enableDiffSnapshots?: boolean;
|
||||
/** Whether to resume the VM after loading. */
|
||||
resumeVm?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete MicroVM configuration combining all sub-configs.
|
||||
*/
|
||||
export interface IMicroVMConfig {
|
||||
/** Unique VM identifier. Auto-generated if not provided. */
|
||||
id?: string;
|
||||
/** Boot source configuration (required). */
|
||||
bootSource: IBootSource;
|
||||
/** Machine hardware configuration (required). */
|
||||
machineConfig: IMachineConfig;
|
||||
/** Block devices. */
|
||||
drives?: IDriveConfig[];
|
||||
/** Network interfaces. */
|
||||
networkInterfaces?: INetworkInterfaceConfig[];
|
||||
/** Vsock device. */
|
||||
vsock?: IVsockConfig;
|
||||
/** Balloon device. */
|
||||
balloon?: IBalloonConfig;
|
||||
/** MMDS configuration. */
|
||||
mmds?: IMmdsConfig;
|
||||
/** Logger configuration. */
|
||||
logger?: ILoggerConfig;
|
||||
/** Metrics configuration. */
|
||||
metrics?: IMetricsConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the NetworkManager.
|
||||
*/
|
||||
export interface INetworkManagerOptions {
|
||||
/** Bridge device name. Defaults to 'svbr0'. */
|
||||
bridgeName?: string;
|
||||
/** Subnet in CIDR notation. Defaults to '172.30.0.0/24'. */
|
||||
subnet?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a TAP device created by the NetworkManager.
|
||||
*/
|
||||
export interface ITapDevice {
|
||||
/** TAP device name on the host. */
|
||||
tapName: string;
|
||||
/** IP address assigned to the guest. */
|
||||
guestIp: string;
|
||||
/** Gateway IP (bridge IP). */
|
||||
gatewayIp: string;
|
||||
/** Subnet mask. */
|
||||
subnetMask: string;
|
||||
/** MAC address for the guest. */
|
||||
mac: string;
|
||||
}
|
||||
3
ts/interfaces/index.ts
Normal file
3
ts/interfaces/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './common.js';
|
||||
export * from './config.js';
|
||||
export * from './api.js';
|
||||
21
ts/plugins.ts
Normal file
21
ts/plugins.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// node native
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
export { fs, path, os };
|
||||
|
||||
// @push.rocks scope
|
||||
import * as smartdelay from '@push.rocks/smartdelay';
|
||||
import * as smartexit from '@push.rocks/smartexit';
|
||||
import { SmartRequest } from '@push.rocks/smartrequest';
|
||||
import * as smartshell from '@push.rocks/smartshell';
|
||||
import * as smartunique from '@push.rocks/smartunique';
|
||||
|
||||
export {
|
||||
smartdelay,
|
||||
smartexit,
|
||||
SmartRequest,
|
||||
smartshell,
|
||||
smartunique,
|
||||
};
|
||||
12
tsconfig.json
Normal file
12
tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"exclude": [
|
||||
"dist_*/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user