Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cdb8571a4 | |||
| 9d0a57c5de | |||
| 0ace928886 | |||
| ad73e8ecb8 |
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"@git.zone/cli": {
|
||||
"projectType": "npm",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
"gitscope": "push.rocks",
|
||||
"gitrepo": "smartvm",
|
||||
"description": "A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs",
|
||||
"npmPackagename": "@push.rocks/smartvm",
|
||||
"license": "MIT",
|
||||
"projectDomain": "push.rocks"
|
||||
},
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
},
|
||||
"@git.zone/tsdoc": {
|
||||
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||
},
|
||||
"@ship.zone/szci": {
|
||||
"npmGlobalTools": [],
|
||||
"npmRegistryUrl": "registry.npmjs.org"
|
||||
}
|
||||
}
|
||||
@@ -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,21 @@
|
||||
# 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)
|
||||
tighten TypeScript compiler settings and harden error message handling
|
||||
|
||||
- enable strict noImplicitAny checks and include Node types in the TypeScript configuration
|
||||
- remove the implicit any override from the build script so compiler strictness is enforced during builds
|
||||
- handle non-Error thrown values safely when wrapping startup and network setup failures
|
||||
- update package metadata and bundled files to include project configuration and license assets
|
||||
|
||||
## 2026-02-08 - 1.1.0 - feat(release)
|
||||
add release configuration with npm registries and public access; add @ship.zone/szci placeholder
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016 Task Venture Capital GmbH
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+13
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartvm",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"private": false,
|
||||
"description": "A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs",
|
||||
"type": "module",
|
||||
@@ -14,7 +14,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"test": "(tstest test/ --verbose)",
|
||||
"build": "(tsbuild --web --allowimplicitany)"
|
||||
"build": "(tsbuild --web)"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -23,21 +23,25 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
"@push.rocks/smartexit": "^1.0.22",
|
||||
"@push.rocks/smartexit": "^2.0.3",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartshell": "^3.2.3",
|
||||
"@push.rocks/smartshell": "^3.3.8",
|
||||
"@push.rocks/smartunique": "^3.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.1.2",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tstest": "^3.1.8",
|
||||
"@types/node": "^25.2.2"
|
||||
"@git.zone/tsbuild": "^4.4.0",
|
||||
"@git.zone/tsrun": "^2.0.3",
|
||||
"@git.zone/tstest": "^3.6.3",
|
||||
"@types/node": "^25.6.0"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"dist_ts/**/*",
|
||||
"assets/**/*",
|
||||
".smartconfig.json",
|
||||
"license",
|
||||
"npmextra.json",
|
||||
"readme.md"
|
||||
]
|
||||
],
|
||||
"packageManager": "pnpm@10.28.2"
|
||||
}
|
||||
|
||||
Generated
+1545
-1902
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,9 @@
|
||||
- Uses `@push.rocks/smartrequest` with URL format `http://unix:<socket>:<path>` for socket communication
|
||||
- Uses `@push.rocks/smartshell` `execStreaming()` to manage Firecracker child processes
|
||||
- Uses `@push.rocks/smartexit` for cleanup on process exit
|
||||
- `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
|
||||
- SmartRequest: `SmartRequest.create().url(...).json(body).put()` — response has `.status`, `.ok`, `.json()`
|
||||
@@ -16,3 +19,9 @@
|
||||
- Start: PUT /actions { action_type: "InstanceStart" }
|
||||
- Pause/Resume: PATCH /vm { state: "Paused" | "Resumed" }
|
||||
- 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.
|
||||
|
||||
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
|
||||
|
||||
```typescript
|
||||
@@ -118,6 +126,8 @@ const smartvm = new SmartVM({
|
||||
firecrackerVersion: 'v1.7.0', // default: latest from GitHub
|
||||
arch: 'x86_64', // default: x86_64 (also: aarch64)
|
||||
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
|
||||
subnet: '172.30.0.0/24', // default: 172.30.0.0/24
|
||||
});
|
||||
@@ -126,6 +136,7 @@ const smartvm = new SmartVM({
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `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. |
|
||||
| `getVM(id)` | Look up an active VM by ID. |
|
||||
| `listVMs()` | Returns an array of active VM IDs. |
|
||||
@@ -249,6 +260,57 @@ const clonePath = await imageManager.cloneRootfs(rootfsPath, 'ubuntu-clone.ext4'
|
||||
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
|
||||
|
||||
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:**
|
||||
- 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
|
||||
- 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
|
||||
- Deterministic MAC generation (`02:xx:xx:xx:xx:xx` locally-administered)
|
||||
- TAP names fit Linux's 15-char IFNAMSIZ limit
|
||||
@@ -352,6 +415,14 @@ try {
|
||||
| `BINARY_NOT_FOUND` | Firecracker binary not at expected path |
|
||||
| `DOWNLOAD_FAILED` | Failed to download binary/kernel/rootfs |
|
||||
| `VERSION_FETCH_FAILED` | Couldn't query GitHub for latest version |
|
||||
| `BASE_IMAGE_RESOLVE_FAILED` | Failed to resolve Firecracker CI base image artifacts |
|
||||
| `BASE_IMAGE_MANIFEST_FAILED` | Failed to load or use a hosted base image manifest |
|
||||
| `BASE_IMAGE_PREPARE_FAILED` | Failed to download or prepare a base image bundle |
|
||||
| `INVALID_BASE_IMAGE_MANIFEST` | Hosted base image manifest is invalid |
|
||||
| `INVALID_BASE_IMAGE_CACHE_LIMIT` | Base image cache retention limit is invalid |
|
||||
| `INVALID_SUBNET` | Subnet is not a supported IPv4 CIDR range |
|
||||
| `INVALID_INTERFACE_NAME` | Bridge or TAP interface name is invalid |
|
||||
| `IP_EXHAUSTED` | No guest IP addresses remain in the configured subnet |
|
||||
| `BRIDGE_SETUP_FAILED` | Failed to create network bridge |
|
||||
| `TAP_CREATE_FAILED` | Failed to create TAP device |
|
||||
| `ROOTFS_CREATE_FAILED` | Failed to create blank rootfs |
|
||||
@@ -434,6 +505,36 @@ await smartvm.stopAll();
|
||||
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
|
||||
|
||||
All configuration interfaces are fully exported for type-safe usage:
|
||||
@@ -451,6 +552,11 @@ import type {
|
||||
IMmdsConfig,
|
||||
ILoggerConfig,
|
||||
IMetricsConfig,
|
||||
IBaseImageManagerOptions,
|
||||
IEnsureBaseImageOptions,
|
||||
IBaseImageBundle,
|
||||
IBaseImageHostedManifest,
|
||||
IBaseImageArtifactManifest,
|
||||
ISnapshotCreateParams,
|
||||
ISnapshotLoadParams,
|
||||
IRateLimiter,
|
||||
@@ -463,6 +569,8 @@ import type {
|
||||
TCacheType,
|
||||
TSnapshotType,
|
||||
TLogLevel,
|
||||
TBaseImagePreset,
|
||||
TBaseImageRootfsType,
|
||||
} 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 {
|
||||
BaseImageManager,
|
||||
VMConfig,
|
||||
SocketClient,
|
||||
NetworkManager,
|
||||
MicroVM,
|
||||
SmartVM,
|
||||
SmartVMError,
|
||||
} 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
|
||||
@@ -87,6 +107,33 @@ tap.test('VMConfig - validate() should fail for multiple root drives', async ()
|
||||
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 () => {
|
||||
const vmConfig = new VMConfig(sampleConfig);
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const config: IMicroVMConfig = {
|
||||
...sampleConfig,
|
||||
@@ -167,6 +273,269 @@ tap.test('SocketClient - URL construction', async () => {
|
||||
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
|
||||
// ============================================================
|
||||
@@ -181,6 +550,65 @@ tap.test('NetworkManager - allocateIp() should allocate sequential IPs', async (
|
||||
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 () => {
|
||||
const nm = new NetworkManager();
|
||||
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');
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 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
|
||||
// ============================================================
|
||||
@@ -236,6 +686,7 @@ tap.test('SmartVM - instantiation with defaults', async () => {
|
||||
const smartvm = new SmartVM();
|
||||
expect(smartvm).toBeTruthy();
|
||||
expect(smartvm.imageManager).toBeTruthy();
|
||||
expect(smartvm.baseImageManager).toBeTruthy();
|
||||
expect(smartvm.networkManager).toBeTruthy();
|
||||
expect(smartvm.vmCount).toEqual(0);
|
||||
expect(smartvm.listVMs()).toHaveLength(0);
|
||||
@@ -251,4 +702,19 @@ tap.test('SmartVM - instantiation with custom options', async () => {
|
||||
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();
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartvm',
|
||||
version: '1.1.0',
|
||||
version: '1.2.0',
|
||||
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 { 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.
|
||||
*/
|
||||
export class FirecrackerProcess {
|
||||
private options: IFirecrackerProcessOptions;
|
||||
private streaming: any | null = null;
|
||||
private streaming: TStreamingResult | null = null;
|
||||
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
||||
private smartExitInstance: InstanceType<typeof plugins.smartexit.SmartExit> | null = null;
|
||||
private lastExitResult: TExecResult | null = null;
|
||||
private lastExitError: string | null = null;
|
||||
public socketClient: SocketClient;
|
||||
|
||||
constructor(options: IFirecrackerProcessOptions) {
|
||||
@@ -28,14 +37,21 @@ export class FirecrackerProcess {
|
||||
plugins.fs.unlinkSync(this.options.socketPath);
|
||||
}
|
||||
|
||||
// Build the command
|
||||
let cmd = `${this.options.binaryPath} --api-sock ${this.options.socketPath}`;
|
||||
// Build the command args without a shell so paths are not interpreted.
|
||||
const args = ['--api-sock', this.options.socketPath];
|
||||
if (this.options.logLevel) {
|
||||
cmd += ` --level ${this.options.logLevel}`;
|
||||
args.push('--level', this.options.logLevel);
|
||||
}
|
||||
|
||||
// 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
|
||||
if (this.streaming?.childProcess) {
|
||||
@@ -46,9 +62,11 @@ export class FirecrackerProcess {
|
||||
// Wait for the socket file to appear
|
||||
const socketReady = await this.waitForSocket(10000);
|
||||
if (!socketReady) {
|
||||
const wasRunning = this.isRunning();
|
||||
const diagnostics = this.formatDiagnostics();
|
||||
await this.stop();
|
||||
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',
|
||||
);
|
||||
}
|
||||
@@ -56,9 +74,10 @@ export class FirecrackerProcess {
|
||||
// Wait for the API to be responsive
|
||||
const apiReady = await this.socketClient.isReady(5000);
|
||||
if (!apiReady) {
|
||||
const diagnostics = this.formatDiagnostics();
|
||||
await this.stop();
|
||||
throw new SmartVMError(
|
||||
'Firecracker API did not become responsive within timeout',
|
||||
`Firecracker API did not become responsive within timeout${diagnostics}`,
|
||||
'API_TIMEOUT',
|
||||
);
|
||||
}
|
||||
@@ -73,36 +92,69 @@ export class FirecrackerProcess {
|
||||
if (plugins.fs.existsSync(this.options.socketPath)) {
|
||||
return true;
|
||||
}
|
||||
if (this.streaming && !this.isRunning()) {
|
||||
return false;
|
||||
}
|
||||
await plugins.smartdelay.delayFor(100);
|
||||
}
|
||||
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.
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.streaming) return;
|
||||
const streaming = this.streaming;
|
||||
if (!streaming) return;
|
||||
|
||||
try {
|
||||
// Try graceful termination first
|
||||
await this.streaming.terminate();
|
||||
await streaming.terminate();
|
||||
|
||||
// Wait up to 5 seconds for the process to exit
|
||||
const exitPromise = Promise.race([
|
||||
this.streaming.finalPromise,
|
||||
plugins.smartdelay.delayFor(5000),
|
||||
]);
|
||||
await exitPromise;
|
||||
const terminated = await this.waitForExit(streaming, 5000);
|
||||
if (!terminated) {
|
||||
await streaming.kill();
|
||||
await this.waitForExit(streaming, 1000);
|
||||
}
|
||||
} catch {
|
||||
// If termination fails, force kill
|
||||
try {
|
||||
await this.streaming.kill();
|
||||
await streaming.kill();
|
||||
await this.waitForExit(streaming, 1000);
|
||||
} catch {
|
||||
// Process may already be dead
|
||||
}
|
||||
}
|
||||
|
||||
if (this.smartExitInstance) {
|
||||
this.smartExitInstance.removeProcess(streaming.childProcess);
|
||||
this.smartExitInstance = null;
|
||||
}
|
||||
this.streaming = null;
|
||||
}
|
||||
|
||||
@@ -122,10 +174,11 @@ export class FirecrackerProcess {
|
||||
* Check if the process is currently running.
|
||||
*/
|
||||
public isRunning(): boolean {
|
||||
if (!this.streaming?.childProcess) return false;
|
||||
const pid = this.streaming?.childProcess?.pid;
|
||||
if (!pid) return false;
|
||||
try {
|
||||
// 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;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
+51
-27
@@ -2,6 +2,12 @@ import * as plugins from './plugins.js';
|
||||
import type { TFirecrackerArch } 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.
|
||||
*/
|
||||
@@ -21,10 +27,21 @@ async function pathExists(filePath: string): Promise<boolean> {
|
||||
export class ImageManager {
|
||||
private dataDir: string;
|
||||
private arch: TFirecrackerArch;
|
||||
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
||||
|
||||
constructor(dataDir: string, arch: TFirecrackerArch = 'x86_64') {
|
||||
this.dataDir = dataDir;
|
||||
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> {
|
||||
try {
|
||||
const response = await plugins.SmartRequest.create()
|
||||
.url('https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest')
|
||||
.get();
|
||||
const data = await response.json() as { tag_name: string };
|
||||
return data.tag_name;
|
||||
const result = await this.runChecked('curl', [
|
||||
'-fsSLI',
|
||||
'-o',
|
||||
'/dev/null',
|
||||
'-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) {
|
||||
throw new SmartVMError(
|
||||
`Failed to fetch latest Firecracker version: ${(err as Error).message}`,
|
||||
`Failed to fetch latest Firecracker version: ${getErrorMessage(err)}`,
|
||||
'VERSION_FETCH_FAILED',
|
||||
);
|
||||
}
|
||||
@@ -119,11 +144,10 @@ export class ImageManager {
|
||||
|
||||
try {
|
||||
// Download the archive
|
||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||
await shell.exec(`curl -fSL -o "${archivePath}" "${downloadUrl}"`);
|
||||
await this.runChecked('curl', ['-fSL', '-o', archivePath, downloadUrl]);
|
||||
|
||||
// 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/
|
||||
// with binaries named like firecracker-v1.5.0-x86_64
|
||||
@@ -134,21 +158,25 @@ export class ImageManager {
|
||||
const jailerDst = this.getJailerPath(version);
|
||||
|
||||
// Move binaries to expected paths
|
||||
await shell.exec(`mv "${firecrackerSrc}" "${firecrackerDst}"`);
|
||||
await plugins.fs.promises.rename(firecrackerSrc, firecrackerDst);
|
||||
if (await pathExists(jailerSrc)) {
|
||||
await shell.exec(`mv "${jailerSrc}" "${jailerDst}"`);
|
||||
await plugins.fs.promises.rename(jailerSrc, jailerDst);
|
||||
}
|
||||
|
||||
// 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
|
||||
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;
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`Failed to download Firecracker ${version}: ${(err as Error).message}`,
|
||||
`Failed to download Firecracker ${version}: ${getErrorMessage(err)}`,
|
||||
'DOWNLOAD_FAILED',
|
||||
);
|
||||
}
|
||||
@@ -163,12 +191,11 @@ export class ImageManager {
|
||||
const kernelPath = plugins.path.join(kernelsDir, name);
|
||||
|
||||
try {
|
||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||
await shell.exec(`curl -fSL -o "${kernelPath}" "${url}"`);
|
||||
await this.runChecked('curl', ['-fSL', '-o', kernelPath, url]);
|
||||
return kernelPath;
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`Failed to download kernel from ${url}: ${(err as Error).message}`,
|
||||
`Failed to download kernel from ${url}: ${getErrorMessage(err)}`,
|
||||
'DOWNLOAD_FAILED',
|
||||
);
|
||||
}
|
||||
@@ -183,12 +210,11 @@ export class ImageManager {
|
||||
const rootfsPath = plugins.path.join(rootfsDir, name);
|
||||
|
||||
try {
|
||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||
await shell.exec(`curl -fSL -o "${rootfsPath}" "${url}"`);
|
||||
await this.runChecked('curl', ['-fSL', '-o', rootfsPath, url]);
|
||||
return rootfsPath;
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`Failed to download rootfs from ${url}: ${(err as Error).message}`,
|
||||
`Failed to download rootfs from ${url}: ${getErrorMessage(err)}`,
|
||||
'DOWNLOAD_FAILED',
|
||||
);
|
||||
}
|
||||
@@ -203,13 +229,12 @@ export class ImageManager {
|
||||
const rootfsPath = plugins.path.join(rootfsDir, name);
|
||||
|
||||
try {
|
||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||
await shell.exec(`dd if=/dev/zero of="${rootfsPath}" bs=1M count=${sizeMib}`);
|
||||
await shell.exec(`mkfs.ext4 "${rootfsPath}"`);
|
||||
await this.runChecked('dd', ['if=/dev/zero', `of=${rootfsPath}`, 'bs=1M', `count=${sizeMib}`]);
|
||||
await this.runChecked('mkfs.ext4', [rootfsPath]);
|
||||
return rootfsPath;
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`Failed to create blank rootfs: ${(err as Error).message}`,
|
||||
`Failed to create blank rootfs: ${getErrorMessage(err)}`,
|
||||
'ROOTFS_CREATE_FAILED',
|
||||
);
|
||||
}
|
||||
@@ -224,12 +249,11 @@ export class ImageManager {
|
||||
const targetPath = plugins.path.join(rootfsDir, targetName);
|
||||
|
||||
try {
|
||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||
await shell.exec(`cp "${sourcePath}" "${targetPath}"`);
|
||||
await plugins.fs.promises.copyFile(sourcePath, targetPath);
|
||||
return targetPath;
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`Failed to clone rootfs: ${(err as Error).message}`,
|
||||
`Failed to clone rootfs: ${getErrorMessage(err)}`,
|
||||
'ROOTFS_CLONE_FAILED',
|
||||
);
|
||||
}
|
||||
|
||||
+17
-12
@@ -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.
|
||||
* Validates config, starts the Firecracker process, applies pre-boot configuration, and boots the VM.
|
||||
@@ -155,8 +165,9 @@ export class MicroVM {
|
||||
if (err instanceof SmartVMError) {
|
||||
throw err;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new SmartVMError(
|
||||
`Failed to start VM ${this.id}: ${err.message}`,
|
||||
`Failed to start VM ${this.id}: ${message}`,
|
||||
'START_FAILED',
|
||||
);
|
||||
}
|
||||
@@ -243,7 +254,7 @@ export class MicroVM {
|
||||
*/
|
||||
public async getMetadata(): Promise<any> {
|
||||
this.assertState(['running', 'paused'], 'getMetadata');
|
||||
const response = await this.socketClient!.get('/mmds');
|
||||
const response = await this.getSocketClient('getMetadata').get('/mmds');
|
||||
return response.body;
|
||||
}
|
||||
|
||||
@@ -281,7 +292,7 @@ export class MicroVM {
|
||||
* Get VM instance info.
|
||||
*/
|
||||
public async getInfo(): Promise<any> {
|
||||
const response = await this.socketClient!.get('/');
|
||||
const response = await this.getSocketClient('getInfo').get('/');
|
||||
return response.body;
|
||||
}
|
||||
|
||||
@@ -289,7 +300,7 @@ export class MicroVM {
|
||||
* Get Firecracker version info.
|
||||
*/
|
||||
public async getVersion(): Promise<any> {
|
||||
const response = await this.socketClient!.get('/version');
|
||||
const response = await this.getSocketClient('getVersion').get('/version');
|
||||
return response.body;
|
||||
}
|
||||
|
||||
@@ -333,10 +344,7 @@ export class MicroVM {
|
||||
* Helper: PUT request with error handling.
|
||||
*/
|
||||
private async apiPut(path: string, body: Record<string, any>): Promise<void> {
|
||||
if (!this.socketClient) {
|
||||
throw new SmartVMError('Socket client not initialized', 'NO_CLIENT');
|
||||
}
|
||||
const response = await this.socketClient.put(path, body);
|
||||
const response = await this.getSocketClient(`PUT ${path}`).put(path, body);
|
||||
if (!response.ok) {
|
||||
throw new SmartVMError(
|
||||
`API PUT ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`,
|
||||
@@ -351,10 +359,7 @@ export class MicroVM {
|
||||
* Helper: PATCH request with error handling.
|
||||
*/
|
||||
private async apiPatch(path: string, body: Record<string, any>): Promise<void> {
|
||||
if (!this.socketClient) {
|
||||
throw new SmartVMError('Socket client not initialized', 'NO_CLIENT');
|
||||
}
|
||||
const response = await this.socketClient.patch(path, body);
|
||||
const response = await this.getSocketClient(`PATCH ${path}`).patch(path, body);
|
||||
if (!response.ok) {
|
||||
throw new SmartVMError(
|
||||
`API PATCH ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`,
|
||||
|
||||
+194
-48
@@ -2,6 +2,15 @@ import * as plugins from './plugins.js';
|
||||
import type { INetworkManagerOptions, ITapDevice } 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.
|
||||
* Creates TAP devices, Linux bridges, and configures NAT for VM internet access.
|
||||
@@ -12,53 +21,121 @@ export class NetworkManager {
|
||||
private subnetCidr: number;
|
||||
private gatewayIp: string;
|
||||
private subnetMask: string;
|
||||
private nextIpOctet: number;
|
||||
private nextIpAddress: number;
|
||||
private lastUsableIpAddress: number;
|
||||
private activeTaps: Map<string, ITapDevice> = new Map();
|
||||
private bridgeCreated: boolean = false;
|
||||
private defaultRouteInterface: string | null = null;
|
||||
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
||||
|
||||
constructor(options: INetworkManagerOptions = {}) {
|
||||
this.bridgeName = options.bridgeName || 'svbr0';
|
||||
this.validateInterfaceName(this.bridgeName, 'bridgeName');
|
||||
const subnet = options.subnet || '172.30.0.0/24';
|
||||
const parsedSubnet = this.parseSubnet(subnet);
|
||||
|
||||
// Parse the subnet
|
||||
const [baseIp, cidrStr] = subnet.split('/');
|
||||
this.subnetBase = baseIp;
|
||||
this.subnetCidr = parseInt(cidrStr, 10);
|
||||
this.subnetMask = this.cidrToSubnetMask(this.subnetCidr);
|
||||
|
||||
// Gateway is .1 in the subnet
|
||||
const parts = this.subnetBase.split('.').map(Number);
|
||||
parts[3] = 1;
|
||||
this.gatewayIp = parts.join('.');
|
||||
|
||||
// VMs start at .2
|
||||
this.nextIpOctet = 2;
|
||||
this.subnetBase = this.intToIp(parsedSubnet.networkAddress);
|
||||
this.subnetCidr = parsedSubnet.cidr;
|
||||
this.subnetMask = parsedSubnet.subnetMask;
|
||||
this.gatewayIp = this.intToIp(parsedSubnet.networkAddress + 1);
|
||||
this.nextIpAddress = parsedSubnet.networkAddress + 2;
|
||||
this.lastUsableIpAddress = parsedSubnet.broadcastAddress - 1;
|
||||
|
||||
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 {
|
||||
const mask = (0xffffffff << (32 - cidr)) >>> 0;
|
||||
private parseSubnet(subnet: string): IParsedSubnet {
|
||||
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 [
|
||||
(mask >>> 24) & 0xff,
|
||||
(mask >>> 16) & 0xff,
|
||||
(mask >>> 8) & 0xff,
|
||||
mask & 0xff,
|
||||
Math.floor(address / 256 ** 3) % 256,
|
||||
Math.floor(address / 256 ** 2) % 256,
|
||||
Math.floor(address / 256) % 256,
|
||||
address % 256,
|
||||
].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.
|
||||
*/
|
||||
public allocateIp(): string {
|
||||
const parts = this.subnetBase.split('.').map(Number);
|
||||
parts[3] = this.nextIpOctet;
|
||||
this.nextIpOctet++;
|
||||
return parts.join('.');
|
||||
if (this.nextIpAddress > this.lastUsableIpAddress) {
|
||||
throw new SmartVMError(
|
||||
`Subnet ${this.subnetBase}/${this.subnetCidr} has no available guest IP addresses`,
|
||||
'IP_EXHAUSTED',
|
||||
);
|
||||
}
|
||||
|
||||
const ip = this.intToIp(this.nextIpAddress);
|
||||
this.nextIpAddress++;
|
||||
return ip;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,6 +179,36 @@ export class NetworkManager {
|
||||
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.
|
||||
*/
|
||||
@@ -110,31 +217,52 @@ export class NetworkManager {
|
||||
|
||||
try {
|
||||
// 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) {
|
||||
// Create bridge
|
||||
await this.shell.exec(`ip link add ${this.bridgeName} type bridge`);
|
||||
await this.shell.exec(`ip addr add ${this.gatewayIp}/${this.subnetCidr} dev ${this.bridgeName}`);
|
||||
await this.shell.exec(`ip link set ${this.bridgeName} up`);
|
||||
await this.runChecked('ip', ['link', 'add', this.bridgeName, 'type', 'bridge']);
|
||||
await this.runChecked('ip', ['addr', 'add', `${this.gatewayIp}/${this.subnetCidr}`, 'dev', this.bridgeName]);
|
||||
await this.runChecked('ip', ['link', 'set', this.bridgeName, 'up']);
|
||||
}
|
||||
|
||||
// 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)
|
||||
const checkResult = await this.shell.exec(
|
||||
`iptables -t nat -C POSTROUTING -s ${this.subnetBase}/${this.subnetCidr} -o $(ip route | grep default | awk '{print $5}' | head -1) -j MASQUERADE 2>/dev/null`,
|
||||
);
|
||||
const defaultIface = await this.getDefaultRouteInterface();
|
||||
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) {
|
||||
await this.shell.exec(
|
||||
`iptables -t nat -A POSTROUTING -s ${this.subnetBase}/${this.subnetCidr} -o $(ip route | grep default | awk '{print $5}' | head -1) -j MASQUERADE`,
|
||||
);
|
||||
await this.runChecked('iptables', [
|
||||
'-t',
|
||||
'nat',
|
||||
'-A',
|
||||
'POSTROUTING',
|
||||
'-s',
|
||||
`${this.subnetBase}/${this.subnetCidr}`,
|
||||
'-o',
|
||||
defaultIface,
|
||||
'-j',
|
||||
'MASQUERADE',
|
||||
]);
|
||||
}
|
||||
|
||||
this.bridgeCreated = true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new SmartVMError(
|
||||
`Failed to set up network bridge: ${err.message}`,
|
||||
`Failed to set up network bridge: ${message}`,
|
||||
'BRIDGE_SETUP_FAILED',
|
||||
);
|
||||
}
|
||||
@@ -147,16 +275,19 @@ export class NetworkManager {
|
||||
await this.ensureBridge();
|
||||
|
||||
const tapName = this.generateTapName(vmId, ifaceId);
|
||||
this.validateInterfaceName(tapName, 'tapName');
|
||||
const guestIp = this.allocateIp();
|
||||
const mac = this.generateMac(vmId, ifaceId);
|
||||
let tapCreated = false;
|
||||
|
||||
try {
|
||||
// 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
|
||||
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
|
||||
await this.shell.exec(`ip link set ${tapName} up`);
|
||||
await this.runChecked('ip', ['link', 'set', tapName, 'up']);
|
||||
|
||||
const tap: ITapDevice = {
|
||||
tapName,
|
||||
@@ -169,8 +300,12 @@ export class NetworkManager {
|
||||
this.activeTaps.set(tapName, tap);
|
||||
return tap;
|
||||
} catch (err) {
|
||||
if (tapCreated) {
|
||||
await this.removeTapDevice(tapName);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new SmartVMError(
|
||||
`Failed to create TAP device ${tapName}: ${err.message}`,
|
||||
`Failed to create TAP device ${tapName}: ${message}`,
|
||||
'TAP_CREATE_FAILED',
|
||||
);
|
||||
}
|
||||
@@ -180,8 +315,9 @@ export class NetworkManager {
|
||||
* Remove a TAP device and free its resources.
|
||||
*/
|
||||
public async removeTapDevice(tapName: string): Promise<void> {
|
||||
this.validateInterfaceName(tapName, 'tapName');
|
||||
try {
|
||||
await this.shell.exec(`ip link del ${tapName} 2>/dev/null`);
|
||||
await this.run('ip', ['link', 'del', tapName]);
|
||||
this.activeTaps.delete(tapName);
|
||||
} catch {
|
||||
// Device may already be gone
|
||||
@@ -209,24 +345,34 @@ export class NetworkManager {
|
||||
*/
|
||||
public async cleanup(): Promise<void> {
|
||||
// Remove all TAP devices
|
||||
for (const tapName of this.activeTaps.keys()) {
|
||||
for (const tapName of Array.from(this.activeTaps.keys())) {
|
||||
await this.removeTapDevice(tapName);
|
||||
}
|
||||
|
||||
// Remove bridge if we created it
|
||||
if (this.bridgeCreated) {
|
||||
try {
|
||||
await this.shell.exec(`ip link set ${this.bridgeName} down 2>/dev/null`);
|
||||
await this.shell.exec(`ip link del ${this.bridgeName} 2>/dev/null`);
|
||||
await this.run('ip', ['link', 'set', this.bridgeName, 'down']);
|
||||
await this.run('ip', ['link', 'del', this.bridgeName]);
|
||||
} catch {
|
||||
// Bridge may already be gone
|
||||
}
|
||||
|
||||
// Remove NAT rule
|
||||
try {
|
||||
await this.shell.exec(
|
||||
`iptables -t nat -D POSTROUTING -s ${this.subnetBase}/${this.subnetCidr} -o $(ip route | grep default | awk '{print $5}' | head -1) -j MASQUERADE 2>/dev/null`,
|
||||
);
|
||||
const defaultIface = this.defaultRouteInterface || await this.getDefaultRouteInterface();
|
||||
await this.run('iptables', [
|
||||
'-t',
|
||||
'nat',
|
||||
'-D',
|
||||
'POSTROUTING',
|
||||
'-s',
|
||||
`${this.subnetBase}/${this.subnetCidr}`,
|
||||
'-o',
|
||||
defaultIface,
|
||||
'-j',
|
||||
'MASQUERADE',
|
||||
]);
|
||||
} catch {
|
||||
// Rule may not exist
|
||||
}
|
||||
|
||||
+23
-3
@@ -1,16 +1,22 @@
|
||||
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 { ImageManager } from './classes.imagemanager.js';
|
||||
import { BaseImageManager } from './classes.baseimagemanager.js';
|
||||
import { NetworkManager } from './classes.networkmanager.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.
|
||||
*/
|
||||
export class SmartVM {
|
||||
private options: ISmartVMOptions;
|
||||
public imageManager: ImageManager;
|
||||
public baseImageManager: BaseImageManager;
|
||||
public networkManager: NetworkManager;
|
||||
private activeVMs: Map<string, MicroVM> = new Map();
|
||||
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.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({
|
||||
bridgeName: this.options.bridgeName,
|
||||
subnet: this.options.subnet,
|
||||
@@ -115,6 +128,13 @@ export class SmartVM {
|
||||
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.
|
||||
*/
|
||||
@@ -145,7 +165,7 @@ export class SmartVM {
|
||||
if (vm.state === 'running' || vm.state === 'paused') {
|
||||
stopPromises.push(
|
||||
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()) {
|
||||
cleanupPromises.push(
|
||||
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 { 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.
|
||||
* 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}`;
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
@@ -31,12 +51,7 @@ export class SocketClient {
|
||||
.get();
|
||||
|
||||
const statusCode = response.status;
|
||||
let body: T;
|
||||
try {
|
||||
body = await response.json() as T;
|
||||
} catch {
|
||||
body = undefined as any;
|
||||
}
|
||||
const body = await this.parseResponseBody<T>(response);
|
||||
return {
|
||||
statusCode,
|
||||
body,
|
||||
@@ -44,7 +59,7 @@ export class SocketClient {
|
||||
};
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`GET ${apiPath} failed: ${(err as Error).message}`,
|
||||
`GET ${apiPath} failed: ${getErrorMessage(err)}`,
|
||||
'SOCKET_REQUEST_FAILED',
|
||||
);
|
||||
}
|
||||
@@ -54,21 +69,19 @@ export class SocketClient {
|
||||
* Perform a PUT request with a JSON body.
|
||||
*/
|
||||
public async put<T = any>(apiPath: string, body?: Record<string, any>): Promise<IApiResponse<T>> {
|
||||
const url = this.buildUrl(apiPath);
|
||||
try {
|
||||
let request = plugins.SmartRequest.create().url(url);
|
||||
if (body !== undefined) {
|
||||
request = request.json(body);
|
||||
}
|
||||
const url = this.buildUrl(apiPath);
|
||||
try {
|
||||
let request = plugins.SmartRequest.create().url(url);
|
||||
if (body !== undefined) {
|
||||
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 statusCode = response.status;
|
||||
let responseBody: T;
|
||||
try {
|
||||
responseBody = await response.json() as T;
|
||||
} catch {
|
||||
responseBody = undefined as any;
|
||||
}
|
||||
const responseBody = await this.parseResponseBody<T>(response);
|
||||
return {
|
||||
statusCode,
|
||||
body: responseBody,
|
||||
@@ -76,7 +89,7 @@ export class SocketClient {
|
||||
};
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`PUT ${apiPath} failed: ${(err as Error).message}`,
|
||||
`PUT ${apiPath} failed: ${getErrorMessage(err)}`,
|
||||
'SOCKET_REQUEST_FAILED',
|
||||
);
|
||||
}
|
||||
@@ -86,21 +99,19 @@ export class SocketClient {
|
||||
* Perform a PATCH request with a JSON body.
|
||||
*/
|
||||
public async patch<T = any>(apiPath: string, body?: Record<string, any>): Promise<IApiResponse<T>> {
|
||||
const url = this.buildUrl(apiPath);
|
||||
try {
|
||||
let request = plugins.SmartRequest.create().url(url);
|
||||
if (body !== undefined) {
|
||||
request = request.json(body);
|
||||
}
|
||||
const url = this.buildUrl(apiPath);
|
||||
try {
|
||||
let request = plugins.SmartRequest.create().url(url);
|
||||
if (body !== undefined) {
|
||||
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 statusCode = response.status;
|
||||
let responseBody: T;
|
||||
try {
|
||||
responseBody = await response.json() as T;
|
||||
} catch {
|
||||
responseBody = undefined as any;
|
||||
}
|
||||
const responseBody = await this.parseResponseBody<T>(response);
|
||||
return {
|
||||
statusCode,
|
||||
body: responseBody,
|
||||
@@ -108,21 +119,21 @@ export class SocketClient {
|
||||
};
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`PATCH ${apiPath} failed: ${(err as Error).message}`,
|
||||
`PATCH ${apiPath} failed: ${getErrorMessage(err)}`,
|
||||
'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> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const response = await this.get('/');
|
||||
if (response.ok || response.statusCode === 200 || response.statusCode === 400) {
|
||||
const response = await this.get('/version');
|
||||
if (response.ok) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
|
||||
+38
-1
@@ -12,7 +12,44 @@ export class VMConfig {
|
||||
public 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 { SocketClient } from './classes.socketclient.js';
|
||||
export { ImageManager } from './classes.imagemanager.js';
|
||||
export { BaseImageManager } from './classes.baseimagemanager.js';
|
||||
export { FirecrackerProcess } from './classes.firecrackerprocess.js';
|
||||
export { NetworkManager } from './classes.networkmanager.js';
|
||||
export { MicroVM } from './classes.microvm.js';
|
||||
|
||||
+123
-2
@@ -16,6 +16,127 @@ export interface ISmartVMOptions {
|
||||
bridgeName?: string;
|
||||
/** Network subnet in CIDR notation. Defaults to '172.30.0.0/24'. */
|
||||
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;
|
||||
/** Log level. */
|
||||
level?: TLogLevel;
|
||||
/** Whether to show log origin (file, line). */
|
||||
showLevel?: boolean;
|
||||
/** Whether to show log level. */
|
||||
showLevel?: boolean;
|
||||
/** Whether to show log origin (file, line). */
|
||||
showLogOrigin?: boolean;
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -2,8 +2,9 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
export { fs, path, os };
|
||||
export { fs, path, os, crypto };
|
||||
|
||||
// @push.rocks scope
|
||||
import * as smartdelay from '@push.rocks/smartdelay';
|
||||
|
||||
+3
-1
@@ -3,8 +3,10 @@
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noImplicitAny": true,
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true
|
||||
"verbatimModuleSyntax": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"exclude": [
|
||||
"dist_*/**/*.d.ts"
|
||||
|
||||
Reference in New Issue
Block a user