feat(base-images): add managed base image bundles with cache retention, hosted manifests, and opt-in integration boot testing
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
# SmartVM Base Image Bundles
|
||||||
|
|
||||||
|
This directory documents the project-owned base image manifest format. The actual kernel and rootfs binaries should be hosted as release assets or in object storage, not committed to git.
|
||||||
|
|
||||||
|
## Bundle Layout
|
||||||
|
|
||||||
|
A hosted bundle should expose three files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
smartvm-minimal-v1-x86_64.manifest.json
|
||||||
|
vmlinux
|
||||||
|
rootfs.ext4
|
||||||
|
```
|
||||||
|
|
||||||
|
The manifest is the only file shape `smartvm` needs to know. It points at the hosted kernel and rootfs artifacts and records checksums.
|
||||||
|
|
||||||
|
## Manifest Fields
|
||||||
|
|
||||||
|
- `schemaVersion`: currently `1`
|
||||||
|
- `bundleId`: stable cache key, using letters, numbers, dot, underscore, and dash only
|
||||||
|
- `arch`: `x86_64` or `aarch64`
|
||||||
|
- `firecrackerVersion`: Firecracker version validated with this bundle
|
||||||
|
- `rootfsType`: `ext4` or `squashfs`
|
||||||
|
- `rootfsIsReadOnly`: use `true` for squashfs or immutable rootfs images
|
||||||
|
- `bootArgs`: kernel boot args to use with the bundle
|
||||||
|
- `kernel`: hosted kernel artifact URL/path plus `sha256` for URL artifacts and optional `sizeBytes`
|
||||||
|
- `rootfs`: hosted rootfs artifact URL/path plus `sha256` for URL artifacts and optional `sizeBytes`
|
||||||
|
- `fileName`: optional plain output filename; path separators are rejected
|
||||||
|
|
||||||
|
`sha256` is required for hosted URL artifacts. `sizeBytes` is optional but helps catch incomplete downloads.
|
||||||
|
|
||||||
|
## Cache Behavior
|
||||||
|
|
||||||
|
Downloaded bundles are cached under `/tmp/.smartvm/base-images` by default. The cache keeps two bundles unless `maxStoredBaseImages` is configured. Eviction is announced with `console.warn`.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"bundleId": "smartvm-minimal-v1-x86_64",
|
||||||
|
"name": "SmartVM minimal x86_64 bundle",
|
||||||
|
"arch": "x86_64",
|
||||||
|
"firecrackerVersion": "v1.15.1",
|
||||||
|
"rootfsType": "ext4",
|
||||||
|
"rootfsIsReadOnly": false,
|
||||||
|
"bootArgs": "console=ttyS0 reboot=k panic=1 pci=off",
|
||||||
|
"kernel": {
|
||||||
|
"url": "https://assets.example.com/push.rocks/smartvm/base-images/smartvm-minimal-v1/x86_64/vmlinux",
|
||||||
|
"fileName": "vmlinux",
|
||||||
|
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"sizeBytes": 12345678
|
||||||
|
},
|
||||||
|
"rootfs": {
|
||||||
|
"url": "https://assets.example.com/push.rocks/smartvm/base-images/smartvm-minimal-v1/x86_64/rootfs.ext4",
|
||||||
|
"fileName": "rootfs.ext4",
|
||||||
|
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"sizeBytes": 12345678
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-05-01 - 1.2.0 - feat(base-images)
|
||||||
|
add managed base image bundles with cache retention, hosted manifests, and opt-in integration boot testing
|
||||||
|
|
||||||
|
- introduces BaseImageManager with support for Firecracker CI presets and hosted manifest-based kernel/rootfs bundles
|
||||||
|
- adds SmartVM.ensureBaseImage() and exports new base image types and manager APIs
|
||||||
|
- validates and verifies downloaded base image artifacts with checksums and bounded cache eviction
|
||||||
|
- hardens process, socket, network, and config handling with safer spawning, subnet/interface validation, and expanded tests
|
||||||
|
|
||||||
## 2026-04-30 - 1.1.1 - fix(build)
|
## 2026-04-30 - 1.1.1 - fix(build)
|
||||||
tighten TypeScript compiler settings and harden error message handling
|
tighten TypeScript compiler settings and harden error message handling
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
- Uses `@push.rocks/smartrequest` with URL format `http://unix:<socket>:<path>` for socket communication
|
- Uses `@push.rocks/smartrequest` with URL format `http://unix:<socket>:<path>` for socket communication
|
||||||
- Uses `@push.rocks/smartshell` `execStreaming()` to manage Firecracker child processes
|
- Uses `@push.rocks/smartshell` `execStreaming()` to manage Firecracker child processes
|
||||||
- Uses `@push.rocks/smartexit` for cleanup on process exit
|
- Uses `@push.rocks/smartexit` for cleanup on process exit
|
||||||
|
- `BaseImageManager` downloads Firecracker CI demo artifacts or hosted project manifests into `/tmp/.smartvm/base-images` by default
|
||||||
|
- Base image cache keeps 2 bundles by default and warns before evicting older bundles
|
||||||
|
- Hosted manifest examples live in `assets/base-images/`
|
||||||
|
|
||||||
## Key API Patterns
|
## Key API Patterns
|
||||||
- SmartRequest: `SmartRequest.create().url(...).json(body).put()` — response has `.status`, `.ok`, `.json()`
|
- SmartRequest: `SmartRequest.create().url(...).json(body).put()` — response has `.status`, `.ok`, `.json()`
|
||||||
@@ -16,3 +19,9 @@
|
|||||||
- Start: PUT /actions { action_type: "InstanceStart" }
|
- Start: PUT /actions { action_type: "InstanceStart" }
|
||||||
- Pause/Resume: PATCH /vm { state: "Paused" | "Resumed" }
|
- Pause/Resume: PATCH /vm { state: "Paused" | "Resumed" }
|
||||||
- Stop: PUT /actions { action_type: "SendCtrlAltDel" }
|
- Stop: PUT /actions { action_type: "SendCtrlAltDel" }
|
||||||
|
|
||||||
|
## Integration Testing
|
||||||
|
- Default `pnpm test` skips real Firecracker boot testing
|
||||||
|
- Set `SMARTVM_RUN_INTEGRATION=true` to run the opt-in boot test
|
||||||
|
- `SMARTVM_BASE_IMAGE_PRESET` supports `latest` and `lts`; default is `latest`
|
||||||
|
- Hosted/project-owned bundles use `baseImageManifestUrl`, `baseImageManifestPath`, `manifestUrl`, or `manifestPath`
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ 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.
|
> ⚡ **Prerequisites**: Firecracker requires a Linux host with KVM support (`/dev/kvm`). Networking features (TAP devices, bridges, NAT) require root privileges.
|
||||||
|
|
||||||
|
Runtime host requirements:
|
||||||
|
|
||||||
|
- Linux with `/dev/kvm` available to the running process
|
||||||
|
- A Firecracker binary downloaded by `ensureBinary()` or supplied through `firecrackerBinaryPath`
|
||||||
|
- Root privileges for automatic bridge, TAP, IP forwarding, and iptables NAT setup
|
||||||
|
- Host tools available for networking: `ip`, `sysctl`, and `iptables`
|
||||||
|
- IPv4 CIDR subnets with prefix length `1-30`; the bridge uses the first usable address as gateway and guests start at the second usable address
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@@ -118,6 +126,8 @@ const smartvm = new SmartVM({
|
|||||||
firecrackerVersion: 'v1.7.0', // default: latest from GitHub
|
firecrackerVersion: 'v1.7.0', // default: latest from GitHub
|
||||||
arch: 'x86_64', // default: x86_64 (also: aarch64)
|
arch: 'x86_64', // default: x86_64 (also: aarch64)
|
||||||
firecrackerBinaryPath: '/usr/bin/firecracker', // optional: skip download
|
firecrackerBinaryPath: '/usr/bin/firecracker', // optional: skip download
|
||||||
|
baseImageCacheDir: '/tmp/.smartvm/base-images', // default: /tmp/.smartvm/base-images
|
||||||
|
maxStoredBaseImages: 2, // default: keep at most 2 cached base image bundles
|
||||||
bridgeName: 'svbr0', // default: svbr0
|
bridgeName: 'svbr0', // default: svbr0
|
||||||
subnet: '172.30.0.0/24', // default: 172.30.0.0/24
|
subnet: '172.30.0.0/24', // default: 172.30.0.0/24
|
||||||
});
|
});
|
||||||
@@ -126,6 +136,7 @@ const smartvm = new SmartVM({
|
|||||||
| Method | Description |
|
| Method | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ensureBinary()` | Downloads Firecracker from GitHub if not cached. Returns path to binary. |
|
| `ensureBinary()` | Downloads Firecracker from GitHub if not cached. Returns path to binary. |
|
||||||
|
| `ensureBaseImage(options)` | Downloads/caches a Firecracker CI base image bundle. Defaults to the `latest` preset. |
|
||||||
| `createVM(config)` | Creates a `MicroVM` instance (not yet started). Returns the VM. |
|
| `createVM(config)` | Creates a `MicroVM` instance (not yet started). Returns the VM. |
|
||||||
| `getVM(id)` | Look up an active VM by ID. |
|
| `getVM(id)` | Look up an active VM by ID. |
|
||||||
| `listVMs()` | Returns an array of active VM IDs. |
|
| `listVMs()` | Returns an array of active VM IDs. |
|
||||||
@@ -249,6 +260,57 @@ const clonePath = await imageManager.cloneRootfs(rootfsPath, 'ubuntu-clone.ext4'
|
|||||||
sockets/<vmId>.sock
|
sockets/<vmId>.sock
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `BaseImageManager` — Base Images
|
||||||
|
|
||||||
|
Downloads known base image bundles into a `/tmp` cache for integration tests and quick local smoke tests. The default preset is `latest`; `lts` maps to a pinned Firecracker CI train (`v1.7`) for a stable fallback. Hosted project-owned manifests are also supported for pinned Alpine/BusyBox-style bundles.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const baseImage = await smartvm.ensureBaseImage(); // same as { preset: 'latest' }
|
||||||
|
|
||||||
|
const ltsBaseImage = await smartvm.ensureBaseImage({ preset: 'lts' });
|
||||||
|
|
||||||
|
const hostedBaseImage = await smartvm.ensureBaseImage({
|
||||||
|
manifestUrl: 'https://assets.example.com/push.rocks/smartvm/base-images/smartvm-minimal-v1/x86_64/manifest.json',
|
||||||
|
});
|
||||||
|
|
||||||
|
const vm = await smartvm.createVM({
|
||||||
|
bootSource: {
|
||||||
|
kernelImagePath: baseImage.kernelImagePath,
|
||||||
|
bootArgs: baseImage.bootArgs,
|
||||||
|
},
|
||||||
|
machineConfig: { vcpuCount: 1, memSizeMib: 256 },
|
||||||
|
drives: [
|
||||||
|
{
|
||||||
|
driveId: 'rootfs',
|
||||||
|
pathOnHost: baseImage.rootfsPath,
|
||||||
|
isRootDevice: true,
|
||||||
|
isReadOnly: baseImage.rootfsIsReadOnly,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cache behavior:**
|
||||||
|
|
||||||
|
- Default cache directory: `/tmp/.smartvm/base-images`
|
||||||
|
- Default retention: at most `2` base image bundles
|
||||||
|
- Configure retention with `maxStoredBaseImages`
|
||||||
|
- Configure location with `baseImageCacheDir`
|
||||||
|
- When a new download causes the retention limit to be exceeded, older bundles are removed and a console warning is emitted
|
||||||
|
- Downloaded bundles include a local `manifest.json` with source URLs/keys, file paths, sizes, and computed SHA256 hashes
|
||||||
|
|
||||||
|
Example configuration:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const smartvm = new SmartVM({
|
||||||
|
baseImageCacheDir: '/tmp/.smartvm/base-images',
|
||||||
|
maxStoredBaseImages: 4,
|
||||||
|
baseImageManifestUrl: 'https://assets.example.com/push.rocks/smartvm/base-images/smartvm-minimal-v1/x86_64/manifest.json',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Hosted manifest format examples live in `assets/base-images/`. Hosted URL artifacts require SHA256 hashes; `smartvm` verifies them during download before returning the bundle paths.
|
||||||
|
|
||||||
### `NetworkManager` — Host Networking
|
### `NetworkManager` — Host Networking
|
||||||
|
|
||||||
Automatically manages TAP devices, a Linux bridge, and iptables NAT masquerade rules so VMs get internet access out of the box.
|
Automatically manages TAP devices, a Linux bridge, and iptables NAT masquerade rules so VMs get internet access out of the box.
|
||||||
@@ -273,9 +335,10 @@ const bootArgs = networkManager.getGuestNetworkBootArgs(tap);
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Networking architecture:**
|
**Networking architecture:**
|
||||||
- Creates a Linux bridge (default: `svbr0`) with gateway at `.1`
|
- Creates a Linux bridge (default: `svbr0`) with gateway at the first usable subnet address
|
||||||
- Each VM gets a TAP device attached to the bridge
|
- Each VM gets a TAP device attached to the bridge
|
||||||
- Sequential IP allocation from `.2` onwards
|
- Sequential IP allocation from the second usable subnet address onwards
|
||||||
|
- Subnet input is normalized to the network address and allocation fails with `IP_EXHAUSTED` when no guest addresses remain
|
||||||
- iptables NAT masquerade for outbound internet
|
- iptables NAT masquerade for outbound internet
|
||||||
- Deterministic MAC generation (`02:xx:xx:xx:xx:xx` locally-administered)
|
- Deterministic MAC generation (`02:xx:xx:xx:xx:xx` locally-administered)
|
||||||
- TAP names fit Linux's 15-char IFNAMSIZ limit
|
- TAP names fit Linux's 15-char IFNAMSIZ limit
|
||||||
@@ -352,6 +415,14 @@ try {
|
|||||||
| `BINARY_NOT_FOUND` | Firecracker binary not at expected path |
|
| `BINARY_NOT_FOUND` | Firecracker binary not at expected path |
|
||||||
| `DOWNLOAD_FAILED` | Failed to download binary/kernel/rootfs |
|
| `DOWNLOAD_FAILED` | Failed to download binary/kernel/rootfs |
|
||||||
| `VERSION_FETCH_FAILED` | Couldn't query GitHub for latest version |
|
| `VERSION_FETCH_FAILED` | Couldn't query GitHub for latest version |
|
||||||
|
| `BASE_IMAGE_RESOLVE_FAILED` | Failed to resolve Firecracker CI base image artifacts |
|
||||||
|
| `BASE_IMAGE_MANIFEST_FAILED` | Failed to load or use a hosted base image manifest |
|
||||||
|
| `BASE_IMAGE_PREPARE_FAILED` | Failed to download or prepare a base image bundle |
|
||||||
|
| `INVALID_BASE_IMAGE_MANIFEST` | Hosted base image manifest is invalid |
|
||||||
|
| `INVALID_BASE_IMAGE_CACHE_LIMIT` | Base image cache retention limit is invalid |
|
||||||
|
| `INVALID_SUBNET` | Subnet is not a supported IPv4 CIDR range |
|
||||||
|
| `INVALID_INTERFACE_NAME` | Bridge or TAP interface name is invalid |
|
||||||
|
| `IP_EXHAUSTED` | No guest IP addresses remain in the configured subnet |
|
||||||
| `BRIDGE_SETUP_FAILED` | Failed to create network bridge |
|
| `BRIDGE_SETUP_FAILED` | Failed to create network bridge |
|
||||||
| `TAP_CREATE_FAILED` | Failed to create TAP device |
|
| `TAP_CREATE_FAILED` | Failed to create TAP device |
|
||||||
| `ROOTFS_CREATE_FAILED` | Failed to create blank rootfs |
|
| `ROOTFS_CREATE_FAILED` | Failed to create blank rootfs |
|
||||||
@@ -434,6 +505,36 @@ await smartvm.stopAll();
|
|||||||
await smartvm.cleanup();
|
await smartvm.cleanup();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The default test suite is unit-level and safe to run without KVM or root privileges:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
pnpm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
These tests cover config validation, Firecracker payload generation, lifecycle guard errors, VM tracking, and subnet/IP allocation. They do not boot a real microVM.
|
||||||
|
|
||||||
|
Real Firecracker boot testing should be run on a Linux/KVM host with the runtime requirements above. At minimum, verify `ensureBinary()`, `createVM()`, `start()`, `getInfo()`, `stop()`, and `cleanup()` against a known-good kernel and rootfs image before relying on a new host setup.
|
||||||
|
|
||||||
|
An opt-in integration test scaffold is included and skipped by default:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SMARTVM_RUN_INTEGRATION=true pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful integration-test environment variables:
|
||||||
|
|
||||||
|
- `SMARTVM_BASE_IMAGE_PRESET`: `latest` or `lts` (default: `latest`)
|
||||||
|
- `SMARTVM_BASE_IMAGE_MANIFEST_URL`: use a hosted/project-owned base image manifest instead of a preset
|
||||||
|
- `SMARTVM_BASE_IMAGE_MANIFEST_PATH`: use a local base image manifest instead of a preset
|
||||||
|
- `SMARTVM_BASE_IMAGE_CACHE_DIR`: override `/tmp/.smartvm/base-images`
|
||||||
|
- `SMARTVM_MAX_STORED_BASE_IMAGES`: override the default retention limit of `2`
|
||||||
|
- `SMARTVM_FIRECRACKER_VERSION`: override the Firecracker binary version; otherwise the base image's recommended version is used
|
||||||
|
- `SMARTVM_ARCH`: `x86_64` or `aarch64`; defaults from the host architecture
|
||||||
|
- `SMARTVM_INTEGRATION_DATA_DIR`: override the Firecracker binary/socket data directory
|
||||||
|
|
||||||
## TypeScript Interfaces
|
## TypeScript Interfaces
|
||||||
|
|
||||||
All configuration interfaces are fully exported for type-safe usage:
|
All configuration interfaces are fully exported for type-safe usage:
|
||||||
@@ -451,6 +552,11 @@ import type {
|
|||||||
IMmdsConfig,
|
IMmdsConfig,
|
||||||
ILoggerConfig,
|
ILoggerConfig,
|
||||||
IMetricsConfig,
|
IMetricsConfig,
|
||||||
|
IBaseImageManagerOptions,
|
||||||
|
IEnsureBaseImageOptions,
|
||||||
|
IBaseImageBundle,
|
||||||
|
IBaseImageHostedManifest,
|
||||||
|
IBaseImageArtifactManifest,
|
||||||
ISnapshotCreateParams,
|
ISnapshotCreateParams,
|
||||||
ISnapshotLoadParams,
|
ISnapshotLoadParams,
|
||||||
IRateLimiter,
|
IRateLimiter,
|
||||||
@@ -463,6 +569,8 @@ import type {
|
|||||||
TCacheType,
|
TCacheType,
|
||||||
TSnapshotType,
|
TSnapshotType,
|
||||||
TLogLevel,
|
TLogLevel,
|
||||||
|
TBaseImagePreset,
|
||||||
|
TBaseImageRootfsType,
|
||||||
} from '@push.rocks/smartvm';
|
} from '@push.rocks/smartvm';
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { BaseImageManager, SmartVM } from '../ts/index.js';
|
||||||
|
import type { TBaseImagePreset, TFirecrackerArch } from '../ts/index.js';
|
||||||
|
|
||||||
|
const integrationEnabled = ['1', 'true', 'yes'].includes(
|
||||||
|
(process.env.SMARTVM_RUN_INTEGRATION || '').toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
function getHostArch(): TFirecrackerArch {
|
||||||
|
return process.arch === 'arm64' ? 'aarch64' : 'x86_64';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertHostReady(): Promise<void> {
|
||||||
|
if (process.platform !== 'linux') {
|
||||||
|
throw new Error('Firecracker integration tests require Linux');
|
||||||
|
}
|
||||||
|
await fs.promises.access('/dev/kvm', fs.constants.R_OK | fs.constants.W_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
tap.test('SmartVM integration - boots a Firecracker CI base image when explicitly enabled', async () => {
|
||||||
|
if (!integrationEnabled) {
|
||||||
|
console.log('Skipping SmartVM integration test. Set SMARTVM_RUN_INTEGRATION=true to enable it.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertHostReady();
|
||||||
|
|
||||||
|
const arch = (process.env.SMARTVM_ARCH as TFirecrackerArch | undefined) || getHostArch();
|
||||||
|
const preset = (process.env.SMARTVM_BASE_IMAGE_PRESET as TBaseImagePreset | undefined) || 'latest';
|
||||||
|
const maxStoredBaseImages = process.env.SMARTVM_MAX_STORED_BASE_IMAGES
|
||||||
|
? Number(process.env.SMARTVM_MAX_STORED_BASE_IMAGES)
|
||||||
|
: undefined;
|
||||||
|
const baseImageManager = new BaseImageManager({
|
||||||
|
arch,
|
||||||
|
cacheDir: process.env.SMARTVM_BASE_IMAGE_CACHE_DIR,
|
||||||
|
maxStoredBaseImages,
|
||||||
|
hostedManifestUrl: process.env.SMARTVM_BASE_IMAGE_MANIFEST_URL,
|
||||||
|
hostedManifestPath: process.env.SMARTVM_BASE_IMAGE_MANIFEST_PATH,
|
||||||
|
});
|
||||||
|
const baseImage = await baseImageManager.ensureBaseImage({
|
||||||
|
preset,
|
||||||
|
manifestUrl: process.env.SMARTVM_BASE_IMAGE_MANIFEST_URL,
|
||||||
|
manifestPath: process.env.SMARTVM_BASE_IMAGE_MANIFEST_PATH,
|
||||||
|
});
|
||||||
|
|
||||||
|
const smartvm = new SmartVM({
|
||||||
|
arch,
|
||||||
|
dataDir: process.env.SMARTVM_INTEGRATION_DATA_DIR || path.join(os.tmpdir(), '.smartvm-integration'),
|
||||||
|
firecrackerVersion: process.env.SMARTVM_FIRECRACKER_VERSION || baseImage.firecrackerVersion,
|
||||||
|
baseImageCacheDir: process.env.SMARTVM_BASE_IMAGE_CACHE_DIR,
|
||||||
|
maxStoredBaseImages,
|
||||||
|
baseImageManifestUrl: process.env.SMARTVM_BASE_IMAGE_MANIFEST_URL,
|
||||||
|
baseImageManifestPath: process.env.SMARTVM_BASE_IMAGE_MANIFEST_PATH,
|
||||||
|
});
|
||||||
|
const vm = await smartvm.createVM({
|
||||||
|
id: `smartvm-it-${Date.now()}`,
|
||||||
|
bootSource: {
|
||||||
|
kernelImagePath: baseImage.kernelImagePath,
|
||||||
|
bootArgs: baseImage.bootArgs,
|
||||||
|
},
|
||||||
|
machineConfig: {
|
||||||
|
vcpuCount: 1,
|
||||||
|
memSizeMib: 256,
|
||||||
|
},
|
||||||
|
drives: [
|
||||||
|
{
|
||||||
|
driveId: 'rootfs',
|
||||||
|
pathOnHost: baseImage.rootfsPath,
|
||||||
|
isRootDevice: true,
|
||||||
|
isReadOnly: baseImage.rootfsIsReadOnly,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await vm.start();
|
||||||
|
expect(vm.state).toEqual('running');
|
||||||
|
expect(await vm.getInfo()).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
if (vm.state === 'running' || vm.state === 'paused') {
|
||||||
|
await vm.stop();
|
||||||
|
}
|
||||||
|
await vm.cleanup();
|
||||||
|
await smartvm.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
+467
-1
@@ -1,11 +1,31 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import {
|
import {
|
||||||
|
BaseImageManager,
|
||||||
VMConfig,
|
VMConfig,
|
||||||
SocketClient,
|
SocketClient,
|
||||||
NetworkManager,
|
NetworkManager,
|
||||||
|
MicroVM,
|
||||||
SmartVM,
|
SmartVM,
|
||||||
|
SmartVMError,
|
||||||
} from '../ts/index.js';
|
} from '../ts/index.js';
|
||||||
import type { IMicroVMConfig } from '../ts/index.js';
|
import type { IBaseImageBundle, IBaseImageHostedManifest, IMicroVMConfig } from '../ts/index.js';
|
||||||
|
|
||||||
|
async function getRejectedError(promise: Promise<unknown>): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
await promise;
|
||||||
|
} catch (err) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256Buffer(buffer: Buffer): string {
|
||||||
|
return crypto.createHash('sha256').update(buffer).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// VMConfig Tests
|
// VMConfig Tests
|
||||||
@@ -87,6 +107,33 @@ tap.test('VMConfig - validate() should fail for multiple root drives', async ()
|
|||||||
expect(result.valid).toBeFalse();
|
expect(result.valid).toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('VMConfig - validate() should fail for invalid vsock guestCid', async () => {
|
||||||
|
const vmConfig = new VMConfig({
|
||||||
|
bootSource: { kernelImagePath: '/vmlinux' },
|
||||||
|
machineConfig: { vcpuCount: 1, memSizeMib: 128 },
|
||||||
|
vsock: { guestCid: 2, udsPath: '/tmp/vsock.sock' },
|
||||||
|
});
|
||||||
|
const result = vmConfig.validate();
|
||||||
|
expect(result.valid).toBeFalse();
|
||||||
|
expect(result.errors).toContain('vsock.guestCid must be >= 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('VMConfig - constructor should not retain caller references', async () => {
|
||||||
|
const config: IMicroVMConfig = {
|
||||||
|
...sampleConfig,
|
||||||
|
networkInterfaces: [{ ifaceId: 'eth0', guestMac: '02:00:00:00:00:01' }],
|
||||||
|
mmds: { version: 'V2', networkInterfaces: ['eth0'] },
|
||||||
|
};
|
||||||
|
const vmConfig = new VMConfig(config);
|
||||||
|
|
||||||
|
config.networkInterfaces![0].guestMac = '02:00:00:00:00:02';
|
||||||
|
config.mmds!.networkInterfaces.push('eth1');
|
||||||
|
|
||||||
|
expect(vmConfig.toNetworkInterfacePayload(vmConfig.config.networkInterfaces![0]).guest_mac)
|
||||||
|
.toEqual('02:00:00:00:00:01');
|
||||||
|
expect(vmConfig.toMmdsConfigPayload()!.network_interfaces).toEqual(['eth0']);
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('VMConfig - toBootSourcePayload() should generate correct snake_case', async () => {
|
tap.test('VMConfig - toBootSourcePayload() should generate correct snake_case', async () => {
|
||||||
const vmConfig = new VMConfig(sampleConfig);
|
const vmConfig = new VMConfig(sampleConfig);
|
||||||
const payload = vmConfig.toBootSourcePayload();
|
const payload = vmConfig.toBootSourcePayload();
|
||||||
@@ -144,6 +191,65 @@ tap.test('VMConfig - toBalloonPayload() should generate correct payload', async
|
|||||||
expect(payload!.stats_polling_interval_s).toEqual(5);
|
expect(payload!.stats_polling_interval_s).toEqual(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('VMConfig - toVsockPayload() should generate correct payload', async () => {
|
||||||
|
const config: IMicroVMConfig = {
|
||||||
|
...sampleConfig,
|
||||||
|
vsock: { guestCid: 3, udsPath: '/tmp/vsock.sock' },
|
||||||
|
};
|
||||||
|
const vmConfig = new VMConfig(config);
|
||||||
|
const payload = vmConfig.toVsockPayload();
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
expect(payload!.guest_cid).toEqual(3);
|
||||||
|
expect(payload!.uds_path).toEqual('/tmp/vsock.sock');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('VMConfig - toMmdsConfigPayload() should generate correct payload', async () => {
|
||||||
|
const config: IMicroVMConfig = {
|
||||||
|
...sampleConfig,
|
||||||
|
mmds: { version: 'V2', networkInterfaces: ['eth0'] },
|
||||||
|
};
|
||||||
|
const vmConfig = new VMConfig(config);
|
||||||
|
const payload = vmConfig.toMmdsConfigPayload();
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
expect(payload!.version).toEqual('V2');
|
||||||
|
expect(payload!.network_interfaces).toEqual(['eth0']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('VMConfig - toMetricsPayload() should generate correct payload', async () => {
|
||||||
|
const config: IMicroVMConfig = {
|
||||||
|
...sampleConfig,
|
||||||
|
metrics: { metricsPath: '/tmp/firecracker-metrics.fifo' },
|
||||||
|
};
|
||||||
|
const vmConfig = new VMConfig(config);
|
||||||
|
const payload = vmConfig.toMetricsPayload();
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
expect(payload!.metrics_path).toEqual('/tmp/firecracker-metrics.fifo');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('VMConfig - toDrivePayload() should include rate limiter payloads', async () => {
|
||||||
|
const vmConfig = new VMConfig(sampleConfig);
|
||||||
|
const payload = vmConfig.toDrivePayload({
|
||||||
|
driveId: 'data',
|
||||||
|
pathOnHost: '/path/to/data.ext4',
|
||||||
|
isRootDevice: false,
|
||||||
|
rateLimiter: {
|
||||||
|
bandwidth: { size: 1000, refillTime: 2000, oneTimeBurst: 3000 },
|
||||||
|
ops: { size: 10, refillTime: 20, oneTimeBurst: 30 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.rate_limiter.bandwidth).toEqual({
|
||||||
|
size: 1000,
|
||||||
|
refill_time: 2000,
|
||||||
|
one_time_burst: 3000,
|
||||||
|
});
|
||||||
|
expect(payload.rate_limiter.ops).toEqual({
|
||||||
|
size: 10,
|
||||||
|
refill_time: 20,
|
||||||
|
one_time_burst: 30,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('VMConfig - toLoggerPayload() should generate correct payload', async () => {
|
tap.test('VMConfig - toLoggerPayload() should generate correct payload', async () => {
|
||||||
const config: IMicroVMConfig = {
|
const config: IMicroVMConfig = {
|
||||||
...sampleConfig,
|
...sampleConfig,
|
||||||
@@ -167,6 +273,269 @@ tap.test('SocketClient - URL construction', async () => {
|
|||||||
expect(client).toBeTruthy();
|
expect(client).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// BaseImageManager Tests
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
tap.test('BaseImageManager - instantiation with defaults', async () => {
|
||||||
|
const manager = new BaseImageManager();
|
||||||
|
expect(manager.getCacheDir()).toEqual(path.join(os.tmpdir(), '.smartvm', 'base-images'));
|
||||||
|
expect(manager.getMaxStoredBaseImages()).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('BaseImageManager - rejects invalid maxStoredBaseImages', async () => {
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
new BaseImageManager({ maxStoredBaseImages: 0 });
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((error as SmartVMError).code).toEqual('INVALID_BASE_IMAGE_CACHE_LIMIT');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('BaseImageManager - pruneBaseImageCache() should evict old bundles', async () => {
|
||||||
|
const cacheDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-base-image-test-'));
|
||||||
|
const manager = new BaseImageManager({ cacheDir, maxStoredBaseImages: 2 });
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
const warnings: string[] = [];
|
||||||
|
console.warn = (message?: any) => {
|
||||||
|
warnings.push(String(message));
|
||||||
|
};
|
||||||
|
|
||||||
|
const createManifest = async (bundleId: string, lastAccessedAt: string) => {
|
||||||
|
const bundleDir = path.join(cacheDir, bundleId);
|
||||||
|
await fs.promises.mkdir(bundleDir, { recursive: true });
|
||||||
|
const bundle: IBaseImageBundle = {
|
||||||
|
preset: 'lts',
|
||||||
|
arch: 'x86_64',
|
||||||
|
ciVersion: 'v1.7',
|
||||||
|
firecrackerVersion: 'v1.7.0',
|
||||||
|
bundleId,
|
||||||
|
bundleDir,
|
||||||
|
kernelImagePath: path.join(bundleDir, 'vmlinux'),
|
||||||
|
rootfsPath: path.join(bundleDir, 'rootfs.ext4'),
|
||||||
|
rootfsType: 'ext4',
|
||||||
|
rootfsIsReadOnly: false,
|
||||||
|
bootArgs: 'console=ttyS0 reboot=k panic=1 pci=off',
|
||||||
|
source: {
|
||||||
|
bucketUrl: 'https://s3.amazonaws.com/spec.ccfc.min',
|
||||||
|
kernelKey: 'kernel',
|
||||||
|
rootfsKey: 'rootfs',
|
||||||
|
},
|
||||||
|
createdAt: lastAccessedAt,
|
||||||
|
lastAccessedAt,
|
||||||
|
};
|
||||||
|
await fs.promises.writeFile(path.join(bundleDir, 'manifest.json'), `${JSON.stringify(bundle, null, 2)}\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createManifest('old', '2024-01-01T00:00:00.000Z');
|
||||||
|
await createManifest('middle', '2024-01-02T00:00:00.000Z');
|
||||||
|
await createManifest('new', '2024-01-03T00:00:00.000Z');
|
||||||
|
|
||||||
|
const evicted = await manager.pruneBaseImageCache('new');
|
||||||
|
expect(evicted).toEqual(['old']);
|
||||||
|
expect(warnings.length).toEqual(1);
|
||||||
|
expect(warnings[0]).toInclude('Evicting old');
|
||||||
|
expect(fs.existsSync(path.join(cacheDir, 'old'))).toBeFalse();
|
||||||
|
expect(fs.existsSync(path.join(cacheDir, 'middle'))).toBeTrue();
|
||||||
|
expect(fs.existsSync(path.join(cacheDir, 'new'))).toBeTrue();
|
||||||
|
} finally {
|
||||||
|
console.warn = originalWarn;
|
||||||
|
await fs.promises.rm(cacheDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('BaseImageManager - ensureBaseImage() should copy hosted manifest artifacts', async () => {
|
||||||
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-test-'));
|
||||||
|
const cacheDir = path.join(workDir, 'cache');
|
||||||
|
const assetsDir = path.join(workDir, 'assets');
|
||||||
|
await fs.promises.mkdir(assetsDir, { recursive: true });
|
||||||
|
|
||||||
|
const kernelBuffer = Buffer.from('fake-kernel');
|
||||||
|
const rootfsBuffer = Buffer.from('fake-rootfs');
|
||||||
|
const kernelPath = path.join(assetsDir, 'vmlinux-test');
|
||||||
|
const rootfsPath = path.join(assetsDir, 'rootfs-test.ext4');
|
||||||
|
await fs.promises.writeFile(kernelPath, kernelBuffer);
|
||||||
|
await fs.promises.writeFile(rootfsPath, rootfsBuffer);
|
||||||
|
|
||||||
|
const manifest: IBaseImageHostedManifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
bundleId: 'smartvm-minimal-test',
|
||||||
|
name: 'SmartVM minimal test bundle',
|
||||||
|
arch: 'x86_64',
|
||||||
|
firecrackerVersion: 'v1.15.1',
|
||||||
|
rootfsType: 'ext4',
|
||||||
|
rootfsIsReadOnly: false,
|
||||||
|
bootArgs: 'console=ttyS0 reboot=k panic=1 pci=off',
|
||||||
|
kernel: {
|
||||||
|
path: kernelPath,
|
||||||
|
fileName: 'vmlinux',
|
||||||
|
sha256: sha256Buffer(kernelBuffer),
|
||||||
|
sizeBytes: kernelBuffer.length,
|
||||||
|
},
|
||||||
|
rootfs: {
|
||||||
|
path: rootfsPath,
|
||||||
|
fileName: 'rootfs.ext4',
|
||||||
|
sha256: sha256Buffer(rootfsBuffer),
|
||||||
|
sizeBytes: rootfsBuffer.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const manifestPath = path.join(workDir, 'manifest.json');
|
||||||
|
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = new BaseImageManager({ cacheDir });
|
||||||
|
const bundle = await manager.ensureBaseImage({ manifestPath });
|
||||||
|
|
||||||
|
expect(bundle.preset).toEqual('hosted');
|
||||||
|
expect(bundle.bundleId).toEqual('smartvm-minimal-test');
|
||||||
|
expect(bundle.firecrackerVersion).toEqual('v1.15.1');
|
||||||
|
expect(bundle.source.type).toEqual('hosted-manifest');
|
||||||
|
expect(bundle.source.manifestPath).toEqual(manifestPath);
|
||||||
|
expect(fs.existsSync(bundle.kernelImagePath)).toBeTrue();
|
||||||
|
expect(fs.existsSync(bundle.rootfsPath)).toBeTrue();
|
||||||
|
expect(bundle.checksums!.kernelSha256).toEqual(sha256Buffer(kernelBuffer));
|
||||||
|
expect(bundle.checksums!.rootfsSha256).toEqual(sha256Buffer(rootfsBuffer));
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('BaseImageManager - ensureBaseImage() should redownload corrupted cached artifacts', async () => {
|
||||||
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-cache-test-'));
|
||||||
|
const cacheDir = path.join(workDir, 'cache');
|
||||||
|
const assetsDir = path.join(workDir, 'assets');
|
||||||
|
await fs.promises.mkdir(assetsDir, { recursive: true });
|
||||||
|
|
||||||
|
const kernelBuffer = Buffer.from('fresh-kernel');
|
||||||
|
const rootfsBuffer = Buffer.from('fresh-rootfs');
|
||||||
|
const kernelPath = path.join(assetsDir, 'vmlinux-test');
|
||||||
|
const rootfsPath = path.join(assetsDir, 'rootfs-test.ext4');
|
||||||
|
await fs.promises.writeFile(kernelPath, kernelBuffer);
|
||||||
|
await fs.promises.writeFile(rootfsPath, rootfsBuffer);
|
||||||
|
|
||||||
|
const manifest: IBaseImageHostedManifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
bundleId: 'smartvm-corruption-test',
|
||||||
|
arch: 'x86_64',
|
||||||
|
firecrackerVersion: 'v1.15.1',
|
||||||
|
rootfsType: 'ext4',
|
||||||
|
kernel: {
|
||||||
|
path: kernelPath,
|
||||||
|
fileName: 'vmlinux',
|
||||||
|
sha256: sha256Buffer(kernelBuffer),
|
||||||
|
sizeBytes: kernelBuffer.length,
|
||||||
|
},
|
||||||
|
rootfs: {
|
||||||
|
path: rootfsPath,
|
||||||
|
fileName: 'rootfs.ext4',
|
||||||
|
sha256: sha256Buffer(rootfsBuffer),
|
||||||
|
sizeBytes: rootfsBuffer.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const manifestPath = path.join(workDir, 'manifest.json');
|
||||||
|
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = new BaseImageManager({ cacheDir });
|
||||||
|
const firstBundle = await manager.ensureBaseImage({ manifestPath });
|
||||||
|
await fs.promises.writeFile(firstBundle.kernelImagePath, 'tampered-kernel');
|
||||||
|
|
||||||
|
const secondBundle = await manager.ensureBaseImage({ manifestPath });
|
||||||
|
expect(await fs.promises.readFile(secondBundle.kernelImagePath, 'utf8')).toEqual('fresh-kernel');
|
||||||
|
expect(secondBundle.checksums!.kernelSha256).toEqual(sha256Buffer(kernelBuffer));
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('BaseImageManager - ensureBaseImage() should reject hosted manifest arch mismatch', async () => {
|
||||||
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-invalid-test-'));
|
||||||
|
const manifestPath = path.join(workDir, 'manifest.json');
|
||||||
|
const manifest: IBaseImageHostedManifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
bundleId: 'smartvm-invalid-arch-test',
|
||||||
|
arch: 'aarch64',
|
||||||
|
firecrackerVersion: 'v1.15.1',
|
||||||
|
rootfsType: 'ext4',
|
||||||
|
kernel: { path: path.join(workDir, 'vmlinux') },
|
||||||
|
rootfs: { path: path.join(workDir, 'rootfs.ext4') },
|
||||||
|
};
|
||||||
|
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = new BaseImageManager({ cacheDir: path.join(workDir, 'cache'), arch: 'x86_64' });
|
||||||
|
const error = await getRejectedError(manager.ensureBaseImage({ manifestPath }));
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((error as SmartVMError).code).toEqual('INVALID_BASE_IMAGE_MANIFEST');
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('BaseImageManager - ensureBaseImage() should reject hosted manifest fileName traversal', async () => {
|
||||||
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-filename-test-'));
|
||||||
|
const manifestPath = path.join(workDir, 'manifest.json');
|
||||||
|
const manifest: IBaseImageHostedManifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
bundleId: 'smartvm-invalid-filename-test',
|
||||||
|
arch: 'x86_64',
|
||||||
|
firecrackerVersion: 'v1.15.1',
|
||||||
|
rootfsType: 'ext4',
|
||||||
|
kernel: { path: path.join(workDir, 'vmlinux'), fileName: '../vmlinux' },
|
||||||
|
rootfs: { path: path.join(workDir, 'rootfs.ext4'), fileName: 'rootfs.ext4' },
|
||||||
|
};
|
||||||
|
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = new BaseImageManager({ cacheDir: path.join(workDir, 'cache') });
|
||||||
|
const error = await getRejectedError(manager.ensureBaseImage({ manifestPath }));
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((error as SmartVMError).code).toEqual('INVALID_BASE_IMAGE_MANIFEST');
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('BaseImageManager - ensureBaseImage() should reject hosted URL artifacts without sha256', async () => {
|
||||||
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-url-test-'));
|
||||||
|
const manifestPath = path.join(workDir, 'manifest.json');
|
||||||
|
const manifest: IBaseImageHostedManifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
bundleId: 'smartvm-invalid-url-test',
|
||||||
|
arch: 'x86_64',
|
||||||
|
firecrackerVersion: 'v1.15.1',
|
||||||
|
rootfsType: 'ext4',
|
||||||
|
kernel: { url: 'https://example.com/vmlinux' },
|
||||||
|
rootfs: { path: path.join(workDir, 'rootfs.ext4') },
|
||||||
|
};
|
||||||
|
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = new BaseImageManager({ cacheDir: path.join(workDir, 'cache') });
|
||||||
|
const error = await getRejectedError(manager.ensureBaseImage({ manifestPath }));
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((error as SmartVMError).code).toEqual('INVALID_BASE_IMAGE_MANIFEST');
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('BaseImageManager - hosted preset should require a manifest', async () => {
|
||||||
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-missing-test-'));
|
||||||
|
try {
|
||||||
|
const manager = new BaseImageManager({ cacheDir: path.join(workDir, 'cache') });
|
||||||
|
const error = await getRejectedError(manager.ensureBaseImage({ preset: 'hosted' }));
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((error as SmartVMError).code).toEqual('BASE_IMAGE_MANIFEST_FAILED');
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// NetworkManager Tests
|
// NetworkManager Tests
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -181,6 +550,65 @@ tap.test('NetworkManager - allocateIp() should allocate sequential IPs', async (
|
|||||||
expect(ip3).toEqual('172.30.0.4');
|
expect(ip3).toEqual('172.30.0.4');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('NetworkManager - allocateIp() should normalize non-network CIDR input', async () => {
|
||||||
|
const nm = new NetworkManager({ subnet: '10.20.30.17/29' });
|
||||||
|
expect(nm.allocateIp()).toEqual('10.20.30.18');
|
||||||
|
expect(nm.allocateIp()).toEqual('10.20.30.19');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('NetworkManager - allocateIp() should fail when subnet is exhausted', async () => {
|
||||||
|
const nm = new NetworkManager({ subnet: '192.168.100.0/30' });
|
||||||
|
expect(nm.allocateIp()).toEqual('192.168.100.2');
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
nm.allocateIp();
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((error as SmartVMError).code).toEqual('IP_EXHAUSTED');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('NetworkManager - constructor should reject invalid subnet input', async () => {
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
new NetworkManager({ subnet: '10.0.0.0/31' });
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((error as SmartVMError).code).toEqual('INVALID_SUBNET');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('NetworkManager - constructor should reject malformed IPv4 octets', async () => {
|
||||||
|
for (const subnet of ['10..0.1/24', '10.0x10.0.1/24', '10.0.0.1 /24']) {
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
new NetworkManager({ subnet });
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((error as SmartVMError).code).toEqual('INVALID_SUBNET');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('NetworkManager - constructor should reject invalid bridge names', async () => {
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
new NetworkManager({ bridgeName: 'bad bridge' });
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((error as SmartVMError).code).toEqual('INVALID_INTERFACE_NAME');
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('NetworkManager - generateMac() should generate locally-administered MACs', async () => {
|
tap.test('NetworkManager - generateMac() should generate locally-administered MACs', async () => {
|
||||||
const nm = new NetworkManager();
|
const nm = new NetworkManager();
|
||||||
const mac1 = nm.generateMac('vm1', 'eth0');
|
const mac1 = nm.generateMac('vm1', 'eth0');
|
||||||
@@ -228,6 +656,28 @@ tap.test('NetworkManager - getGuestNetworkBootArgs() should format correctly', a
|
|||||||
expect(bootArgs).toEqual('ip=172.30.0.2::172.30.0.1:255.255.255.0::eth0:off');
|
expect(bootArgs).toEqual('ip=172.30.0.2::172.30.0.1:255.255.255.0::eth0:off');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// MicroVM Tests
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
tap.test('MicroVM - invalid lifecycle calls should throw SmartVMError', async () => {
|
||||||
|
const vm = new MicroVM(
|
||||||
|
'lifecycle-vm',
|
||||||
|
sampleConfig,
|
||||||
|
'/bin/false',
|
||||||
|
'/tmp/smartvm-lifecycle.sock',
|
||||||
|
new NetworkManager(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pauseError = await getRejectedError(vm.pause());
|
||||||
|
expect(pauseError).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((pauseError as SmartVMError).code).toEqual('INVALID_STATE');
|
||||||
|
|
||||||
|
const infoError = await getRejectedError(vm.getInfo());
|
||||||
|
expect(infoError).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((infoError as SmartVMError).code).toEqual('NO_CLIENT');
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// SmartVM Tests
|
// SmartVM Tests
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -236,6 +686,7 @@ tap.test('SmartVM - instantiation with defaults', async () => {
|
|||||||
const smartvm = new SmartVM();
|
const smartvm = new SmartVM();
|
||||||
expect(smartvm).toBeTruthy();
|
expect(smartvm).toBeTruthy();
|
||||||
expect(smartvm.imageManager).toBeTruthy();
|
expect(smartvm.imageManager).toBeTruthy();
|
||||||
|
expect(smartvm.baseImageManager).toBeTruthy();
|
||||||
expect(smartvm.networkManager).toBeTruthy();
|
expect(smartvm.networkManager).toBeTruthy();
|
||||||
expect(smartvm.vmCount).toEqual(0);
|
expect(smartvm.vmCount).toEqual(0);
|
||||||
expect(smartvm.listVMs()).toHaveLength(0);
|
expect(smartvm.listVMs()).toHaveLength(0);
|
||||||
@@ -251,4 +702,19 @@ tap.test('SmartVM - instantiation with custom options', async () => {
|
|||||||
expect(smartvm).toBeTruthy();
|
expect(smartvm).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('SmartVM - createVM() should track created VMs', async () => {
|
||||||
|
const smartvm = new SmartVM({
|
||||||
|
dataDir: '/tmp/smartvm-test',
|
||||||
|
firecrackerBinaryPath: '/bin/false',
|
||||||
|
});
|
||||||
|
const vm = await smartvm.createVM(sampleConfig);
|
||||||
|
|
||||||
|
expect(smartvm.vmCount).toEqual(1);
|
||||||
|
expect(smartvm.getVM(vm.id)).toEqual(vm);
|
||||||
|
expect(smartvm.listVMs()).toEqual([vm.id]);
|
||||||
|
|
||||||
|
await smartvm.cleanup();
|
||||||
|
expect(smartvm.vmCount).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
export default tap.start();
|
export default tap.start();
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartvm',
|
name: '@push.rocks/smartvm',
|
||||||
version: '1.1.1',
|
version: '1.2.0',
|
||||||
description: 'A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs'
|
description: 'A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,713 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import type {
|
||||||
|
IBaseImageArtifactManifest,
|
||||||
|
IBaseImageBundle,
|
||||||
|
IBaseImageHostedManifest,
|
||||||
|
IBaseImageManagerOptions,
|
||||||
|
IEnsureBaseImageOptions,
|
||||||
|
TBaseImagePreset,
|
||||||
|
TBaseImageRootfsType,
|
||||||
|
TFirecrackerArch,
|
||||||
|
} from './interfaces/index.js';
|
||||||
|
import { SmartVMError } from './interfaces/index.js';
|
||||||
|
|
||||||
|
const FIRECRACKER_CI_BUCKET_URL = 'https://s3.amazonaws.com/spec.ccfc.min';
|
||||||
|
const DEFAULT_MAX_STORED_BASE_IMAGES = 2;
|
||||||
|
const LTS_CI_VERSION = 'v1.7';
|
||||||
|
const LTS_FIRECRACKER_VERSION = 'v1.7.0';
|
||||||
|
|
||||||
|
type TShellExecResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawn']>>;
|
||||||
|
|
||||||
|
interface IResolvedBaseImageSource {
|
||||||
|
preset: TBaseImagePreset;
|
||||||
|
arch: TFirecrackerArch;
|
||||||
|
ciVersion: string;
|
||||||
|
firecrackerVersion: string;
|
||||||
|
kernelKey?: string;
|
||||||
|
rootfsKey?: string;
|
||||||
|
kernelUrl?: string;
|
||||||
|
rootfsUrl?: string;
|
||||||
|
kernelSourcePath?: string;
|
||||||
|
rootfsSourcePath?: string;
|
||||||
|
kernelFileName?: string;
|
||||||
|
rootfsFileName?: string;
|
||||||
|
expectedKernelSha256?: string;
|
||||||
|
expectedRootfsSha256?: string;
|
||||||
|
expectedKernelBytes?: number;
|
||||||
|
expectedRootfsBytes?: number;
|
||||||
|
rootfsType: TBaseImageRootfsType;
|
||||||
|
rootfsIsReadOnly: boolean;
|
||||||
|
bundleId: string;
|
||||||
|
bootArgs: string;
|
||||||
|
source: IBaseImageBundle['source'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads and retains Firecracker CI base images for integration testing.
|
||||||
|
*/
|
||||||
|
export class BaseImageManager {
|
||||||
|
private arch: TFirecrackerArch;
|
||||||
|
private cacheDir: string;
|
||||||
|
private maxStoredBaseImages: number;
|
||||||
|
private hostedManifestUrl?: string;
|
||||||
|
private hostedManifestPath?: string;
|
||||||
|
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
||||||
|
|
||||||
|
constructor(options: IBaseImageManagerOptions = {}) {
|
||||||
|
this.arch = options.arch || 'x86_64';
|
||||||
|
this.cacheDir = options.cacheDir || plugins.path.join(plugins.os.tmpdir(), '.smartvm', 'base-images');
|
||||||
|
this.maxStoredBaseImages = options.maxStoredBaseImages ?? DEFAULT_MAX_STORED_BASE_IMAGES;
|
||||||
|
this.hostedManifestUrl = options.hostedManifestUrl;
|
||||||
|
this.hostedManifestPath = options.hostedManifestPath;
|
||||||
|
if (!Number.isInteger(this.maxStoredBaseImages) || this.maxStoredBaseImages < 1) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
'maxStoredBaseImages must be a positive integer',
|
||||||
|
'INVALID_BASE_IMAGE_CACHE_LIMIT',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCacheDir(): string {
|
||||||
|
return this.cacheDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMaxStoredBaseImages(): number {
|
||||||
|
return this.maxStoredBaseImages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a base image bundle exists locally and return its paths.
|
||||||
|
*/
|
||||||
|
public async ensureBaseImage(options: IEnsureBaseImageOptions = {}): Promise<IBaseImageBundle> {
|
||||||
|
const source = await this.resolveBaseImageSource(options);
|
||||||
|
const bundleDir = plugins.path.join(this.cacheDir, source.bundleId);
|
||||||
|
const manifestPath = this.getManifestPath(bundleDir);
|
||||||
|
|
||||||
|
const cachedBundle = options.forceDownload ? undefined : await this.readCompleteBundle(bundleDir);
|
||||||
|
if (cachedBundle) {
|
||||||
|
const updatedBundle = {
|
||||||
|
...cachedBundle,
|
||||||
|
lastAccessedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await this.writeBundleManifest(updatedBundle);
|
||||||
|
await this.pruneBaseImageCache(updatedBundle.bundleId);
|
||||||
|
return updatedBundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
await plugins.fs.promises.mkdir(bundleDir, { recursive: true });
|
||||||
|
|
||||||
|
const kernelFileName = source.kernelFileName || this.getSourceFileName(source.kernelUrl || source.kernelSourcePath || source.kernelKey!, 'vmlinux');
|
||||||
|
const rootfsFileName = source.rootfsFileName || this.getSourceFileName(source.rootfsUrl || source.rootfsSourcePath || source.rootfsKey!, `rootfs.${source.rootfsType}`);
|
||||||
|
const kernelPath = this.resolveBundleFilePath(bundleDir, kernelFileName);
|
||||||
|
const rootfsPath = this.resolveBundleFilePath(bundleDir, rootfsFileName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.prepareArtifact({
|
||||||
|
url: source.kernelUrl || (source.kernelKey ? this.keyToUrl(source.kernelKey) : undefined),
|
||||||
|
sourcePath: source.kernelSourcePath,
|
||||||
|
targetPath: kernelPath,
|
||||||
|
expectedSha256: source.expectedKernelSha256,
|
||||||
|
expectedBytes: source.expectedKernelBytes,
|
||||||
|
});
|
||||||
|
await this.prepareArtifact({
|
||||||
|
url: source.rootfsUrl || (source.rootfsKey ? this.keyToUrl(source.rootfsKey) : undefined),
|
||||||
|
sourcePath: source.rootfsSourcePath,
|
||||||
|
targetPath: rootfsPath,
|
||||||
|
expectedSha256: source.expectedRootfsSha256,
|
||||||
|
expectedBytes: source.expectedRootfsBytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const bundle: IBaseImageBundle = {
|
||||||
|
preset: source.preset,
|
||||||
|
arch: source.arch,
|
||||||
|
ciVersion: source.ciVersion,
|
||||||
|
firecrackerVersion: source.firecrackerVersion,
|
||||||
|
bundleId: source.bundleId,
|
||||||
|
bundleDir,
|
||||||
|
kernelImagePath: kernelPath,
|
||||||
|
rootfsPath,
|
||||||
|
rootfsType: source.rootfsType,
|
||||||
|
rootfsIsReadOnly: source.rootfsIsReadOnly,
|
||||||
|
bootArgs: source.bootArgs,
|
||||||
|
source: source.source,
|
||||||
|
checksums: {
|
||||||
|
kernelSha256: await this.sha256File(kernelPath),
|
||||||
|
rootfsSha256: await this.sha256File(rootfsPath),
|
||||||
|
},
|
||||||
|
sizes: {
|
||||||
|
kernelBytes: (await plugins.fs.promises.stat(kernelPath)).size,
|
||||||
|
rootfsBytes: (await plugins.fs.promises.stat(rootfsPath)).size,
|
||||||
|
},
|
||||||
|
createdAt: now,
|
||||||
|
lastAccessedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.writeBundleManifest(bundle);
|
||||||
|
await this.pruneBaseImageCache(bundle.bundleId);
|
||||||
|
return bundle;
|
||||||
|
} catch (err) {
|
||||||
|
await plugins.fs.promises.rm(bundleDir, { recursive: true, force: true });
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Failed to prepare base image bundle ${source.bundleId}: ${getErrorMessage(err)}`,
|
||||||
|
'BASE_IMAGE_PREPARE_FAILED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prune cached base image bundles according to the retention limit.
|
||||||
|
*/
|
||||||
|
public async pruneBaseImageCache(keepBundleId?: string): Promise<string[]> {
|
||||||
|
await plugins.fs.promises.mkdir(this.cacheDir, { recursive: true });
|
||||||
|
const bundles = await this.listCachedBundles();
|
||||||
|
bundles.sort((a, b) => {
|
||||||
|
if (keepBundleId) {
|
||||||
|
if (a.bundleId === keepBundleId) return -1;
|
||||||
|
if (b.bundleId === keepBundleId) return 1;
|
||||||
|
}
|
||||||
|
return Date.parse(b.lastAccessedAt) - Date.parse(a.lastAccessedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
const evicted: string[] = [];
|
||||||
|
for (const bundle of bundles.slice(this.maxStoredBaseImages)) {
|
||||||
|
console.warn(
|
||||||
|
`[smartvm] Base image cache stores at most ${this.maxStoredBaseImages} bundle(s). ` +
|
||||||
|
`Evicting ${bundle.bundleId} from ${bundle.bundleDir}. Configure maxStoredBaseImages to change this behavior.`,
|
||||||
|
);
|
||||||
|
await plugins.fs.promises.rm(bundle.bundleDir, { recursive: true, force: true });
|
||||||
|
evicted.push(bundle.bundleId);
|
||||||
|
}
|
||||||
|
return evicted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveBaseImageSource(options: IEnsureBaseImageOptions): Promise<IResolvedBaseImageSource> {
|
||||||
|
const arch = options.arch || this.arch;
|
||||||
|
const manifestUrl = options.manifestUrl || this.hostedManifestUrl;
|
||||||
|
const manifestPath = options.manifestPath || this.hostedManifestPath;
|
||||||
|
if (manifestUrl || manifestPath) {
|
||||||
|
return this.resolveHostedManifestSource({ arch, manifestUrl, manifestPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = options.preset || 'latest';
|
||||||
|
if (preset === 'hosted') {
|
||||||
|
throw new SmartVMError(
|
||||||
|
'The hosted base image preset requires manifestUrl, manifestPath, or a manager-level hosted manifest option',
|
||||||
|
'BASE_IMAGE_MANIFEST_FAILED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const firecrackerVersion = preset === 'latest'
|
||||||
|
? await this.getLatestFirecrackerVersion()
|
||||||
|
: LTS_FIRECRACKER_VERSION;
|
||||||
|
const ciVersion = preset === 'latest'
|
||||||
|
? firecrackerVersion.split('.').slice(0, 2).join('.')
|
||||||
|
: LTS_CI_VERSION;
|
||||||
|
|
||||||
|
const keys = await this.listCiKeys(ciVersion, arch);
|
||||||
|
const kernelKey = this.selectKernelKey(keys);
|
||||||
|
const rootfsKey = this.selectRootfsKey(keys);
|
||||||
|
const rootfsType = rootfsKey.endsWith('.ext4') ? 'ext4' : 'squashfs';
|
||||||
|
const bundleId = this.buildBundleId(preset, ciVersion, arch, kernelKey, rootfsKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
preset,
|
||||||
|
arch,
|
||||||
|
ciVersion,
|
||||||
|
firecrackerVersion,
|
||||||
|
kernelKey,
|
||||||
|
rootfsKey,
|
||||||
|
rootfsType,
|
||||||
|
rootfsIsReadOnly: rootfsType === 'squashfs',
|
||||||
|
bundleId,
|
||||||
|
bootArgs: this.buildBootArgs(arch, rootfsType),
|
||||||
|
source: {
|
||||||
|
type: 'firecracker-ci',
|
||||||
|
bucketUrl: FIRECRACKER_CI_BUCKET_URL,
|
||||||
|
kernelKey,
|
||||||
|
rootfsKey,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveHostedManifestSource(options: {
|
||||||
|
arch: TFirecrackerArch;
|
||||||
|
manifestUrl?: string;
|
||||||
|
manifestPath?: string;
|
||||||
|
}): Promise<IResolvedBaseImageSource> {
|
||||||
|
const manifest = await this.loadHostedManifest(options);
|
||||||
|
this.validateHostedManifest(manifest, options.arch);
|
||||||
|
this.getArtifactSource(manifest.kernel, 'kernel');
|
||||||
|
this.getArtifactSource(manifest.rootfs, 'rootfs');
|
||||||
|
|
||||||
|
return {
|
||||||
|
preset: 'hosted',
|
||||||
|
arch: manifest.arch,
|
||||||
|
ciVersion: 'hosted',
|
||||||
|
firecrackerVersion: manifest.firecrackerVersion,
|
||||||
|
kernelUrl: manifest.kernel.url,
|
||||||
|
rootfsUrl: manifest.rootfs.url,
|
||||||
|
kernelSourcePath: manifest.kernel.path,
|
||||||
|
rootfsSourcePath: manifest.rootfs.path,
|
||||||
|
kernelFileName: manifest.kernel.fileName,
|
||||||
|
rootfsFileName: manifest.rootfs.fileName,
|
||||||
|
expectedKernelSha256: manifest.kernel.sha256,
|
||||||
|
expectedRootfsSha256: manifest.rootfs.sha256,
|
||||||
|
expectedKernelBytes: manifest.kernel.sizeBytes,
|
||||||
|
expectedRootfsBytes: manifest.rootfs.sizeBytes,
|
||||||
|
rootfsType: manifest.rootfsType,
|
||||||
|
rootfsIsReadOnly: manifest.rootfsIsReadOnly ?? manifest.rootfsType === 'squashfs',
|
||||||
|
bundleId: this.sanitizeBundleId(manifest.bundleId),
|
||||||
|
bootArgs: manifest.bootArgs || this.buildBootArgs(manifest.arch, manifest.rootfsType),
|
||||||
|
source: {
|
||||||
|
type: 'hosted-manifest',
|
||||||
|
manifestUrl: options.manifestUrl,
|
||||||
|
manifestPath: options.manifestPath,
|
||||||
|
kernelUrl: manifest.kernel.url,
|
||||||
|
rootfsUrl: manifest.rootfs.url,
|
||||||
|
kernelSourcePath: manifest.kernel.path,
|
||||||
|
rootfsSourcePath: manifest.rootfs.path,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getLatestFirecrackerVersion(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const result = await this.shell.execSpawn('curl', [
|
||||||
|
'-fsSLI',
|
||||||
|
'-o',
|
||||||
|
'/dev/null',
|
||||||
|
'-w',
|
||||||
|
'%{url_effective}',
|
||||||
|
'https://github.com/firecracker-microvm/firecracker/releases/latest',
|
||||||
|
], { silent: true });
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
const output = (result.stderr || result.stdout || '').trim();
|
||||||
|
throw new Error(`curl exited with code ${result.exitCode}${output ? `: ${output}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = result.stdout.trim().match(/\/releases\/tag\/(v\d+\.\d+\.\d+)$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Could not parse latest Firecracker release URL: ${result.stdout.trim()}`);
|
||||||
|
}
|
||||||
|
return match[1];
|
||||||
|
} catch (err) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Failed to resolve latest Firecracker version: ${getErrorMessage(err)}`,
|
||||||
|
'VERSION_FETCH_FAILED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadHostedManifest(options: {
|
||||||
|
manifestUrl?: string;
|
||||||
|
manifestPath?: string;
|
||||||
|
}): Promise<IBaseImageHostedManifest> {
|
||||||
|
try {
|
||||||
|
let raw: string;
|
||||||
|
if (options.manifestPath) {
|
||||||
|
raw = await plugins.fs.promises.readFile(options.manifestPath, 'utf8');
|
||||||
|
} else if (options.manifestUrl) {
|
||||||
|
const response = await plugins.SmartRequest.create()
|
||||||
|
.url(options.manifestUrl)
|
||||||
|
.get();
|
||||||
|
raw = await response.text();
|
||||||
|
} else {
|
||||||
|
throw new Error('manifestUrl or manifestPath is required');
|
||||||
|
}
|
||||||
|
return JSON.parse(raw) as IBaseImageHostedManifest;
|
||||||
|
} catch (err) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Failed to load hosted base image manifest: ${getErrorMessage(err)}`,
|
||||||
|
'BASE_IMAGE_MANIFEST_FAILED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateHostedManifest(manifest: IBaseImageHostedManifest, expectedArch: TFirecrackerArch): void {
|
||||||
|
if (manifest.schemaVersion !== 1) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
'Hosted base image manifest schemaVersion must be 1',
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!manifest.bundleId || !/^[a-zA-Z0-9._-]+$/.test(manifest.bundleId)) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
'Hosted base image manifest bundleId must use only letters, numbers, dot, underscore, and dash',
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (manifest.arch !== expectedArch) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Hosted base image arch '${manifest.arch}' does not match requested arch '${expectedArch}'`,
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!manifest.firecrackerVersion || !/^v\d+\.\d+\.\d+$/.test(manifest.firecrackerVersion)) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
'Hosted base image manifest firecrackerVersion must look like v1.15.1',
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (manifest.rootfsType !== 'ext4' && manifest.rootfsType !== 'squashfs') {
|
||||||
|
throw new SmartVMError(
|
||||||
|
'Hosted base image manifest rootfsType must be ext4 or squashfs',
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.validateArtifactManifest(manifest.kernel, 'kernel');
|
||||||
|
this.validateArtifactManifest(manifest.rootfs, 'rootfs');
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateArtifactManifest(artifact: IBaseImageArtifactManifest, label: string): void {
|
||||||
|
this.getArtifactSource(artifact, label);
|
||||||
|
if (artifact.fileName !== undefined) {
|
||||||
|
this.validateArtifactFileName(artifact.fileName, label);
|
||||||
|
}
|
||||||
|
if (artifact.url && !artifact.sha256) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Hosted base image manifest ${label} artifact with url requires sha256`,
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (artifact.sha256 !== undefined && !/^[a-fA-F0-9]{64}$/.test(artifact.sha256)) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Hosted base image manifest ${label} artifact sha256 must be a 64 character hex string`,
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
artifact.sizeBytes !== undefined &&
|
||||||
|
(!Number.isInteger(artifact.sizeBytes) || artifact.sizeBytes < 0)
|
||||||
|
) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Hosted base image manifest ${label} artifact sizeBytes must be a non-negative integer`,
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateArtifactFileName(fileName: string, label: string): void {
|
||||||
|
if (
|
||||||
|
!fileName ||
|
||||||
|
fileName === '.' ||
|
||||||
|
fileName === '..' ||
|
||||||
|
fileName !== plugins.path.basename(fileName) ||
|
||||||
|
!/^[a-zA-Z0-9._-]+$/.test(fileName)
|
||||||
|
) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Hosted base image manifest ${label} artifact fileName must be a plain file name using letters, numbers, dot, underscore, and dash`,
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getArtifactSource(artifact: { url?: string; path?: string }, label: string): string {
|
||||||
|
if (!artifact.url && !artifact.path) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Hosted base image manifest ${label} artifact requires url or path`,
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (artifact.url && artifact.path) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Hosted base image manifest ${label} artifact must not set both url and path`,
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return artifact.url || artifact.path!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSourceFileName(source: string, fallback: string): string {
|
||||||
|
let fileName: string;
|
||||||
|
try {
|
||||||
|
fileName = plugins.path.basename(new URL(source).pathname);
|
||||||
|
} catch {
|
||||||
|
fileName = plugins.path.basename(source);
|
||||||
|
}
|
||||||
|
return this.sanitizeFileName(fileName || fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveBundleFilePath(bundleDir: string, fileName: string): string {
|
||||||
|
const resolvedBundleDir = plugins.path.resolve(bundleDir);
|
||||||
|
const resolvedFilePath = plugins.path.resolve(resolvedBundleDir, this.sanitizeFileName(fileName));
|
||||||
|
if (!this.isPathInside(resolvedBundleDir, resolvedFilePath)) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Resolved base image artifact path escapes bundle directory: ${fileName}`,
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return resolvedFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeFileName(fileName: string): string {
|
||||||
|
const sanitized = plugins.path.basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
if (!sanitized || sanitized === '.' || sanitized === '..') {
|
||||||
|
return 'artifact';
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeBundleId(bundleId: string): string {
|
||||||
|
return bundleId.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listCiKeys(ciVersion: string, arch: TFirecrackerArch): Promise<string[]> {
|
||||||
|
const prefix = `firecracker-ci/${ciVersion}/${arch}/`;
|
||||||
|
try {
|
||||||
|
const response = await plugins.SmartRequest.create()
|
||||||
|
.url(`${FIRECRACKER_CI_BUCKET_URL}/?prefix=${encodeURIComponent(prefix)}&list-type=2`)
|
||||||
|
.get();
|
||||||
|
const body = await response.text();
|
||||||
|
const keys = Array.from(body.matchAll(/<Key>([^<]+)<\/Key>/g)).map((match) => this.decodeXml(match[1]));
|
||||||
|
if (keys.length === 0) {
|
||||||
|
throw new Error(`No Firecracker CI artifacts found for ${ciVersion}/${arch}`);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
} catch (err) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Failed to list Firecracker CI artifacts for ${ciVersion}/${arch}: ${getErrorMessage(err)}`,
|
||||||
|
'BASE_IMAGE_RESOLVE_FAILED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectKernelKey(keys: string[]): string {
|
||||||
|
const kernelKeys = keys.filter((key) => /\/vmlinux-\d+\.\d+\.\d+$/.test(key) && !key.includes('/debug/'));
|
||||||
|
if (kernelKeys.length === 0) {
|
||||||
|
throw new SmartVMError('No suitable Firecracker CI kernel image found', 'BASE_IMAGE_RESOLVE_FAILED');
|
||||||
|
}
|
||||||
|
return kernelKeys.sort((a, b) => this.compareKernelKeys(a, b)).at(-1)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectRootfsKey(keys: string[]): string {
|
||||||
|
const ext4Keys = keys.filter((key) => /\/ubuntu-[^/]+\.ext4$/.test(key));
|
||||||
|
if (ext4Keys.length > 0) {
|
||||||
|
return ext4Keys.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1)!;
|
||||||
|
}
|
||||||
|
const squashfsKeys = keys.filter((key) => /\/ubuntu-[^/]+\.squashfs$/.test(key));
|
||||||
|
if (squashfsKeys.length > 0) {
|
||||||
|
return squashfsKeys.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1)!;
|
||||||
|
}
|
||||||
|
throw new SmartVMError('No suitable Firecracker CI rootfs image found', 'BASE_IMAGE_RESOLVE_FAILED');
|
||||||
|
}
|
||||||
|
|
||||||
|
private compareKernelKeys(a: string, b: string): number {
|
||||||
|
const aParts = this.extractKernelVersion(a);
|
||||||
|
const bParts = this.extractKernelVersion(b);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if (aParts[i] !== bParts[i]) {
|
||||||
|
return aParts[i] - bParts[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a.localeCompare(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractKernelVersion(key: string): [number, number, number] {
|
||||||
|
const match = key.match(/vmlinux-(\d+)\.(\d+)\.(\d+)$/);
|
||||||
|
if (!match) {
|
||||||
|
return [0, 0, 0];
|
||||||
|
}
|
||||||
|
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildBundleId(
|
||||||
|
preset: TBaseImagePreset,
|
||||||
|
ciVersion: string,
|
||||||
|
arch: TFirecrackerArch,
|
||||||
|
kernelKey: string,
|
||||||
|
rootfsKey: string,
|
||||||
|
): string {
|
||||||
|
const rawId = [
|
||||||
|
preset,
|
||||||
|
ciVersion,
|
||||||
|
arch,
|
||||||
|
plugins.path.basename(kernelKey),
|
||||||
|
plugins.path.basename(rootfsKey),
|
||||||
|
].join('-');
|
||||||
|
return this.sanitizeBundleId(rawId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildBootArgs(arch: TFirecrackerArch, rootfsType: TBaseImageRootfsType): string {
|
||||||
|
const args = ['console=ttyS0', 'reboot=k', 'panic=1', 'pci=off'];
|
||||||
|
if (arch === 'aarch64') {
|
||||||
|
args.unshift('keep_bootcon');
|
||||||
|
}
|
||||||
|
if (rootfsType === 'squashfs') {
|
||||||
|
args.push('ro', 'rootfstype=squashfs');
|
||||||
|
}
|
||||||
|
return args.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
private keyToUrl(key: string): string {
|
||||||
|
return `${FIRECRACKER_CI_BUCKET_URL}/${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepareArtifact(options: {
|
||||||
|
url?: string;
|
||||||
|
sourcePath?: string;
|
||||||
|
targetPath: string;
|
||||||
|
expectedSha256?: string;
|
||||||
|
expectedBytes?: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (options.sourcePath) {
|
||||||
|
await plugins.fs.promises.copyFile(options.sourcePath, options.targetPath);
|
||||||
|
} else if (options.url) {
|
||||||
|
await this.downloadFile(options.url, options.targetPath);
|
||||||
|
} else {
|
||||||
|
throw new Error('Artifact requires url or sourcePath');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = await plugins.fs.promises.stat(options.targetPath);
|
||||||
|
if (options.expectedBytes !== undefined && stat.size !== options.expectedBytes) {
|
||||||
|
throw new Error(
|
||||||
|
`Artifact ${options.targetPath} size mismatch: expected ${options.expectedBytes}, got ${stat.size}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (options.expectedSha256) {
|
||||||
|
const actualSha256 = await this.sha256File(options.targetPath);
|
||||||
|
if (actualSha256.toLowerCase() !== options.expectedSha256.toLowerCase()) {
|
||||||
|
throw new Error(
|
||||||
|
`Artifact ${options.targetPath} SHA256 mismatch: expected ${options.expectedSha256}, got ${actualSha256}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async downloadFile(url: string, targetPath: string): Promise<TShellExecResult> {
|
||||||
|
await plugins.fs.promises.mkdir(plugins.path.dirname(targetPath), { recursive: true });
|
||||||
|
const tempPath = `${targetPath}.download`;
|
||||||
|
await plugins.fs.promises.rm(tempPath, { force: true });
|
||||||
|
const result = await this.shell.execSpawn('curl', ['-fSL', '-o', tempPath, url], { silent: true });
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
const output = (result.stderr || result.stdout || '').trim();
|
||||||
|
throw new Error(`curl failed for ${url} with code ${result.exitCode}${output ? `: ${output}` : ''}`);
|
||||||
|
}
|
||||||
|
await plugins.fs.promises.rename(tempPath, targetPath);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sha256File(filePath: string): Promise<string> {
|
||||||
|
const hash = plugins.crypto.createHash('sha256');
|
||||||
|
const stream = plugins.fs.createReadStream(filePath);
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
hash.update(chunk);
|
||||||
|
}
|
||||||
|
return hash.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readCompleteBundle(bundleDir: string): Promise<IBaseImageBundle | undefined> {
|
||||||
|
const manifestPath = this.getManifestPath(bundleDir);
|
||||||
|
try {
|
||||||
|
const bundle = {
|
||||||
|
...await this.readBundleManifest(manifestPath),
|
||||||
|
bundleDir,
|
||||||
|
};
|
||||||
|
await this.verifyCachedBundle(bundle);
|
||||||
|
return bundle;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifyCachedBundle(bundle: IBaseImageBundle): Promise<void> {
|
||||||
|
if (!this.isPathInside(bundle.bundleDir, bundle.kernelImagePath)) {
|
||||||
|
throw new Error(`Cached kernel path escapes bundle directory: ${bundle.kernelImagePath}`);
|
||||||
|
}
|
||||||
|
if (!this.isPathInside(bundle.bundleDir, bundle.rootfsPath)) {
|
||||||
|
throw new Error(`Cached rootfs path escapes bundle directory: ${bundle.rootfsPath}`);
|
||||||
|
}
|
||||||
|
if (!bundle.checksums?.kernelSha256 || !bundle.checksums?.rootfsSha256) {
|
||||||
|
throw new Error(`Cached bundle ${bundle.bundleId} is missing checksums`);
|
||||||
|
}
|
||||||
|
if (bundle.sizes?.kernelBytes === undefined || bundle.sizes.rootfsBytes === undefined) {
|
||||||
|
throw new Error(`Cached bundle ${bundle.bundleId} is missing sizes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [kernelStat, rootfsStat] = await Promise.all([
|
||||||
|
plugins.fs.promises.stat(bundle.kernelImagePath),
|
||||||
|
plugins.fs.promises.stat(bundle.rootfsPath),
|
||||||
|
]);
|
||||||
|
if (kernelStat.size !== bundle.sizes.kernelBytes) {
|
||||||
|
throw new Error(`Cached kernel size mismatch for bundle ${bundle.bundleId}`);
|
||||||
|
}
|
||||||
|
if (rootfsStat.size !== bundle.sizes.rootfsBytes) {
|
||||||
|
throw new Error(`Cached rootfs size mismatch for bundle ${bundle.bundleId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [kernelSha256, rootfsSha256] = await Promise.all([
|
||||||
|
this.sha256File(bundle.kernelImagePath),
|
||||||
|
this.sha256File(bundle.rootfsPath),
|
||||||
|
]);
|
||||||
|
if (kernelSha256.toLowerCase() !== bundle.checksums.kernelSha256.toLowerCase()) {
|
||||||
|
throw new Error(`Cached kernel SHA256 mismatch for bundle ${bundle.bundleId}`);
|
||||||
|
}
|
||||||
|
if (rootfsSha256.toLowerCase() !== bundle.checksums.rootfsSha256.toLowerCase()) {
|
||||||
|
throw new Error(`Cached rootfs SHA256 mismatch for bundle ${bundle.bundleId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPathInside(baseDir: string, candidatePath: string): boolean {
|
||||||
|
const resolvedBase = plugins.path.resolve(baseDir);
|
||||||
|
const resolvedCandidate = plugins.path.resolve(candidatePath);
|
||||||
|
return resolvedCandidate === resolvedBase || resolvedCandidate.startsWith(`${resolvedBase}${plugins.path.sep}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getManifestPath(bundleDir: string): string {
|
||||||
|
return plugins.path.join(bundleDir, 'manifest.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readBundleManifest(manifestPath: string): Promise<IBaseImageBundle> {
|
||||||
|
const raw = await plugins.fs.promises.readFile(manifestPath, 'utf8');
|
||||||
|
return JSON.parse(raw) as IBaseImageBundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeBundleManifest(bundle: IBaseImageBundle): Promise<void> {
|
||||||
|
await plugins.fs.promises.mkdir(bundle.bundleDir, { recursive: true });
|
||||||
|
await plugins.fs.promises.writeFile(
|
||||||
|
this.getManifestPath(bundle.bundleDir),
|
||||||
|
`${JSON.stringify(bundle, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listCachedBundles(): Promise<IBaseImageBundle[]> {
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = await plugins.fs.promises.readdir(this.cacheDir);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundles: IBaseImageBundle[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const bundleDir = plugins.path.join(this.cacheDir, entry);
|
||||||
|
try {
|
||||||
|
const stat = await plugins.fs.promises.stat(bundleDir);
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bundle = await this.readBundleManifest(this.getManifestPath(bundleDir));
|
||||||
|
bundles.push({
|
||||||
|
...bundle,
|
||||||
|
bundleDir,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore incomplete cache entries.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bundles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeXml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,14 +3,23 @@ import type { IFirecrackerProcessOptions } from './interfaces/index.js';
|
|||||||
import { SmartVMError } from './interfaces/index.js';
|
import { SmartVMError } from './interfaces/index.js';
|
||||||
import { SocketClient } from './classes.socketclient.js';
|
import { SocketClient } from './classes.socketclient.js';
|
||||||
|
|
||||||
|
type TStreamingResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawnStreaming']>>;
|
||||||
|
type TExecResult = Awaited<TStreamingResult['finalPromise']>;
|
||||||
|
|
||||||
|
function getErrorMessage(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages a single Firecracker child process, including startup, readiness polling, and shutdown.
|
* Manages a single Firecracker child process, including startup, readiness polling, and shutdown.
|
||||||
*/
|
*/
|
||||||
export class FirecrackerProcess {
|
export class FirecrackerProcess {
|
||||||
private options: IFirecrackerProcessOptions;
|
private options: IFirecrackerProcessOptions;
|
||||||
private streaming: any | null = null;
|
private streaming: TStreamingResult | null = null;
|
||||||
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
||||||
private smartExitInstance: InstanceType<typeof plugins.smartexit.SmartExit> | null = null;
|
private smartExitInstance: InstanceType<typeof plugins.smartexit.SmartExit> | null = null;
|
||||||
|
private lastExitResult: TExecResult | null = null;
|
||||||
|
private lastExitError: string | null = null;
|
||||||
public socketClient: SocketClient;
|
public socketClient: SocketClient;
|
||||||
|
|
||||||
constructor(options: IFirecrackerProcessOptions) {
|
constructor(options: IFirecrackerProcessOptions) {
|
||||||
@@ -28,14 +37,21 @@ export class FirecrackerProcess {
|
|||||||
plugins.fs.unlinkSync(this.options.socketPath);
|
plugins.fs.unlinkSync(this.options.socketPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the command
|
// Build the command args without a shell so paths are not interpreted.
|
||||||
let cmd = `${this.options.binaryPath} --api-sock ${this.options.socketPath}`;
|
const args = ['--api-sock', this.options.socketPath];
|
||||||
if (this.options.logLevel) {
|
if (this.options.logLevel) {
|
||||||
cmd += ` --level ${this.options.logLevel}`;
|
args.push('--level', this.options.logLevel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn the process
|
// Spawn the process
|
||||||
this.streaming = await this.shell.execStreaming(cmd, true);
|
this.streaming = await this.shell.execSpawnStreaming(this.options.binaryPath, args, { silent: true });
|
||||||
|
this.streaming.finalPromise
|
||||||
|
.then((result) => {
|
||||||
|
this.lastExitResult = result;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
this.lastExitError = getErrorMessage(err);
|
||||||
|
});
|
||||||
|
|
||||||
// Register with smartexit for automatic cleanup
|
// Register with smartexit for automatic cleanup
|
||||||
if (this.streaming?.childProcess) {
|
if (this.streaming?.childProcess) {
|
||||||
@@ -46,9 +62,11 @@ export class FirecrackerProcess {
|
|||||||
// Wait for the socket file to appear
|
// Wait for the socket file to appear
|
||||||
const socketReady = await this.waitForSocket(10000);
|
const socketReady = await this.waitForSocket(10000);
|
||||||
if (!socketReady) {
|
if (!socketReady) {
|
||||||
|
const wasRunning = this.isRunning();
|
||||||
|
const diagnostics = this.formatDiagnostics();
|
||||||
await this.stop();
|
await this.stop();
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
'Firecracker socket did not become ready within timeout',
|
`Firecracker socket did not become ready within timeout${diagnostics || (wasRunning ? '' : this.formatDiagnostics())}`,
|
||||||
'SOCKET_TIMEOUT',
|
'SOCKET_TIMEOUT',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -56,9 +74,10 @@ export class FirecrackerProcess {
|
|||||||
// Wait for the API to be responsive
|
// Wait for the API to be responsive
|
||||||
const apiReady = await this.socketClient.isReady(5000);
|
const apiReady = await this.socketClient.isReady(5000);
|
||||||
if (!apiReady) {
|
if (!apiReady) {
|
||||||
|
const diagnostics = this.formatDiagnostics();
|
||||||
await this.stop();
|
await this.stop();
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
'Firecracker API did not become responsive within timeout',
|
`Firecracker API did not become responsive within timeout${diagnostics}`,
|
||||||
'API_TIMEOUT',
|
'API_TIMEOUT',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -73,36 +92,69 @@ export class FirecrackerProcess {
|
|||||||
if (plugins.fs.existsSync(this.options.socketPath)) {
|
if (plugins.fs.existsSync(this.options.socketPath)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (this.streaming && !this.isRunning()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
await plugins.smartdelay.delayFor(100);
|
await plugins.smartdelay.delayFor(100);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async waitForExit(streaming: TStreamingResult, timeoutMs: number): Promise<boolean> {
|
||||||
|
return Promise.race([
|
||||||
|
streaming.finalPromise.then((result) => {
|
||||||
|
this.lastExitResult = result;
|
||||||
|
return true;
|
||||||
|
}).catch((err) => {
|
||||||
|
this.lastExitError = getErrorMessage(err);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
plugins.smartdelay.delayFor(timeoutMs).then(() => false),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDiagnostics(): string {
|
||||||
|
if (this.lastExitError) {
|
||||||
|
return `: ${this.lastExitError}`;
|
||||||
|
}
|
||||||
|
if (this.lastExitResult) {
|
||||||
|
const output = (this.lastExitResult.stderr || this.lastExitResult.stdout || '').trim();
|
||||||
|
return `: process exited with code ${this.lastExitResult.exitCode}${output ? `: ${output}` : ''}`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gracefully stop the Firecracker process with SIGTERM, then SIGKILL after timeout.
|
* Gracefully stop the Firecracker process with SIGTERM, then SIGKILL after timeout.
|
||||||
*/
|
*/
|
||||||
public async stop(): Promise<void> {
|
public async stop(): Promise<void> {
|
||||||
if (!this.streaming) return;
|
const streaming = this.streaming;
|
||||||
|
if (!streaming) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try graceful termination first
|
// Try graceful termination first
|
||||||
await this.streaming.terminate();
|
await streaming.terminate();
|
||||||
|
|
||||||
// Wait up to 5 seconds for the process to exit
|
// Wait up to 5 seconds for the process to exit
|
||||||
const exitPromise = Promise.race([
|
const terminated = await this.waitForExit(streaming, 5000);
|
||||||
this.streaming.finalPromise,
|
if (!terminated) {
|
||||||
plugins.smartdelay.delayFor(5000),
|
await streaming.kill();
|
||||||
]);
|
await this.waitForExit(streaming, 1000);
|
||||||
await exitPromise;
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// If termination fails, force kill
|
// If termination fails, force kill
|
||||||
try {
|
try {
|
||||||
await this.streaming.kill();
|
await streaming.kill();
|
||||||
|
await this.waitForExit(streaming, 1000);
|
||||||
} catch {
|
} catch {
|
||||||
// Process may already be dead
|
// Process may already be dead
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.smartExitInstance) {
|
||||||
|
this.smartExitInstance.removeProcess(streaming.childProcess);
|
||||||
|
this.smartExitInstance = null;
|
||||||
|
}
|
||||||
this.streaming = null;
|
this.streaming = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,10 +174,11 @@ export class FirecrackerProcess {
|
|||||||
* Check if the process is currently running.
|
* Check if the process is currently running.
|
||||||
*/
|
*/
|
||||||
public isRunning(): boolean {
|
public isRunning(): boolean {
|
||||||
if (!this.streaming?.childProcess) return false;
|
const pid = this.streaming?.childProcess?.pid;
|
||||||
|
if (!pid) return false;
|
||||||
try {
|
try {
|
||||||
// Sending signal 0 tests if process exists without actually sending a signal
|
// Sending signal 0 tests if process exists without actually sending a signal
|
||||||
process.kill(this.streaming.childProcess.pid, 0);
|
process.kill(pid, 0);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
+51
-27
@@ -2,6 +2,12 @@ import * as plugins from './plugins.js';
|
|||||||
import type { TFirecrackerArch } from './interfaces/index.js';
|
import type { TFirecrackerArch } from './interfaces/index.js';
|
||||||
import { SmartVMError } from './interfaces/index.js';
|
import { SmartVMError } from './interfaces/index.js';
|
||||||
|
|
||||||
|
type TShellExecResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawn']>>;
|
||||||
|
|
||||||
|
function getErrorMessage(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to check if a file or directory exists.
|
* Helper to check if a file or directory exists.
|
||||||
*/
|
*/
|
||||||
@@ -21,10 +27,21 @@ async function pathExists(filePath: string): Promise<boolean> {
|
|||||||
export class ImageManager {
|
export class ImageManager {
|
||||||
private dataDir: string;
|
private dataDir: string;
|
||||||
private arch: TFirecrackerArch;
|
private arch: TFirecrackerArch;
|
||||||
|
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
||||||
|
|
||||||
constructor(dataDir: string, arch: TFirecrackerArch = 'x86_64') {
|
constructor(dataDir: string, arch: TFirecrackerArch = 'x86_64') {
|
||||||
this.dataDir = dataDir;
|
this.dataDir = dataDir;
|
||||||
this.arch = arch;
|
this.arch = arch;
|
||||||
|
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runChecked(command: string, args: string[]): Promise<TShellExecResult> {
|
||||||
|
const result = await this.shell.execSpawn(command, args, { silent: true });
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
const output = (result.stderr || result.stdout || '').trim();
|
||||||
|
throw new Error(`${command} ${args.join(' ')} exited with code ${result.exitCode}${output ? `: ${output}` : ''}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,14 +106,22 @@ export class ImageManager {
|
|||||||
*/
|
*/
|
||||||
public async getLatestVersion(): Promise<string> {
|
public async getLatestVersion(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const response = await plugins.SmartRequest.create()
|
const result = await this.runChecked('curl', [
|
||||||
.url('https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest')
|
'-fsSLI',
|
||||||
.get();
|
'-o',
|
||||||
const data = await response.json() as { tag_name: string };
|
'/dev/null',
|
||||||
return data.tag_name;
|
'-w',
|
||||||
|
'%{url_effective}',
|
||||||
|
'https://github.com/firecracker-microvm/firecracker/releases/latest',
|
||||||
|
]);
|
||||||
|
const match = result.stdout.trim().match(/\/releases\/tag\/(v\d+\.\d+\.\d+)$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Could not parse latest Firecracker release URL: ${result.stdout.trim()}`);
|
||||||
|
}
|
||||||
|
return match[1];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`Failed to fetch latest Firecracker version: ${(err as Error).message}`,
|
`Failed to fetch latest Firecracker version: ${getErrorMessage(err)}`,
|
||||||
'VERSION_FETCH_FAILED',
|
'VERSION_FETCH_FAILED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -119,11 +144,10 @@ export class ImageManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Download the archive
|
// Download the archive
|
||||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
await this.runChecked('curl', ['-fSL', '-o', archivePath, downloadUrl]);
|
||||||
await shell.exec(`curl -fSL -o "${archivePath}" "${downloadUrl}"`);
|
|
||||||
|
|
||||||
// Extract the archive
|
// Extract the archive
|
||||||
await shell.exec(`tar -xzf "${archivePath}" -C "${targetDir}"`);
|
await this.runChecked('tar', ['-xzf', archivePath, '-C', targetDir]);
|
||||||
|
|
||||||
// Firecracker archives contain a directory like release-v1.5.0-x86_64/
|
// Firecracker archives contain a directory like release-v1.5.0-x86_64/
|
||||||
// with binaries named like firecracker-v1.5.0-x86_64
|
// with binaries named like firecracker-v1.5.0-x86_64
|
||||||
@@ -134,21 +158,25 @@ export class ImageManager {
|
|||||||
const jailerDst = this.getJailerPath(version);
|
const jailerDst = this.getJailerPath(version);
|
||||||
|
|
||||||
// Move binaries to expected paths
|
// Move binaries to expected paths
|
||||||
await shell.exec(`mv "${firecrackerSrc}" "${firecrackerDst}"`);
|
await plugins.fs.promises.rename(firecrackerSrc, firecrackerDst);
|
||||||
if (await pathExists(jailerSrc)) {
|
if (await pathExists(jailerSrc)) {
|
||||||
await shell.exec(`mv "${jailerSrc}" "${jailerDst}"`);
|
await plugins.fs.promises.rename(jailerSrc, jailerDst);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make executable
|
// Make executable
|
||||||
await shell.exec(`chmod +x "${firecrackerDst}"`);
|
await plugins.fs.promises.chmod(firecrackerDst, 0o755);
|
||||||
|
if (await pathExists(jailerDst)) {
|
||||||
|
await plugins.fs.promises.chmod(jailerDst, 0o755);
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
await shell.exec(`rm -rf "${archivePath}" "${extractedDir}"`);
|
await plugins.fs.promises.rm(archivePath, { force: true });
|
||||||
|
await plugins.fs.promises.rm(extractedDir, { recursive: true, force: true });
|
||||||
|
|
||||||
return firecrackerDst;
|
return firecrackerDst;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`Failed to download Firecracker ${version}: ${(err as Error).message}`,
|
`Failed to download Firecracker ${version}: ${getErrorMessage(err)}`,
|
||||||
'DOWNLOAD_FAILED',
|
'DOWNLOAD_FAILED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -163,12 +191,11 @@ export class ImageManager {
|
|||||||
const kernelPath = plugins.path.join(kernelsDir, name);
|
const kernelPath = plugins.path.join(kernelsDir, name);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
await this.runChecked('curl', ['-fSL', '-o', kernelPath, url]);
|
||||||
await shell.exec(`curl -fSL -o "${kernelPath}" "${url}"`);
|
|
||||||
return kernelPath;
|
return kernelPath;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`Failed to download kernel from ${url}: ${(err as Error).message}`,
|
`Failed to download kernel from ${url}: ${getErrorMessage(err)}`,
|
||||||
'DOWNLOAD_FAILED',
|
'DOWNLOAD_FAILED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -183,12 +210,11 @@ export class ImageManager {
|
|||||||
const rootfsPath = plugins.path.join(rootfsDir, name);
|
const rootfsPath = plugins.path.join(rootfsDir, name);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
await this.runChecked('curl', ['-fSL', '-o', rootfsPath, url]);
|
||||||
await shell.exec(`curl -fSL -o "${rootfsPath}" "${url}"`);
|
|
||||||
return rootfsPath;
|
return rootfsPath;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`Failed to download rootfs from ${url}: ${(err as Error).message}`,
|
`Failed to download rootfs from ${url}: ${getErrorMessage(err)}`,
|
||||||
'DOWNLOAD_FAILED',
|
'DOWNLOAD_FAILED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -203,13 +229,12 @@ export class ImageManager {
|
|||||||
const rootfsPath = plugins.path.join(rootfsDir, name);
|
const rootfsPath = plugins.path.join(rootfsDir, name);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
await this.runChecked('dd', ['if=/dev/zero', `of=${rootfsPath}`, 'bs=1M', `count=${sizeMib}`]);
|
||||||
await shell.exec(`dd if=/dev/zero of="${rootfsPath}" bs=1M count=${sizeMib}`);
|
await this.runChecked('mkfs.ext4', [rootfsPath]);
|
||||||
await shell.exec(`mkfs.ext4 "${rootfsPath}"`);
|
|
||||||
return rootfsPath;
|
return rootfsPath;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`Failed to create blank rootfs: ${(err as Error).message}`,
|
`Failed to create blank rootfs: ${getErrorMessage(err)}`,
|
||||||
'ROOTFS_CREATE_FAILED',
|
'ROOTFS_CREATE_FAILED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -224,12 +249,11 @@ export class ImageManager {
|
|||||||
const targetPath = plugins.path.join(rootfsDir, targetName);
|
const targetPath = plugins.path.join(rootfsDir, targetName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
await plugins.fs.promises.copyFile(sourcePath, targetPath);
|
||||||
await shell.exec(`cp "${sourcePath}" "${targetPath}"`);
|
|
||||||
return targetPath;
|
return targetPath;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`Failed to clone rootfs: ${(err as Error).message}`,
|
`Failed to clone rootfs: ${getErrorMessage(err)}`,
|
||||||
'ROOTFS_CLONE_FAILED',
|
'ROOTFS_CLONE_FAILED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-11
@@ -54,6 +54,16 @@ export class MicroVM {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getSocketClient(operation: string): SocketClient {
|
||||||
|
if (!this.socketClient) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Cannot ${operation}: socket client not initialized`,
|
||||||
|
'NO_CLIENT',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.socketClient;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the MicroVM.
|
* Start the MicroVM.
|
||||||
* Validates config, starts the Firecracker process, applies pre-boot configuration, and boots the VM.
|
* Validates config, starts the Firecracker process, applies pre-boot configuration, and boots the VM.
|
||||||
@@ -244,7 +254,7 @@ export class MicroVM {
|
|||||||
*/
|
*/
|
||||||
public async getMetadata(): Promise<any> {
|
public async getMetadata(): Promise<any> {
|
||||||
this.assertState(['running', 'paused'], 'getMetadata');
|
this.assertState(['running', 'paused'], 'getMetadata');
|
||||||
const response = await this.socketClient!.get('/mmds');
|
const response = await this.getSocketClient('getMetadata').get('/mmds');
|
||||||
return response.body;
|
return response.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,7 +292,7 @@ export class MicroVM {
|
|||||||
* Get VM instance info.
|
* Get VM instance info.
|
||||||
*/
|
*/
|
||||||
public async getInfo(): Promise<any> {
|
public async getInfo(): Promise<any> {
|
||||||
const response = await this.socketClient!.get('/');
|
const response = await this.getSocketClient('getInfo').get('/');
|
||||||
return response.body;
|
return response.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +300,7 @@ export class MicroVM {
|
|||||||
* Get Firecracker version info.
|
* Get Firecracker version info.
|
||||||
*/
|
*/
|
||||||
public async getVersion(): Promise<any> {
|
public async getVersion(): Promise<any> {
|
||||||
const response = await this.socketClient!.get('/version');
|
const response = await this.getSocketClient('getVersion').get('/version');
|
||||||
return response.body;
|
return response.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,10 +344,7 @@ export class MicroVM {
|
|||||||
* Helper: PUT request with error handling.
|
* Helper: PUT request with error handling.
|
||||||
*/
|
*/
|
||||||
private async apiPut(path: string, body: Record<string, any>): Promise<void> {
|
private async apiPut(path: string, body: Record<string, any>): Promise<void> {
|
||||||
if (!this.socketClient) {
|
const response = await this.getSocketClient(`PUT ${path}`).put(path, body);
|
||||||
throw new SmartVMError('Socket client not initialized', 'NO_CLIENT');
|
|
||||||
}
|
|
||||||
const response = await this.socketClient.put(path, body);
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`API PUT ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`,
|
`API PUT ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`,
|
||||||
@@ -352,10 +359,7 @@ export class MicroVM {
|
|||||||
* Helper: PATCH request with error handling.
|
* Helper: PATCH request with error handling.
|
||||||
*/
|
*/
|
||||||
private async apiPatch(path: string, body: Record<string, any>): Promise<void> {
|
private async apiPatch(path: string, body: Record<string, any>): Promise<void> {
|
||||||
if (!this.socketClient) {
|
const response = await this.getSocketClient(`PATCH ${path}`).patch(path, body);
|
||||||
throw new SmartVMError('Socket client not initialized', 'NO_CLIENT');
|
|
||||||
}
|
|
||||||
const response = await this.socketClient.patch(path, body);
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`API PATCH ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`,
|
`API PATCH ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`,
|
||||||
|
|||||||
+190
-46
@@ -2,6 +2,15 @@ import * as plugins from './plugins.js';
|
|||||||
import type { INetworkManagerOptions, ITapDevice } from './interfaces/index.js';
|
import type { INetworkManagerOptions, ITapDevice } from './interfaces/index.js';
|
||||||
import { SmartVMError } from './interfaces/index.js';
|
import { SmartVMError } from './interfaces/index.js';
|
||||||
|
|
||||||
|
type TShellExecResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawn']>>;
|
||||||
|
|
||||||
|
interface IParsedSubnet {
|
||||||
|
networkAddress: number;
|
||||||
|
broadcastAddress: number;
|
||||||
|
cidr: number;
|
||||||
|
subnetMask: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages host networking for Firecracker VMs.
|
* Manages host networking for Firecracker VMs.
|
||||||
* Creates TAP devices, Linux bridges, and configures NAT for VM internet access.
|
* Creates TAP devices, Linux bridges, and configures NAT for VM internet access.
|
||||||
@@ -12,53 +21,121 @@ export class NetworkManager {
|
|||||||
private subnetCidr: number;
|
private subnetCidr: number;
|
||||||
private gatewayIp: string;
|
private gatewayIp: string;
|
||||||
private subnetMask: string;
|
private subnetMask: string;
|
||||||
private nextIpOctet: number;
|
private nextIpAddress: number;
|
||||||
|
private lastUsableIpAddress: number;
|
||||||
private activeTaps: Map<string, ITapDevice> = new Map();
|
private activeTaps: Map<string, ITapDevice> = new Map();
|
||||||
private bridgeCreated: boolean = false;
|
private bridgeCreated: boolean = false;
|
||||||
|
private defaultRouteInterface: string | null = null;
|
||||||
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
||||||
|
|
||||||
constructor(options: INetworkManagerOptions = {}) {
|
constructor(options: INetworkManagerOptions = {}) {
|
||||||
this.bridgeName = options.bridgeName || 'svbr0';
|
this.bridgeName = options.bridgeName || 'svbr0';
|
||||||
|
this.validateInterfaceName(this.bridgeName, 'bridgeName');
|
||||||
const subnet = options.subnet || '172.30.0.0/24';
|
const subnet = options.subnet || '172.30.0.0/24';
|
||||||
|
const parsedSubnet = this.parseSubnet(subnet);
|
||||||
|
|
||||||
// Parse the subnet
|
this.subnetBase = this.intToIp(parsedSubnet.networkAddress);
|
||||||
const [baseIp, cidrStr] = subnet.split('/');
|
this.subnetCidr = parsedSubnet.cidr;
|
||||||
this.subnetBase = baseIp;
|
this.subnetMask = parsedSubnet.subnetMask;
|
||||||
this.subnetCidr = parseInt(cidrStr, 10);
|
this.gatewayIp = this.intToIp(parsedSubnet.networkAddress + 1);
|
||||||
this.subnetMask = this.cidrToSubnetMask(this.subnetCidr);
|
this.nextIpAddress = parsedSubnet.networkAddress + 2;
|
||||||
|
this.lastUsableIpAddress = parsedSubnet.broadcastAddress - 1;
|
||||||
// Gateway is .1 in the subnet
|
|
||||||
const parts = this.subnetBase.split('.').map(Number);
|
|
||||||
parts[3] = 1;
|
|
||||||
this.gatewayIp = parts.join('.');
|
|
||||||
|
|
||||||
// VMs start at .2
|
|
||||||
this.nextIpOctet = 2;
|
|
||||||
|
|
||||||
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a CIDR prefix length to a dotted-decimal subnet mask.
|
* Parse an IPv4 CIDR subnet and ensure there is room for a gateway and guests.
|
||||||
*/
|
*/
|
||||||
private cidrToSubnetMask(cidr: number): string {
|
private parseSubnet(subnet: string): IParsedSubnet {
|
||||||
const mask = (0xffffffff << (32 - cidr)) >>> 0;
|
const [ip, cidrText, extra] = subnet.split('/');
|
||||||
|
const cidr = Number(cidrText);
|
||||||
|
if (!ip || !cidrText || extra !== undefined || !Number.isInteger(cidr) || cidr < 1 || cidr > 30) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Invalid subnet '${subnet}': expected IPv4 CIDR with prefix length 1-30`,
|
||||||
|
'INVALID_SUBNET',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipAddress = this.ipToInt(ip);
|
||||||
|
const mask = this.cidrToMask(cidr);
|
||||||
|
const networkAddress = (ipAddress & mask) >>> 0;
|
||||||
|
const hostCount = 2 ** (32 - cidr);
|
||||||
|
const broadcastAddress = networkAddress + hostCount - 1;
|
||||||
|
if (hostCount < 4) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Invalid subnet '${subnet}': at least two usable host addresses are required`,
|
||||||
|
'INVALID_SUBNET',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
networkAddress,
|
||||||
|
broadcastAddress,
|
||||||
|
cidr,
|
||||||
|
subnetMask: this.intToIp(mask),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private ipToInt(ip: string): number {
|
||||||
|
const octets = ip.split('.');
|
||||||
|
if (octets.length !== 4) {
|
||||||
|
throw new SmartVMError(`Invalid IPv4 address '${ip}'`, 'INVALID_SUBNET');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (octets.some((octet) => !/^[0-9]+$/.test(octet))) {
|
||||||
|
throw new SmartVMError(`Invalid IPv4 address '${ip}'`, 'INVALID_SUBNET');
|
||||||
|
}
|
||||||
|
|
||||||
|
const numbers = octets.map((octet) => Number(octet));
|
||||||
|
if (numbers.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
|
||||||
|
throw new SmartVMError(`Invalid IPv4 address '${ip}'`, 'INVALID_SUBNET');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
numbers[0] * 256 ** 3 +
|
||||||
|
numbers[1] * 256 ** 2 +
|
||||||
|
numbers[2] * 256 +
|
||||||
|
numbers[3]
|
||||||
|
) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private intToIp(address: number): string {
|
||||||
return [
|
return [
|
||||||
(mask >>> 24) & 0xff,
|
Math.floor(address / 256 ** 3) % 256,
|
||||||
(mask >>> 16) & 0xff,
|
Math.floor(address / 256 ** 2) % 256,
|
||||||
(mask >>> 8) & 0xff,
|
Math.floor(address / 256) % 256,
|
||||||
mask & 0xff,
|
address % 256,
|
||||||
].join('.');
|
].join('.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private cidrToMask(cidr: number): number {
|
||||||
|
return (0xffffffff << (32 - cidr)) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateInterfaceName(name: string, fieldName: string): void {
|
||||||
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,14}$/.test(name)) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`${fieldName} '${name}' is not a valid Linux interface name`,
|
||||||
|
'INVALID_INTERFACE_NAME',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allocate the next available IP address in the subnet.
|
* Allocate the next available IP address in the subnet.
|
||||||
*/
|
*/
|
||||||
public allocateIp(): string {
|
public allocateIp(): string {
|
||||||
const parts = this.subnetBase.split('.').map(Number);
|
if (this.nextIpAddress > this.lastUsableIpAddress) {
|
||||||
parts[3] = this.nextIpOctet;
|
throw new SmartVMError(
|
||||||
this.nextIpOctet++;
|
`Subnet ${this.subnetBase}/${this.subnetCidr} has no available guest IP addresses`,
|
||||||
return parts.join('.');
|
'IP_EXHAUSTED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = this.intToIp(this.nextIpAddress);
|
||||||
|
this.nextIpAddress++;
|
||||||
|
return ip;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -102,6 +179,36 @@ export class NetworkManager {
|
|||||||
return tapName.substring(0, 15);
|
return tapName.substring(0, 15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async run(command: string, args: string[]): Promise<TShellExecResult> {
|
||||||
|
return this.shell.execSpawn(command, args, { silent: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runChecked(command: string, args: string[]): Promise<TShellExecResult> {
|
||||||
|
const result = await this.run(command, args);
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
const output = (result.stderr || result.stdout || '').trim();
|
||||||
|
throw new Error(`${command} ${args.join(' ')} exited with code ${result.exitCode}${output ? `: ${output}` : ''}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDefaultRouteInterface(): Promise<string> {
|
||||||
|
if (this.defaultRouteInterface) {
|
||||||
|
return this.defaultRouteInterface;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.runChecked('ip', ['route', 'show', 'default']);
|
||||||
|
const match = result.stdout.match(/\bdev\s+([^\s]+)/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('Could not determine default route interface');
|
||||||
|
}
|
||||||
|
|
||||||
|
const iface = match[1];
|
||||||
|
this.validateInterfaceName(iface, 'default route interface');
|
||||||
|
this.defaultRouteInterface = iface;
|
||||||
|
return iface;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure the Linux bridge is created and configured.
|
* Ensure the Linux bridge is created and configured.
|
||||||
*/
|
*/
|
||||||
@@ -110,25 +217,45 @@ export class NetworkManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if bridge already exists
|
// Check if bridge already exists
|
||||||
const result = await this.shell.exec(`ip link show ${this.bridgeName} 2>/dev/null`);
|
const result = await this.run('ip', ['link', 'show', this.bridgeName]);
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
// Create bridge
|
// Create bridge
|
||||||
await this.shell.exec(`ip link add ${this.bridgeName} type bridge`);
|
await this.runChecked('ip', ['link', 'add', this.bridgeName, 'type', 'bridge']);
|
||||||
await this.shell.exec(`ip addr add ${this.gatewayIp}/${this.subnetCidr} dev ${this.bridgeName}`);
|
await this.runChecked('ip', ['addr', 'add', `${this.gatewayIp}/${this.subnetCidr}`, 'dev', this.bridgeName]);
|
||||||
await this.shell.exec(`ip link set ${this.bridgeName} up`);
|
await this.runChecked('ip', ['link', 'set', this.bridgeName, 'up']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable IP forwarding
|
// Enable IP forwarding
|
||||||
await this.shell.exec('sysctl -w net.ipv4.ip_forward=1');
|
await this.runChecked('sysctl', ['-w', 'net.ipv4.ip_forward=1']);
|
||||||
|
|
||||||
// Set up NAT masquerade (idempotent with -C check)
|
// Set up NAT masquerade (idempotent with -C check)
|
||||||
const checkResult = await this.shell.exec(
|
const defaultIface = await this.getDefaultRouteInterface();
|
||||||
`iptables -t nat -C POSTROUTING -s ${this.subnetBase}/${this.subnetCidr} -o $(ip route | grep default | awk '{print $5}' | head -1) -j MASQUERADE 2>/dev/null`,
|
const natArgs = [
|
||||||
);
|
'-t',
|
||||||
|
'nat',
|
||||||
|
'-C',
|
||||||
|
'POSTROUTING',
|
||||||
|
'-s',
|
||||||
|
`${this.subnetBase}/${this.subnetCidr}`,
|
||||||
|
'-o',
|
||||||
|
defaultIface,
|
||||||
|
'-j',
|
||||||
|
'MASQUERADE',
|
||||||
|
];
|
||||||
|
const checkResult = await this.run('iptables', natArgs);
|
||||||
if (checkResult.exitCode !== 0) {
|
if (checkResult.exitCode !== 0) {
|
||||||
await this.shell.exec(
|
await this.runChecked('iptables', [
|
||||||
`iptables -t nat -A POSTROUTING -s ${this.subnetBase}/${this.subnetCidr} -o $(ip route | grep default | awk '{print $5}' | head -1) -j MASQUERADE`,
|
'-t',
|
||||||
);
|
'nat',
|
||||||
|
'-A',
|
||||||
|
'POSTROUTING',
|
||||||
|
'-s',
|
||||||
|
`${this.subnetBase}/${this.subnetCidr}`,
|
||||||
|
'-o',
|
||||||
|
defaultIface,
|
||||||
|
'-j',
|
||||||
|
'MASQUERADE',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.bridgeCreated = true;
|
this.bridgeCreated = true;
|
||||||
@@ -148,16 +275,19 @@ export class NetworkManager {
|
|||||||
await this.ensureBridge();
|
await this.ensureBridge();
|
||||||
|
|
||||||
const tapName = this.generateTapName(vmId, ifaceId);
|
const tapName = this.generateTapName(vmId, ifaceId);
|
||||||
|
this.validateInterfaceName(tapName, 'tapName');
|
||||||
const guestIp = this.allocateIp();
|
const guestIp = this.allocateIp();
|
||||||
const mac = this.generateMac(vmId, ifaceId);
|
const mac = this.generateMac(vmId, ifaceId);
|
||||||
|
let tapCreated = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create TAP device
|
// Create TAP device
|
||||||
await this.shell.exec(`ip tuntap add dev ${tapName} mode tap`);
|
await this.runChecked('ip', ['tuntap', 'add', 'dev', tapName, 'mode', 'tap']);
|
||||||
|
tapCreated = true;
|
||||||
// Attach to bridge
|
// Attach to bridge
|
||||||
await this.shell.exec(`ip link set ${tapName} master ${this.bridgeName}`);
|
await this.runChecked('ip', ['link', 'set', tapName, 'master', this.bridgeName]);
|
||||||
// Bring TAP device up
|
// Bring TAP device up
|
||||||
await this.shell.exec(`ip link set ${tapName} up`);
|
await this.runChecked('ip', ['link', 'set', tapName, 'up']);
|
||||||
|
|
||||||
const tap: ITapDevice = {
|
const tap: ITapDevice = {
|
||||||
tapName,
|
tapName,
|
||||||
@@ -170,6 +300,9 @@ export class NetworkManager {
|
|||||||
this.activeTaps.set(tapName, tap);
|
this.activeTaps.set(tapName, tap);
|
||||||
return tap;
|
return tap;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (tapCreated) {
|
||||||
|
await this.removeTapDevice(tapName);
|
||||||
|
}
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`Failed to create TAP device ${tapName}: ${message}`,
|
`Failed to create TAP device ${tapName}: ${message}`,
|
||||||
@@ -182,8 +315,9 @@ export class NetworkManager {
|
|||||||
* Remove a TAP device and free its resources.
|
* Remove a TAP device and free its resources.
|
||||||
*/
|
*/
|
||||||
public async removeTapDevice(tapName: string): Promise<void> {
|
public async removeTapDevice(tapName: string): Promise<void> {
|
||||||
|
this.validateInterfaceName(tapName, 'tapName');
|
||||||
try {
|
try {
|
||||||
await this.shell.exec(`ip link del ${tapName} 2>/dev/null`);
|
await this.run('ip', ['link', 'del', tapName]);
|
||||||
this.activeTaps.delete(tapName);
|
this.activeTaps.delete(tapName);
|
||||||
} catch {
|
} catch {
|
||||||
// Device may already be gone
|
// Device may already be gone
|
||||||
@@ -211,24 +345,34 @@ export class NetworkManager {
|
|||||||
*/
|
*/
|
||||||
public async cleanup(): Promise<void> {
|
public async cleanup(): Promise<void> {
|
||||||
// Remove all TAP devices
|
// Remove all TAP devices
|
||||||
for (const tapName of this.activeTaps.keys()) {
|
for (const tapName of Array.from(this.activeTaps.keys())) {
|
||||||
await this.removeTapDevice(tapName);
|
await this.removeTapDevice(tapName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove bridge if we created it
|
// Remove bridge if we created it
|
||||||
if (this.bridgeCreated) {
|
if (this.bridgeCreated) {
|
||||||
try {
|
try {
|
||||||
await this.shell.exec(`ip link set ${this.bridgeName} down 2>/dev/null`);
|
await this.run('ip', ['link', 'set', this.bridgeName, 'down']);
|
||||||
await this.shell.exec(`ip link del ${this.bridgeName} 2>/dev/null`);
|
await this.run('ip', ['link', 'del', this.bridgeName]);
|
||||||
} catch {
|
} catch {
|
||||||
// Bridge may already be gone
|
// Bridge may already be gone
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove NAT rule
|
// Remove NAT rule
|
||||||
try {
|
try {
|
||||||
await this.shell.exec(
|
const defaultIface = this.defaultRouteInterface || await this.getDefaultRouteInterface();
|
||||||
`iptables -t nat -D POSTROUTING -s ${this.subnetBase}/${this.subnetCidr} -o $(ip route | grep default | awk '{print $5}' | head -1) -j MASQUERADE 2>/dev/null`,
|
await this.run('iptables', [
|
||||||
);
|
'-t',
|
||||||
|
'nat',
|
||||||
|
'-D',
|
||||||
|
'POSTROUTING',
|
||||||
|
'-s',
|
||||||
|
`${this.subnetBase}/${this.subnetCidr}`,
|
||||||
|
'-o',
|
||||||
|
defaultIface,
|
||||||
|
'-j',
|
||||||
|
'MASQUERADE',
|
||||||
|
]);
|
||||||
} catch {
|
} catch {
|
||||||
// Rule may not exist
|
// Rule may not exist
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-3
@@ -1,16 +1,22 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
import type { ISmartVMOptions, IMicroVMConfig } from './interfaces/index.js';
|
import type { IBaseImageBundle, IEnsureBaseImageOptions, ISmartVMOptions, IMicroVMConfig } from './interfaces/index.js';
|
||||||
import { SmartVMError } from './interfaces/index.js';
|
import { SmartVMError } from './interfaces/index.js';
|
||||||
import { ImageManager } from './classes.imagemanager.js';
|
import { ImageManager } from './classes.imagemanager.js';
|
||||||
|
import { BaseImageManager } from './classes.baseimagemanager.js';
|
||||||
import { NetworkManager } from './classes.networkmanager.js';
|
import { NetworkManager } from './classes.networkmanager.js';
|
||||||
import { MicroVM } from './classes.microvm.js';
|
import { MicroVM } from './classes.microvm.js';
|
||||||
|
|
||||||
|
function getErrorMessage(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-level orchestrator for creating and managing Firecracker MicroVMs.
|
* Top-level orchestrator for creating and managing Firecracker MicroVMs.
|
||||||
*/
|
*/
|
||||||
export class SmartVM {
|
export class SmartVM {
|
||||||
private options: ISmartVMOptions;
|
private options: ISmartVMOptions;
|
||||||
public imageManager: ImageManager;
|
public imageManager: ImageManager;
|
||||||
|
public baseImageManager: BaseImageManager;
|
||||||
public networkManager: NetworkManager;
|
public networkManager: NetworkManager;
|
||||||
private activeVMs: Map<string, MicroVM> = new Map();
|
private activeVMs: Map<string, MicroVM> = new Map();
|
||||||
private smartExitInstance: InstanceType<typeof plugins.smartexit.SmartExit>;
|
private smartExitInstance: InstanceType<typeof plugins.smartexit.SmartExit>;
|
||||||
@@ -27,6 +33,13 @@ export class SmartVM {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.imageManager = new ImageManager(this.options.dataDir!, this.options.arch);
|
this.imageManager = new ImageManager(this.options.dataDir!, this.options.arch);
|
||||||
|
this.baseImageManager = new BaseImageManager({
|
||||||
|
arch: this.options.arch,
|
||||||
|
cacheDir: this.options.baseImageCacheDir,
|
||||||
|
maxStoredBaseImages: this.options.maxStoredBaseImages,
|
||||||
|
hostedManifestUrl: this.options.baseImageManifestUrl,
|
||||||
|
hostedManifestPath: this.options.baseImageManifestPath,
|
||||||
|
});
|
||||||
this.networkManager = new NetworkManager({
|
this.networkManager = new NetworkManager({
|
||||||
bridgeName: this.options.bridgeName,
|
bridgeName: this.options.bridgeName,
|
||||||
subnet: this.options.subnet,
|
subnet: this.options.subnet,
|
||||||
@@ -115,6 +128,13 @@ export class SmartVM {
|
|||||||
return vm;
|
return vm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a Firecracker CI base image bundle is available locally.
|
||||||
|
*/
|
||||||
|
public async ensureBaseImage(options: IEnsureBaseImageOptions = {}): Promise<IBaseImageBundle> {
|
||||||
|
return this.baseImageManager.ensureBaseImage(options);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get an active VM by ID.
|
* Get an active VM by ID.
|
||||||
*/
|
*/
|
||||||
@@ -145,7 +165,7 @@ export class SmartVM {
|
|||||||
if (vm.state === 'running' || vm.state === 'paused') {
|
if (vm.state === 'running' || vm.state === 'paused') {
|
||||||
stopPromises.push(
|
stopPromises.push(
|
||||||
vm.stop().catch((err) => {
|
vm.stop().catch((err) => {
|
||||||
console.error(`Failed to stop VM ${vm.id}: ${err.message}`);
|
console.error(`Failed to stop VM ${vm.id}: ${getErrorMessage(err)}`);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -162,7 +182,7 @@ export class SmartVM {
|
|||||||
for (const vm of this.activeVMs.values()) {
|
for (const vm of this.activeVMs.values()) {
|
||||||
cleanupPromises.push(
|
cleanupPromises.push(
|
||||||
vm.cleanup().catch((err) => {
|
vm.cleanup().catch((err) => {
|
||||||
console.error(`Failed to clean up VM ${vm.id}: ${err.message}`);
|
console.error(`Failed to clean up VM ${vm.id}: ${getErrorMessage(err)}`);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-36
@@ -2,6 +2,10 @@ import * as plugins from './plugins.js';
|
|||||||
import type { ISocketClientOptions, IApiResponse } from './interfaces/index.js';
|
import type { ISocketClientOptions, IApiResponse } from './interfaces/index.js';
|
||||||
import { SmartVMError } from './interfaces/index.js';
|
import { SmartVMError } from './interfaces/index.js';
|
||||||
|
|
||||||
|
function getErrorMessage(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP client that communicates with Firecracker over a Unix domain socket.
|
* HTTP client that communicates with Firecracker over a Unix domain socket.
|
||||||
* Uses @push.rocks/smartrequest with the `http://unix:<socket>:<path>` URL format.
|
* Uses @push.rocks/smartrequest with the `http://unix:<socket>:<path>` URL format.
|
||||||
@@ -20,6 +24,22 @@ export class SocketClient {
|
|||||||
return `http://unix:${this.socketPath}:${apiPath}`;
|
return `http://unix:${this.socketPath}:${apiPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async parseResponseBody<T>(response: any): Promise<T> {
|
||||||
|
try {
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
} catch {
|
||||||
|
return text as T;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform a GET request.
|
* Perform a GET request.
|
||||||
*/
|
*/
|
||||||
@@ -31,12 +51,7 @@ export class SocketClient {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
const statusCode = response.status;
|
const statusCode = response.status;
|
||||||
let body: T;
|
const body = await this.parseResponseBody<T>(response);
|
||||||
try {
|
|
||||||
body = await response.json() as T;
|
|
||||||
} catch {
|
|
||||||
body = undefined as any;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
statusCode,
|
statusCode,
|
||||||
body,
|
body,
|
||||||
@@ -44,7 +59,7 @@ export class SocketClient {
|
|||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`GET ${apiPath} failed: ${(err as Error).message}`,
|
`GET ${apiPath} failed: ${getErrorMessage(err)}`,
|
||||||
'SOCKET_REQUEST_FAILED',
|
'SOCKET_REQUEST_FAILED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -54,21 +69,19 @@ export class SocketClient {
|
|||||||
* Perform a PUT request with a JSON body.
|
* Perform a PUT request with a JSON body.
|
||||||
*/
|
*/
|
||||||
public async put<T = any>(apiPath: string, body?: Record<string, any>): Promise<IApiResponse<T>> {
|
public async put<T = any>(apiPath: string, body?: Record<string, any>): Promise<IApiResponse<T>> {
|
||||||
const url = this.buildUrl(apiPath);
|
const url = this.buildUrl(apiPath);
|
||||||
try {
|
try {
|
||||||
let request = plugins.SmartRequest.create().url(url);
|
let request = plugins.SmartRequest.create().url(url);
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
request = request.json(body);
|
const bodyBuffer = Buffer.from(JSON.stringify(body));
|
||||||
}
|
request = request
|
||||||
|
.buffer(bodyBuffer, 'application/json')
|
||||||
|
.header('Content-Length', String(bodyBuffer.length));
|
||||||
|
}
|
||||||
const response = await request.put();
|
const response = await request.put();
|
||||||
|
|
||||||
const statusCode = response.status;
|
const statusCode = response.status;
|
||||||
let responseBody: T;
|
const responseBody = await this.parseResponseBody<T>(response);
|
||||||
try {
|
|
||||||
responseBody = await response.json() as T;
|
|
||||||
} catch {
|
|
||||||
responseBody = undefined as any;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
statusCode,
|
statusCode,
|
||||||
body: responseBody,
|
body: responseBody,
|
||||||
@@ -76,7 +89,7 @@ export class SocketClient {
|
|||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`PUT ${apiPath} failed: ${(err as Error).message}`,
|
`PUT ${apiPath} failed: ${getErrorMessage(err)}`,
|
||||||
'SOCKET_REQUEST_FAILED',
|
'SOCKET_REQUEST_FAILED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -86,21 +99,19 @@ export class SocketClient {
|
|||||||
* Perform a PATCH request with a JSON body.
|
* Perform a PATCH request with a JSON body.
|
||||||
*/
|
*/
|
||||||
public async patch<T = any>(apiPath: string, body?: Record<string, any>): Promise<IApiResponse<T>> {
|
public async patch<T = any>(apiPath: string, body?: Record<string, any>): Promise<IApiResponse<T>> {
|
||||||
const url = this.buildUrl(apiPath);
|
const url = this.buildUrl(apiPath);
|
||||||
try {
|
try {
|
||||||
let request = plugins.SmartRequest.create().url(url);
|
let request = plugins.SmartRequest.create().url(url);
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
request = request.json(body);
|
const bodyBuffer = Buffer.from(JSON.stringify(body));
|
||||||
}
|
request = request
|
||||||
|
.buffer(bodyBuffer, 'application/json')
|
||||||
|
.header('Content-Length', String(bodyBuffer.length));
|
||||||
|
}
|
||||||
const response = await request.patch();
|
const response = await request.patch();
|
||||||
|
|
||||||
const statusCode = response.status;
|
const statusCode = response.status;
|
||||||
let responseBody: T;
|
const responseBody = await this.parseResponseBody<T>(response);
|
||||||
try {
|
|
||||||
responseBody = await response.json() as T;
|
|
||||||
} catch {
|
|
||||||
responseBody = undefined as any;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
statusCode,
|
statusCode,
|
||||||
body: responseBody,
|
body: responseBody,
|
||||||
@@ -108,21 +119,21 @@ export class SocketClient {
|
|||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`PATCH ${apiPath} failed: ${(err as Error).message}`,
|
`PATCH ${apiPath} failed: ${getErrorMessage(err)}`,
|
||||||
'SOCKET_REQUEST_FAILED',
|
'SOCKET_REQUEST_FAILED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the Firecracker API socket is ready by polling GET /.
|
* Check if the Firecracker API socket is ready by polling GET /version.
|
||||||
*/
|
*/
|
||||||
public async isReady(timeoutMs: number = 5000): Promise<boolean> {
|
public async isReady(timeoutMs: number = 5000): Promise<boolean> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
while (Date.now() - start < timeoutMs) {
|
while (Date.now() - start < timeoutMs) {
|
||||||
try {
|
try {
|
||||||
const response = await this.get('/');
|
const response = await this.get('/version');
|
||||||
if (response.ok || response.statusCode === 200 || response.statusCode === 400) {
|
if (response.ok) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+38
-1
@@ -12,7 +12,44 @@ export class VMConfig {
|
|||||||
public config: IMicroVMConfig;
|
public config: IMicroVMConfig;
|
||||||
|
|
||||||
constructor(config: IMicroVMConfig) {
|
constructor(config: IMicroVMConfig) {
|
||||||
this.config = config;
|
this.config = this.cloneConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep internal normalization from mutating the caller's config object.
|
||||||
|
*/
|
||||||
|
private cloneConfig(config: IMicroVMConfig): IMicroVMConfig {
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
bootSource: config.bootSource ? { ...config.bootSource } : config.bootSource,
|
||||||
|
machineConfig: config.machineConfig ? { ...config.machineConfig } : config.machineConfig,
|
||||||
|
drives: config.drives?.map((drive) => ({
|
||||||
|
...drive,
|
||||||
|
rateLimiter: drive.rateLimiter ? this.cloneRateLimiter(drive.rateLimiter) : undefined,
|
||||||
|
})),
|
||||||
|
networkInterfaces: config.networkInterfaces?.map((iface) => ({
|
||||||
|
...iface,
|
||||||
|
rxRateLimiter: iface.rxRateLimiter ? this.cloneRateLimiter(iface.rxRateLimiter) : undefined,
|
||||||
|
txRateLimiter: iface.txRateLimiter ? this.cloneRateLimiter(iface.txRateLimiter) : undefined,
|
||||||
|
})),
|
||||||
|
vsock: config.vsock ? { ...config.vsock } : undefined,
|
||||||
|
balloon: config.balloon ? { ...config.balloon } : undefined,
|
||||||
|
mmds: config.mmds ? {
|
||||||
|
...config.mmds,
|
||||||
|
networkInterfaces: config.mmds.networkInterfaces
|
||||||
|
? [...config.mmds.networkInterfaces]
|
||||||
|
: config.mmds.networkInterfaces,
|
||||||
|
} : undefined,
|
||||||
|
logger: config.logger ? { ...config.logger } : undefined,
|
||||||
|
metrics: config.metrics ? { ...config.metrics } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneRateLimiter(rateLimiter: IRateLimiter): IRateLimiter {
|
||||||
|
return {
|
||||||
|
bandwidth: rateLimiter.bandwidth ? { ...rateLimiter.bandwidth } : undefined,
|
||||||
|
ops: rateLimiter.ops ? { ...rateLimiter.ops } : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export * from './interfaces/index.js';
|
|||||||
export { VMConfig } from './classes.vmconfig.js';
|
export { VMConfig } from './classes.vmconfig.js';
|
||||||
export { SocketClient } from './classes.socketclient.js';
|
export { SocketClient } from './classes.socketclient.js';
|
||||||
export { ImageManager } from './classes.imagemanager.js';
|
export { ImageManager } from './classes.imagemanager.js';
|
||||||
|
export { BaseImageManager } from './classes.baseimagemanager.js';
|
||||||
export { FirecrackerProcess } from './classes.firecrackerprocess.js';
|
export { FirecrackerProcess } from './classes.firecrackerprocess.js';
|
||||||
export { NetworkManager } from './classes.networkmanager.js';
|
export { NetworkManager } from './classes.networkmanager.js';
|
||||||
export { MicroVM } from './classes.microvm.js';
|
export { MicroVM } from './classes.microvm.js';
|
||||||
|
|||||||
+123
-2
@@ -16,6 +16,127 @@ export interface ISmartVMOptions {
|
|||||||
bridgeName?: string;
|
bridgeName?: string;
|
||||||
/** Network subnet in CIDR notation. Defaults to '172.30.0.0/24'. */
|
/** Network subnet in CIDR notation. Defaults to '172.30.0.0/24'. */
|
||||||
subnet?: string;
|
subnet?: string;
|
||||||
|
/** Directory for cached base images. Defaults to /tmp/.smartvm/base-images. */
|
||||||
|
baseImageCacheDir?: string;
|
||||||
|
/** Maximum number of cached base image bundles. Defaults to 2. */
|
||||||
|
maxStoredBaseImages?: number;
|
||||||
|
/** Hosted/project-owned base image manifest URL. */
|
||||||
|
baseImageManifestUrl?: string;
|
||||||
|
/** Local hosted/project-owned base image manifest path for development and tests. */
|
||||||
|
baseImageManifestPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Predefined base image sources for integration testing and quick starts.
|
||||||
|
*/
|
||||||
|
export type TBaseImagePreset = 'latest' | 'lts' | 'hosted';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root filesystem image type used by a base image bundle.
|
||||||
|
*/
|
||||||
|
export type TBaseImageRootfsType = 'ext4' | 'squashfs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for the BaseImageManager.
|
||||||
|
*/
|
||||||
|
export interface IBaseImageManagerOptions {
|
||||||
|
/** Architecture to resolve. Defaults to x86_64. */
|
||||||
|
arch?: TFirecrackerArch;
|
||||||
|
/** Directory for cached base image bundles. Defaults to /tmp/.smartvm/base-images. */
|
||||||
|
cacheDir?: string;
|
||||||
|
/** Maximum number of cached base image bundles. Defaults to 2. */
|
||||||
|
maxStoredBaseImages?: number;
|
||||||
|
/** Hosted base image manifest URL for project-owned bundles. */
|
||||||
|
hostedManifestUrl?: string;
|
||||||
|
/** Local hosted base image manifest path for development and tests. */
|
||||||
|
hostedManifestPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options when resolving or downloading a base image bundle.
|
||||||
|
*/
|
||||||
|
export interface IEnsureBaseImageOptions {
|
||||||
|
/** Preset to use. Defaults to latest. */
|
||||||
|
preset?: TBaseImagePreset;
|
||||||
|
/** Architecture to resolve. Defaults to manager architecture. */
|
||||||
|
arch?: TFirecrackerArch;
|
||||||
|
/** Redownload even if the bundle already exists locally. */
|
||||||
|
forceDownload?: boolean;
|
||||||
|
/** Hosted base image manifest URL. Overrides preset resolution. */
|
||||||
|
manifestUrl?: string;
|
||||||
|
/** Local hosted base image manifest path. Overrides preset resolution. */
|
||||||
|
manifestPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single hosted base image artifact in a manifest.
|
||||||
|
*/
|
||||||
|
export interface IBaseImageArtifactManifest {
|
||||||
|
/** Public URL for hosted artifacts. */
|
||||||
|
url?: string;
|
||||||
|
/** Local path for development/tests. */
|
||||||
|
path?: string;
|
||||||
|
/** Optional plain output filename. Defaults to basename of url/path. */
|
||||||
|
fileName?: string;
|
||||||
|
/** Expected SHA256 for verification. Required when url is used. */
|
||||||
|
sha256?: string;
|
||||||
|
/** Expected file size in bytes. */
|
||||||
|
sizeBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosted/project-owned base image manifest format.
|
||||||
|
*/
|
||||||
|
export interface IBaseImageHostedManifest {
|
||||||
|
schemaVersion: 1;
|
||||||
|
bundleId: string;
|
||||||
|
name?: string;
|
||||||
|
arch: TFirecrackerArch;
|
||||||
|
firecrackerVersion: string;
|
||||||
|
rootfsType: TBaseImageRootfsType;
|
||||||
|
rootfsIsReadOnly?: boolean;
|
||||||
|
bootArgs?: string;
|
||||||
|
kernel: IBaseImageArtifactManifest;
|
||||||
|
rootfs: IBaseImageArtifactManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached base image bundle metadata.
|
||||||
|
*/
|
||||||
|
export interface IBaseImageBundle {
|
||||||
|
preset: TBaseImagePreset;
|
||||||
|
arch: TFirecrackerArch;
|
||||||
|
ciVersion: string;
|
||||||
|
firecrackerVersion: string;
|
||||||
|
bundleId: string;
|
||||||
|
bundleDir: string;
|
||||||
|
kernelImagePath: string;
|
||||||
|
rootfsPath: string;
|
||||||
|
rootfsType: TBaseImageRootfsType;
|
||||||
|
rootfsIsReadOnly: boolean;
|
||||||
|
bootArgs: string;
|
||||||
|
source: {
|
||||||
|
type?: 'firecracker-ci' | 'hosted-manifest';
|
||||||
|
bucketUrl?: string;
|
||||||
|
kernelKey?: string;
|
||||||
|
rootfsKey?: string;
|
||||||
|
manifestUrl?: string;
|
||||||
|
manifestPath?: string;
|
||||||
|
kernelUrl?: string;
|
||||||
|
rootfsUrl?: string;
|
||||||
|
kernelSourcePath?: string;
|
||||||
|
rootfsSourcePath?: string;
|
||||||
|
};
|
||||||
|
checksums?: {
|
||||||
|
kernelSha256?: string;
|
||||||
|
rootfsSha256?: string;
|
||||||
|
};
|
||||||
|
sizes?: {
|
||||||
|
kernelBytes?: number;
|
||||||
|
rootfsBytes?: number;
|
||||||
|
};
|
||||||
|
createdAt: string;
|
||||||
|
lastAccessedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -142,9 +263,9 @@ export interface ILoggerConfig {
|
|||||||
logPath: string;
|
logPath: string;
|
||||||
/** Log level. */
|
/** Log level. */
|
||||||
level?: TLogLevel;
|
level?: TLogLevel;
|
||||||
/** Whether to show log origin (file, line). */
|
|
||||||
showLevel?: boolean;
|
|
||||||
/** Whether to show log level. */
|
/** Whether to show log level. */
|
||||||
|
showLevel?: boolean;
|
||||||
|
/** Whether to show log origin (file, line). */
|
||||||
showLogOrigin?: boolean;
|
showLogOrigin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -2,8 +2,9 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
export { fs, path, os };
|
export { fs, path, os, crypto };
|
||||||
|
|
||||||
// @push.rocks scope
|
// @push.rocks scope
|
||||||
import * as smartdelay from '@push.rocks/smartdelay';
|
import * as smartdelay from '@push.rocks/smartdelay';
|
||||||
|
|||||||
Reference in New Issue
Block a user