Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69e66cba00 | |||
| 0e6384b3ee | |||
| a61694bd01 | |||
| c868d07d29 | |||
| 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,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,35 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-05-01 - 1.3.1 - fix(docs)
|
||||||
|
remove outdated base image bundle readme and consolidate hosted manifest documentation
|
||||||
|
|
||||||
|
- Deletes the dedicated assets/base-images/readme.md documentation file
|
||||||
|
- Keeps hosted base image manifest guidance and example usage in the main project README
|
||||||
|
|
||||||
|
## 2026-05-01 - 1.3.0 - feat(runtime)
|
||||||
|
stage VM runtime artifacts and writable drives in per-VM ephemeral storage by default
|
||||||
|
|
||||||
|
- default runtime files to /dev/shm/.smartvm/runtime when available, with per-VM socket and drive staging paths
|
||||||
|
- copy writable drives into per-VM runtime storage before boot and remove them during cleanup, with per-drive and global opt-out controls
|
||||||
|
- prefer squashfs rootfs images over ext4 when resolving Firecracker CI base images
|
||||||
|
- add tests and documentation for ephemeral drive staging and runtime directory defaults
|
||||||
|
|
||||||
|
## 2026-05-01 - 1.2.0 - feat(base-images)
|
||||||
|
add managed base image bundles with cache retention, hosted manifests, and opt-in integration boot testing
|
||||||
|
|
||||||
|
- introduces BaseImageManager with support for Firecracker CI presets and hosted manifest-based kernel/rootfs bundles
|
||||||
|
- adds SmartVM.ensureBaseImage() and exports new base image types and manager APIs
|
||||||
|
- validates and verifies downloaded base image artifacts with checksums and bounded cache eviction
|
||||||
|
- hardens process, socket, network, and config handling with safer spawning, subnet/interface validation, and expanded tests
|
||||||
|
|
||||||
|
## 2026-04-30 - 1.1.1 - fix(build)
|
||||||
|
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)
|
## 2026-02-08 - 1.1.0 - feat(release)
|
||||||
add release configuration with npm registries and public access; add @ship.zone/szci placeholder
|
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",
|
"name": "@push.rocks/smartvm",
|
||||||
"version": "1.1.0",
|
"version": "1.3.1",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs",
|
"description": "A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "(tstest test/ --verbose)",
|
"test": "(tstest test/ --verbose)",
|
||||||
"build": "(tsbuild --web --allowimplicitany)"
|
"build": "(tsbuild --web)"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -23,21 +23,25 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/smartdelay": "^3.0.5",
|
"@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/smartrequest": "^5.0.1",
|
||||||
"@push.rocks/smartshell": "^3.2.3",
|
"@push.rocks/smartshell": "^3.3.8",
|
||||||
"@push.rocks/smartunique": "^3.0.9"
|
"@push.rocks/smartunique": "^3.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^4.1.2",
|
"@git.zone/tsbuild": "^4.4.0",
|
||||||
"@git.zone/tsrun": "^2.0.1",
|
"@git.zone/tsrun": "^2.0.3",
|
||||||
"@git.zone/tstest": "^3.1.8",
|
"@git.zone/tstest": "^3.6.3",
|
||||||
"@types/node": "^25.2.2"
|
"@types/node": "^25.6.0"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"ts/**/*",
|
"ts/**/*",
|
||||||
"dist_ts/**/*",
|
"dist_ts/**/*",
|
||||||
"assets/**/*",
|
"assets/**/*",
|
||||||
|
".smartconfig.json",
|
||||||
|
"license",
|
||||||
|
"npmextra.json",
|
||||||
"readme.md"
|
"readme.md"
|
||||||
]
|
],
|
||||||
|
"packageManager": "pnpm@10.28.2"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1545
-1902
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,11 @@
|
|||||||
- Uses `@push.rocks/smartrequest` with URL format `http://unix:<socket>:<path>` for socket communication
|
- Uses `@push.rocks/smartrequest` with URL format `http://unix:<socket>:<path>` for socket communication
|
||||||
- Uses `@push.rocks/smartshell` `execStreaming()` to manage Firecracker child processes
|
- Uses `@push.rocks/smartshell` `execStreaming()` to manage Firecracker child processes
|
||||||
- Uses `@push.rocks/smartexit` for cleanup on process exit
|
- Uses `@push.rocks/smartexit` for cleanup on process exit
|
||||||
|
- `BaseImageManager` downloads Firecracker CI demo artifacts or hosted project manifests into `/tmp/.smartvm/base-images` by default
|
||||||
|
- Base image cache keeps 2 bundles by default and warns before evicting older bundles
|
||||||
|
- Hosted manifest examples live in `assets/base-images/`
|
||||||
|
- VM runtime files default to `/dev/shm/.smartvm/runtime` when available
|
||||||
|
- Writable drives are staged into per-VM runtime storage by default and removed during cleanup; use `ephemeral: false` only for explicit persistence
|
||||||
|
|
||||||
## Key API Patterns
|
## Key API Patterns
|
||||||
- SmartRequest: `SmartRequest.create().url(...).json(body).put()` — response has `.status`, `.ok`, `.json()`
|
- SmartRequest: `SmartRequest.create().url(...).json(body).put()` — response has `.status`, `.ok`, `.json()`
|
||||||
@@ -16,3 +21,9 @@
|
|||||||
- Start: PUT /actions { action_type: "InstanceStart" }
|
- Start: PUT /actions { action_type: "InstanceStart" }
|
||||||
- Pause/Resume: PATCH /vm { state: "Paused" | "Resumed" }
|
- Pause/Resume: PATCH /vm { state: "Paused" | "Resumed" }
|
||||||
- Stop: PUT /actions { action_type: "SendCtrlAltDel" }
|
- Stop: PUT /actions { action_type: "SendCtrlAltDel" }
|
||||||
|
|
||||||
|
## Integration Testing
|
||||||
|
- Default `pnpm test` skips real Firecracker boot testing
|
||||||
|
- Set `SMARTVM_RUN_INTEGRATION=true` to run the opt-in boot test
|
||||||
|
- `SMARTVM_BASE_IMAGE_PRESET` supports `latest` and `lts`; default is `latest`
|
||||||
|
- Hosted/project-owned bundles use `baseImageManifestUrl`, `baseImageManifestPath`, `manifestUrl`, or `manifestPath`
|
||||||
|
|||||||
@@ -1,179 +1,341 @@
|
|||||||
# @push.rocks/smartvm
|
# @push.rocks/smartvm
|
||||||
|
|
||||||
A TypeScript module that wraps Amazon's [Firecracker VMM](https://firecracker-microvm.github.io/) to create, configure, and manage lightweight microVMs with a clean, type-safe API.
|
Run Firecracker microVMs from TypeScript without hand-rolling process management, Unix-socket HTTP calls, TAP devices, bridge setup, image caching, and cleanup. `@push.rocks/smartvm` gives you a typed orchestration layer for building tiny, fast, disk-light VM workflows on Linux/KVM.
|
||||||
|
|
||||||
## Issue Reporting and Security
|
## Issue Reporting and Security
|
||||||
|
|
||||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
`smartvm` wraps the operational parts of Amazon Firecracker:
|
||||||
|
|
||||||
|
- Downloads and caches Firecracker binaries.
|
||||||
|
- Resolves bootable Firecracker CI base-image bundles with `latest` and `lts` presets.
|
||||||
|
- Supports project-owned hosted base-image manifests with SHA256 verification.
|
||||||
|
- Creates and controls microVMs through Firecracker's HTTP-over-Unix-socket API.
|
||||||
|
- Converts TypeScript camelCase config into Firecracker's snake_case payloads.
|
||||||
|
- Creates TAP devices, a Linux bridge, static guest IP assignments, and NAT rules.
|
||||||
|
- Defaults VM runtime artifacts to tmpfs via `/dev/shm/.smartvm/runtime` when available.
|
||||||
|
- Stages writable drives into per-VM ephemeral storage by default so guest writes do not touch cached rootfs files.
|
||||||
|
- Cleans up Firecracker processes, sockets, TAPs, bridges, NAT rules, and staged drive copies.
|
||||||
|
|
||||||
|
The design goal is close to a Cloudflare Workers/Deno-style filesystem model, adapted to Firecracker: immutable root image, explicit writable scratch, no accidental persistent state, and persistence only when you opt in.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install @push.rocks/smartvm
|
pnpm add @push.rocks/smartvm
|
||||||
```
|
```
|
||||||
|
|
||||||
> ⚡ **Prerequisites**: Firecracker requires a Linux host with KVM support (`/dev/kvm`). Networking features (TAP devices, bridges, NAT) require root privileges.
|
## Runtime Requirements
|
||||||
|
|
||||||
|
Firecracker is a Linux/KVM technology. The package is TypeScript, but the runtime host must provide the VM substrate.
|
||||||
|
|
||||||
|
| Requirement | Why it matters |
|
||||||
|
|---|---|
|
||||||
|
| Linux with `/dev/kvm` | Firecracker needs KVM acceleration. |
|
||||||
|
| Firecracker binary | Downloaded by `ensureBinary()` or supplied through `firecrackerBinaryPath`. |
|
||||||
|
| Root privileges for networking | TAP devices, bridges, IP forwarding, and iptables NAT require elevated privileges. |
|
||||||
|
| Host tools: `curl`, `tar`, `ip`, `sysctl`, `iptables` | Used for binary/image downloads and network setup. |
|
||||||
|
| Enough tmpfs memory | Writable VM drives are copied into `/dev/shm` by default when available. |
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
This is the happy path: let `smartvm` download Firecracker, resolve a known-good base image, boot it, and clean it up.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SmartVM } from '@push.rocks/smartvm';
|
import { SmartVM } from '@push.rocks/smartvm';
|
||||||
|
|
||||||
// 1. Create the orchestrator
|
|
||||||
const smartvm = new SmartVM({
|
const smartvm = new SmartVM({
|
||||||
dataDir: '/opt/smartvm', // where binaries, kernels, rootfs are cached
|
// Optional. Defaults are intentionally disk-light.
|
||||||
firecrackerVersion: 'v1.7.0', // or omit for latest
|
dataDir: '/tmp/.smartvm',
|
||||||
arch: 'x86_64',
|
runtimeDir: '/dev/shm/.smartvm/runtime',
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Download Firecracker if not already present
|
const baseImage = await smartvm.ensureBaseImage({ preset: 'latest' });
|
||||||
await smartvm.ensureBinary();
|
|
||||||
|
|
||||||
// 3. Create a MicroVM
|
|
||||||
const vm = await smartvm.createVM({
|
const vm = await smartvm.createVM({
|
||||||
|
id: 'hello-firecracker',
|
||||||
bootSource: {
|
bootSource: {
|
||||||
kernelImagePath: '/opt/smartvm/kernels/vmlinux',
|
kernelImagePath: baseImage.kernelImagePath,
|
||||||
bootArgs: 'console=ttyS0 reboot=k panic=1 pci=off',
|
bootArgs: baseImage.bootArgs,
|
||||||
},
|
},
|
||||||
machineConfig: {
|
machineConfig: {
|
||||||
vcpuCount: 2,
|
vcpuCount: 1,
|
||||||
memSizeMib: 256,
|
memSizeMib: 256,
|
||||||
},
|
},
|
||||||
drives: [
|
drives: [
|
||||||
{
|
{
|
||||||
driveId: 'rootfs',
|
driveId: 'rootfs',
|
||||||
pathOnHost: '/opt/smartvm/rootfs/ubuntu.ext4',
|
pathOnHost: baseImage.rootfsPath,
|
||||||
isRootDevice: true,
|
isRootDevice: true,
|
||||||
isReadOnly: false,
|
isReadOnly: baseImage.rootfsIsReadOnly,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
networkInterfaces: [
|
|
||||||
{ ifaceId: 'eth0' }, // TAP device and MAC auto-generated
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Start it 🚀
|
try {
|
||||||
await vm.start();
|
await vm.start();
|
||||||
|
console.log(vm.state); // "running"
|
||||||
// 5. Inspect
|
console.log(await vm.getVersion());
|
||||||
console.log(vm.state); // 'running'
|
console.log(await vm.getInfo());
|
||||||
console.log(await vm.getInfo()); // Firecracker instance info
|
} finally {
|
||||||
|
if (vm.state === 'running' || vm.state === 'paused') {
|
||||||
// 6. Pause / Resume
|
|
||||||
await vm.pause(); // state → 'paused'
|
|
||||||
await vm.resume(); // state → 'running'
|
|
||||||
|
|
||||||
// 7. Stop and clean up
|
|
||||||
await vm.stop();
|
await vm.stop();
|
||||||
|
}
|
||||||
await vm.cleanup();
|
await vm.cleanup();
|
||||||
await smartvm.cleanup();
|
await smartvm.cleanup();
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture Overview
|
## Disk-Light Runtime Model
|
||||||
|
|
||||||
```
|
By default, `smartvm` treats VMs as ephemeral execution units.
|
||||||
┌─────────────────────────────────────────────┐
|
|
||||||
│ SmartVM │ ← Top-level orchestrator
|
| Path | Default | Persistence model |
|
||||||
│ ┌──────────────┐ ┌────────────────────┐ │
|
|---|---|---|
|
||||||
│ │ ImageManager │ │ NetworkManager │ │
|
| Firecracker binaries | `/tmp/.smartvm/bin` | Cached for reuse. |
|
||||||
│ │ (binaries, │ │ (TAP, bridge, │ │
|
| Base images | `/tmp/.smartvm/base-images` | Cached, retention-limited, verified before reuse. |
|
||||||
│ │ kernels, │ │ NAT, IP alloc) │ │
|
| VM sockets | `/dev/shm/.smartvm/runtime/<vmId>/firecracker.sock` | Per-VM tmpfs, deleted on cleanup. |
|
||||||
│ │ rootfs) │ │ │ │
|
| Writable drives | `/dev/shm/.smartvm/runtime/<vmId>/drives/*` | Per-VM tmpfs copy, deleted on cleanup. |
|
||||||
│ └──────────────┘ └────────────────────┘ │
|
| Read-only drives | Original path | Not copied unless `ephemeral: true`. |
|
||||||
│ │
|
|
||||||
│ ┌─────────── MicroVM ────────────────┐ │
|
Writable drives are staged into the VM runtime directory before boot. Firecracker receives the staged path, so guest writes do not modify cached base images or source rootfs files.
|
||||||
│ │ state: created → configuring → │ │
|
|
||||||
│ │ running → paused → stopped │ │
|
```typescript
|
||||||
│ │ │ │
|
const vm = await smartvm.createVM({
|
||||||
│ │ ┌──────────────────────────────┐ │ │
|
bootSource: { kernelImagePath: baseImage.kernelImagePath, bootArgs: baseImage.bootArgs },
|
||||||
│ │ │ FirecrackerProcess │ │ │
|
machineConfig: { vcpuCount: 1, memSizeMib: 256 },
|
||||||
│ │ │ (child process management) │ │ │
|
drives: [
|
||||||
│ │ └──────────────────────────────┘ │ │
|
{
|
||||||
│ │ ┌──────────────────────────────┐ │ │
|
driveId: 'rootfs',
|
||||||
│ │ │ SocketClient │ │ │
|
pathOnHost: baseImage.rootfsPath,
|
||||||
│ │ │ (HTTP over Unix socket) │ │ │
|
isRootDevice: true,
|
||||||
│ │ └──────────────────────────────┘ │ │
|
isReadOnly: false,
|
||||||
│ │ ┌──────────────────────────────┐ │ │
|
// Default for writable drives: true
|
||||||
│ │ │ VMConfig │ │ │
|
ephemeral: true,
|
||||||
│ │ │ (camelCase → snake_case) │ │ │
|
},
|
||||||
│ │ └──────────────────────────────┘ │ │
|
],
|
||||||
│ └────────────────────────────────────┘ │
|
});
|
||||||
└─────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Firecracker exposes a REST API over a Unix domain socket. This module handles all the plumbing: spawning the process, waiting for the socket, translating your TypeScript config into Firecracker's snake_case API payloads, managing TAP devices, and tearing everything down on exit.
|
Opt into persistence only when that is the point:
|
||||||
|
|
||||||
## API Reference
|
```typescript
|
||||||
|
const persistentVm = await smartvm.createVM({
|
||||||
|
bootSource: { kernelImagePath: '/images/vmlinux', bootArgs: 'console=ttyS0 reboot=k panic=1 pci=off' },
|
||||||
|
machineConfig: { vcpuCount: 2, memSizeMib: 512 },
|
||||||
|
drives: [
|
||||||
|
{
|
||||||
|
driveId: 'state',
|
||||||
|
pathOnHost: '/var/lib/my-vm/state.ext4',
|
||||||
|
isRootDevice: true,
|
||||||
|
isReadOnly: false,
|
||||||
|
ephemeral: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
### `SmartVM` — The Orchestrator
|
You can also disable writable-drive staging globally:
|
||||||
|
|
||||||
The entry point for everything. Manages binary downloads, VM creation, and global cleanup.
|
```typescript
|
||||||
|
const smartvm = new SmartVM({
|
||||||
|
ephemeralWritableDrives: false,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Best practice for high-volume VM starts:
|
||||||
|
|
||||||
|
- Prefer `squashfs` or another read-only root filesystem.
|
||||||
|
- Put mutable scratch data on tmpfs-backed writable drives.
|
||||||
|
- Keep shared assets read-only by default.
|
||||||
|
- Use external services, object storage, databases, or explicit persistent drives for durable state.
|
||||||
|
- Use a dedicated `runtimeDir` on a real tmpfs if `/dev/shm` is too small or unavailable.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
SmartVM
|
||||||
|
ImageManager downloads/caches Firecracker binaries and manual images
|
||||||
|
BaseImageManager resolves known-good base-image bundles
|
||||||
|
NetworkManager creates TAP devices, bridge, NAT, and static guest network data
|
||||||
|
MicroVM
|
||||||
|
FirecrackerProcess starts/stops the VMM process
|
||||||
|
SocketClient talks HTTP over the Firecracker Unix socket
|
||||||
|
VMConfig validates and transforms TypeScript config
|
||||||
|
```
|
||||||
|
|
||||||
|
Firecracker exposes a REST API over a Unix domain socket. `smartvm` starts the child process, waits for readiness, sends pre-boot config in the right order, starts the instance, and tears down host resources when you are done.
|
||||||
|
|
||||||
|
## SmartVM
|
||||||
|
|
||||||
|
`SmartVM` is the top-level orchestrator.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SmartVM } from '@push.rocks/smartvm';
|
import { SmartVM } from '@push.rocks/smartvm';
|
||||||
import type { ISmartVMOptions } from '@push.rocks/smartvm';
|
import type { ISmartVMOptions } from '@push.rocks/smartvm';
|
||||||
|
|
||||||
const smartvm = new SmartVM({
|
const options: ISmartVMOptions = {
|
||||||
dataDir: '/tmp/.smartvm', // default: /tmp/.smartvm
|
dataDir: '/tmp/.smartvm',
|
||||||
firecrackerVersion: 'v1.7.0', // default: latest from GitHub
|
runtimeDir: '/dev/shm/.smartvm/runtime',
|
||||||
arch: 'x86_64', // default: x86_64 (also: aarch64)
|
ephemeralWritableDrives: true,
|
||||||
firecrackerBinaryPath: '/usr/bin/firecracker', // optional: skip download
|
firecrackerVersion: 'v1.7.0',
|
||||||
bridgeName: 'svbr0', // default: svbr0
|
arch: 'x86_64',
|
||||||
subnet: '172.30.0.0/24', // default: 172.30.0.0/24
|
firecrackerBinaryPath: '/usr/bin/firecracker',
|
||||||
});
|
bridgeName: 'svbr0',
|
||||||
|
subnet: '172.30.0.0/24',
|
||||||
|
baseImageCacheDir: '/tmp/.smartvm/base-images',
|
||||||
|
maxStoredBaseImages: 2,
|
||||||
|
baseImageManifestUrl: 'https://assets.example.com/smartvm/manifest.json',
|
||||||
|
baseImageManifestPath: './assets/base-images/local.manifest.json',
|
||||||
|
};
|
||||||
|
|
||||||
|
const smartvm = new SmartVM(options);
|
||||||
```
|
```
|
||||||
|
|
||||||
| Method | Description |
|
| API | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ensureBinary()` | Downloads Firecracker from GitHub if not cached. Returns path to binary. |
|
| `ensureBinary()` | Ensures the Firecracker binary exists and returns its path. |
|
||||||
| `createVM(config)` | Creates a `MicroVM` instance (not yet started). Returns the VM. |
|
| `ensureBaseImage(options)` | Resolves/downloads a base-image bundle and returns kernel/rootfs paths plus boot args. |
|
||||||
| `getVM(id)` | Look up an active VM by ID. |
|
| `createVM(config)` | Creates a `MicroVM` instance. It does not boot until `vm.start()`. |
|
||||||
| `listVMs()` | Returns an array of active VM IDs. |
|
| `getRuntimeDir()` | Returns the active runtime directory used for per-VM tmpfs artifacts. |
|
||||||
| `vmCount` | Number of active VMs. |
|
| `getVM(id)` | Looks up an active VM by ID. |
|
||||||
| `stopAll()` | Stops all running/paused VMs in parallel. |
|
| `listVMs()` | Lists active VM IDs. |
|
||||||
| `cleanup()` | Stops all VMs, removes TAP devices and bridge. |
|
| `vmCount` | Number of tracked VMs. |
|
||||||
|
| `removeVM(id)` | Removes a VM from the internal tracking map. |
|
||||||
|
| `stopAll()` | Stops every running or paused VM. |
|
||||||
|
| `cleanup()` | Cleans up all tracked VMs and networking resources. |
|
||||||
|
|
||||||
### `MicroVM` — VM Lifecycle
|
## Base Images
|
||||||
|
|
||||||
Each VM follows a strict state machine: **created → configuring → running → paused → stopped**.
|
`BaseImageManager` gives you fast bootable image discovery without committing giant rootfs files to git.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const baseImage = await smartvm.ensureBaseImage(); // preset: "latest"
|
||||||
|
const ltsBaseImage = await smartvm.ensureBaseImage({ preset: 'lts' });
|
||||||
|
const freshBaseImage = await smartvm.ensureBaseImage({ preset: 'latest', forceDownload: true });
|
||||||
|
```
|
||||||
|
|
||||||
|
Presets:
|
||||||
|
|
||||||
|
| Preset | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| `latest` | Resolves the latest Firecracker release and matching CI demo artifacts. |
|
||||||
|
| `lts` | Uses the pinned Firecracker CI train `v1.7` / Firecracker `v1.7.0`. |
|
||||||
|
| `hosted` | Uses a project-owned manifest. Requires `manifestUrl`, `manifestPath`, or manager-level hosted manifest options. |
|
||||||
|
|
||||||
|
The resolver prefers read-only `squashfs` rootfs artifacts when Firecracker CI exposes them, falling back to `ext4` when needed.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BaseImageManager } from '@push.rocks/smartvm';
|
||||||
|
|
||||||
|
const baseImageManager = new BaseImageManager({
|
||||||
|
arch: 'x86_64',
|
||||||
|
cacheDir: '/tmp/.smartvm/base-images',
|
||||||
|
maxStoredBaseImages: 4,
|
||||||
|
hostedManifestPath: './assets/base-images/smartvm-minimal.manifest.json',
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(baseImageManager.getCacheDir());
|
||||||
|
console.log(baseImageManager.getMaxStoredBaseImages());
|
||||||
|
|
||||||
|
const hosted = await baseImageManager.ensureBaseImage({ preset: 'hosted' });
|
||||||
|
const evictedBundleIds = await baseImageManager.pruneBaseImageCache(hosted.bundleId);
|
||||||
|
```
|
||||||
|
|
||||||
|
`IBaseImageBundle` contains:
|
||||||
|
|
||||||
|
- `kernelImagePath`
|
||||||
|
- `rootfsPath`
|
||||||
|
- `rootfsType`
|
||||||
|
- `rootfsIsReadOnly`
|
||||||
|
- `bootArgs`
|
||||||
|
- `firecrackerVersion`
|
||||||
|
- `checksums`
|
||||||
|
- `sizes`
|
||||||
|
- source metadata
|
||||||
|
|
||||||
|
Cache behavior:
|
||||||
|
|
||||||
|
- Default cache directory: `/tmp/.smartvm/base-images`
|
||||||
|
- Default retention: `2` bundles
|
||||||
|
- Older bundles are evicted with a `console.warn` when retention is exceeded
|
||||||
|
- Cached artifacts are checked for size and SHA256 before reuse
|
||||||
|
- Hosted URL artifacts require SHA256 hashes
|
||||||
|
- Hosted local-path artifacts may omit SHA256, but hashes are still recorded in the cached manifest
|
||||||
|
|
||||||
|
Hosted manifest example:
|
||||||
|
|
||||||
|
The repository ships an example at `assets/base-images/smartvm-minimal.manifest.example.json`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"bundleId": "smartvm-minimal-v1-x86_64",
|
||||||
|
"name": "SmartVM minimal x86_64 bundle",
|
||||||
|
"arch": "x86_64",
|
||||||
|
"firecrackerVersion": "v1.15.1",
|
||||||
|
"rootfsType": "squashfs",
|
||||||
|
"rootfsIsReadOnly": true,
|
||||||
|
"bootArgs": "console=ttyS0 reboot=k panic=1 pci=off ro rootfstype=squashfs",
|
||||||
|
"kernel": {
|
||||||
|
"url": "https://assets.example.com/smartvm/vmlinux",
|
||||||
|
"fileName": "vmlinux",
|
||||||
|
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"sizeBytes": 12345678
|
||||||
|
},
|
||||||
|
"rootfs": {
|
||||||
|
"url": "https://assets.example.com/smartvm/rootfs.squashfs",
|
||||||
|
"fileName": "rootfs.squashfs",
|
||||||
|
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
"sizeBytes": 12345678
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## MicroVM Lifecycle
|
||||||
|
|
||||||
|
`MicroVM` is a single Firecracker instance with a strict state machine:
|
||||||
|
|
||||||
|
```text
|
||||||
|
created -> configuring -> running -> paused -> stopped
|
||||||
|
\-> error
|
||||||
|
```
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const vm = await smartvm.createVM({
|
const vm = await smartvm.createVM({
|
||||||
id: 'my-vm', // optional, auto-generated UUID if omitted
|
id: 'api-worker-1',
|
||||||
bootSource: {
|
bootSource: {
|
||||||
kernelImagePath: '/path/to/vmlinux',
|
kernelImagePath: baseImage.kernelImagePath,
|
||||||
bootArgs: 'console=ttyS0 reboot=k panic=1',
|
bootArgs: baseImage.bootArgs,
|
||||||
initrdPath: '/path/to/initrd', // optional
|
|
||||||
},
|
},
|
||||||
machineConfig: {
|
machineConfig: {
|
||||||
vcpuCount: 4,
|
vcpuCount: 2,
|
||||||
memSizeMib: 512,
|
memSizeMib: 512,
|
||||||
smt: false,
|
smt: false,
|
||||||
cpuTemplate: 'T2', // optional: C3, T2, T2S, T2CL, T2A, V1N1
|
cpuTemplate: 'T2',
|
||||||
trackDirtyPages: true,
|
trackDirtyPages: true,
|
||||||
},
|
},
|
||||||
drives: [
|
drives: [
|
||||||
{
|
{
|
||||||
driveId: 'rootfs',
|
driveId: 'rootfs',
|
||||||
pathOnHost: '/path/to/rootfs.ext4',
|
pathOnHost: baseImage.rootfsPath,
|
||||||
isRootDevice: true,
|
isRootDevice: true,
|
||||||
isReadOnly: false,
|
isReadOnly: baseImage.rootfsIsReadOnly,
|
||||||
cacheType: 'Unsafe', // or 'Writeback'
|
cacheType: 'Unsafe',
|
||||||
|
ephemeral: true,
|
||||||
rateLimiter: {
|
rateLimiter: {
|
||||||
bandwidth: { size: 100_000_000, refillTime: 1_000_000_000 },
|
bandwidth: { size: 100_000_000, refillTime: 1_000_000_000 },
|
||||||
ops: { size: 1000, refillTime: 1_000_000_000 },
|
ops: { size: 1000, refillTime: 1_000_000_000 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
networkInterfaces: [
|
networkInterfaces: [{ ifaceId: 'eth0' }],
|
||||||
{
|
|
||||||
ifaceId: 'eth0',
|
|
||||||
// hostDevName and guestMac auto-generated if omitted
|
|
||||||
},
|
|
||||||
],
|
|
||||||
vsock: {
|
vsock: {
|
||||||
guestCid: 3,
|
guestCid: 3,
|
||||||
udsPath: '/tmp/vsock.sock',
|
udsPath: '/dev/shm/api-worker-1.vsock',
|
||||||
},
|
},
|
||||||
balloon: {
|
balloon: {
|
||||||
amountMib: 128,
|
amountMib: 128,
|
||||||
@@ -184,216 +346,184 @@ const vm = await smartvm.createVM({
|
|||||||
version: 'V2',
|
version: 'V2',
|
||||||
networkInterfaces: ['eth0'],
|
networkInterfaces: ['eth0'],
|
||||||
},
|
},
|
||||||
logger: {
|
});
|
||||||
logPath: '/tmp/firecracker.log',
|
|
||||||
level: 'Debug',
|
await vm.start();
|
||||||
showLogOrigin: true,
|
await vm.pause();
|
||||||
},
|
await vm.resume();
|
||||||
metrics: {
|
await vm.stop();
|
||||||
metricsPath: '/tmp/firecracker-metrics.fifo',
|
await vm.cleanup();
|
||||||
},
|
```
|
||||||
|
|
||||||
|
| API | Valid state | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `start()` | `created` | Stages ephemeral drives, starts Firecracker, applies config, boots the VM. |
|
||||||
|
| `pause()` | `running` | Pauses execution. |
|
||||||
|
| `resume()` | `paused` | Resumes execution. |
|
||||||
|
| `stop()` | `running`, `paused` | Sends Ctrl+Alt+Del, waits briefly, then stops the process. |
|
||||||
|
| `cleanup()` | any | Stops process, deletes sockets/runtime dir, removes auto-created TAPs. |
|
||||||
|
| `getInfo()` | after start | Returns Firecracker instance info. |
|
||||||
|
| `getVersion()` | after start | Returns Firecracker version info. |
|
||||||
|
| `setMetadata(data)` | `running`, `paused` | Writes MMDS metadata. |
|
||||||
|
| `getMetadata()` | `running`, `paused` | Reads MMDS metadata. |
|
||||||
|
| `updateDrive(id, path)` | `running`, `paused` | Hot-updates a drive path. |
|
||||||
|
| `updateNetworkInterface(id, update)` | `running`, `paused` | Updates network interface config such as rate limiters. |
|
||||||
|
| `updateBalloon(mib)` | `running`, `paused` | Resizes the balloon device. |
|
||||||
|
| `createSnapshot(params)` | `paused` | Creates a Firecracker snapshot. |
|
||||||
|
| `loadSnapshot(params)` | `created`, `configuring` | Low-level Firecracker snapshot-load call; requires an initialized socket client. |
|
||||||
|
| `getTapDevices()` | any | Returns TAP devices created automatically by this VM. |
|
||||||
|
| `getVMConfig()` | any | Returns the internal `VMConfig` instance. |
|
||||||
|
| `getRuntimeDir()` | any | Returns the per-VM runtime directory after it has been created. |
|
||||||
|
|
||||||
|
Snapshot caveat: diff snapshots require dirty-page tracking to be enabled before boot through `machineConfig.trackDirtyPages`. Snapshot restore is currently exposed as a low-level API; full restore orchestration should be built around the Firecracker process/config lifecycle intentionally.
|
||||||
|
|
||||||
|
## Networking
|
||||||
|
|
||||||
|
`NetworkManager` creates host-side networking primitives. It does not run DHCP inside the guest. Your guest image must configure its interface itself, or you must pass static `ip=` kernel boot arguments.
|
||||||
|
|
||||||
|
Automatic mode:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const vm = await smartvm.createVM({
|
||||||
|
bootSource: { kernelImagePath: baseImage.kernelImagePath, bootArgs: baseImage.bootArgs },
|
||||||
|
machineConfig: { vcpuCount: 1, memSizeMib: 256 },
|
||||||
|
drives: [{ driveId: 'rootfs', pathOnHost: baseImage.rootfsPath, isRootDevice: true, isReadOnly: baseImage.rootfsIsReadOnly }],
|
||||||
|
networkInterfaces: [{ ifaceId: 'eth0' }],
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
| Method | Valid States | Description |
|
Static-kernel-args mode:
|
||||||
|---|---|---|
|
|
||||||
| `start()` | `created` | Spawns Firecracker, applies config, boots the VM |
|
|
||||||
| `pause()` | `running` | Pauses VM execution |
|
|
||||||
| `resume()` | `paused` | Resumes a paused VM |
|
|
||||||
| `stop()` | `running`, `paused` | Graceful shutdown (Ctrl+Alt+Del), then force kill |
|
|
||||||
| `cleanup()` | any | Full cleanup: kill process, remove socket, remove TAPs |
|
|
||||||
| `getInfo()` | any (after start) | Returns Firecracker instance info |
|
|
||||||
| `getVersion()` | any (after start) | Returns Firecracker version |
|
|
||||||
| `createSnapshot(params)` | `paused` | Create a VM snapshot |
|
|
||||||
| `loadSnapshot(params)` | `created`, `configuring` | Load a VM from snapshot |
|
|
||||||
| `setMetadata(data)` | `running`, `paused` | Set MMDS metadata |
|
|
||||||
| `getMetadata()` | `running`, `paused` | Get MMDS metadata |
|
|
||||||
| `updateDrive(id, path)` | `running`, `paused` | Hot-update a drive path |
|
|
||||||
| `updateBalloon(mib)` | `running`, `paused` | Resize the balloon device |
|
|
||||||
| `getTapDevices()` | any | Returns TAP devices associated with this VM |
|
|
||||||
|
|
||||||
### `ImageManager` — Binary & Image Management
|
```typescript
|
||||||
|
const tap = await smartvm.networkManager.createTapDevice('net-vm', 'eth0');
|
||||||
|
|
||||||
Handles downloading and caching Firecracker binaries, kernels, and rootfs images.
|
const vm = await smartvm.createVM({
|
||||||
|
id: 'net-vm',
|
||||||
|
bootSource: {
|
||||||
|
kernelImagePath: baseImage.kernelImagePath,
|
||||||
|
bootArgs: `${baseImage.bootArgs} ${smartvm.networkManager.getGuestNetworkBootArgs(tap)}`,
|
||||||
|
},
|
||||||
|
machineConfig: { vcpuCount: 1, memSizeMib: 256 },
|
||||||
|
drives: [{ driveId: 'rootfs', pathOnHost: baseImage.rootfsPath, isRootDevice: true, isReadOnly: baseImage.rootfsIsReadOnly }],
|
||||||
|
networkInterfaces: [
|
||||||
|
{
|
||||||
|
ifaceId: 'eth0',
|
||||||
|
hostDevName: tap.tapName,
|
||||||
|
guestMac: tap.mac,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Networking behavior:
|
||||||
|
|
||||||
|
- Default bridge: `svbr0`
|
||||||
|
- Default subnet: `172.30.0.0/24`
|
||||||
|
- Subnet input is normalized to the network address
|
||||||
|
- Prefix length must be `1-30`
|
||||||
|
- Gateway uses the first usable address
|
||||||
|
- Guest IP allocation starts at the second usable address
|
||||||
|
- Allocation is sequential and not reused within the same `NetworkManager` instance
|
||||||
|
- MAC addresses are deterministic and locally administered (`02:xx:xx:xx:xx:xx`)
|
||||||
|
- TAP names are capped to Linux's 15-character IFNAMSIZ limit
|
||||||
|
- NAT masquerade uses the host default route interface
|
||||||
|
- Use a dedicated bridge name; `cleanup()` tears down the bridge configured by this manager
|
||||||
|
|
||||||
|
## ImageManager
|
||||||
|
|
||||||
|
`ImageManager` is the lower-level helper for Firecracker binaries and manually managed kernel/rootfs files.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const imageManager = smartvm.imageManager;
|
const imageManager = smartvm.imageManager;
|
||||||
|
|
||||||
// Auto-download the latest Firecracker release
|
await imageManager.ensureDirectories();
|
||||||
const version = await imageManager.getLatestVersion(); // e.g. 'v1.7.0'
|
|
||||||
const binaryPath = await imageManager.downloadFirecracker(version);
|
const latest = await imageManager.getLatestVersion();
|
||||||
|
const firecrackerPath = await imageManager.downloadFirecracker(latest);
|
||||||
|
|
||||||
// Download kernel and rootfs images
|
|
||||||
const kernelPath = await imageManager.downloadKernel(
|
const kernelPath = await imageManager.downloadKernel(
|
||||||
'https://example.com/vmlinux-5.10',
|
'https://example.com/vmlinux',
|
||||||
'vmlinux-5.10',
|
'vmlinux',
|
||||||
);
|
);
|
||||||
|
|
||||||
const rootfsPath = await imageManager.downloadRootfs(
|
const rootfsPath = await imageManager.downloadRootfs(
|
||||||
'https://example.com/ubuntu-22.04.ext4',
|
'https://example.com/rootfs.ext4',
|
||||||
'ubuntu-22.04.ext4',
|
'rootfs.ext4',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create a blank rootfs or clone an existing one
|
const blankRootfs = await imageManager.createBlankRootfs('scratch.ext4', 1024);
|
||||||
const blankPath = await imageManager.createBlankRootfs('scratch.ext4', 1024);
|
const clonedRootfs = await imageManager.cloneRootfs(rootfsPath, 'vm-rootfs.ext4');
|
||||||
const clonePath = await imageManager.cloneRootfs(rootfsPath, 'ubuntu-clone.ext4');
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Data directory layout:**
|
Useful path helpers:
|
||||||
|
|
||||||
```
|
- `getBinDir()`
|
||||||
/tmp/.smartvm/
|
- `getKernelsDir()`
|
||||||
bin/<version>/firecracker
|
- `getRootfsDir()`
|
||||||
bin/<version>/jailer
|
- `getSocketsDir()`
|
||||||
kernels/<name>
|
- `getFirecrackerPath(version)`
|
||||||
rootfs/<name>
|
- `getJailerPath(version)`
|
||||||
sockets/<vmId>.sock
|
- `getSocketPath(vmId)`
|
||||||
```
|
|
||||||
|
|
||||||
### `NetworkManager` — Host Networking
|
Note: `SmartVM.createVM()` uses `runtimeDir/<vmId>/firecracker.sock` for new VM sockets by default. `ImageManager.getSocketPath()` remains available for lower-level/custom flows.
|
||||||
|
|
||||||
Automatically manages TAP devices, a Linux bridge, and iptables NAT masquerade rules so VMs get internet access out of the box.
|
## VMConfig
|
||||||
|
|
||||||
```typescript
|
`VMConfig` validates `IMicroVMConfig` and transforms it into Firecracker API payloads.
|
||||||
const networkManager = smartvm.networkManager;
|
|
||||||
|
|
||||||
// Manually create a TAP device (usually handled by MicroVM.start())
|
|
||||||
const tap = await networkManager.createTapDevice('vm-id', 'eth0');
|
|
||||||
console.log(tap);
|
|
||||||
// {
|
|
||||||
// tapName: 'svvmideth0',
|
|
||||||
// guestIp: '172.30.0.2',
|
|
||||||
// gatewayIp: '172.30.0.1',
|
|
||||||
// subnetMask: '255.255.255.0',
|
|
||||||
// mac: '02:a3:b1:c4:d2:e5'
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Generate kernel boot args for the guest
|
|
||||||
const bootArgs = networkManager.getGuestNetworkBootArgs(tap);
|
|
||||||
// 'ip=172.30.0.2::172.30.0.1:255.255.255.0::eth0:off'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Networking architecture:**
|
|
||||||
- Creates a Linux bridge (default: `svbr0`) with gateway at `.1`
|
|
||||||
- Each VM gets a TAP device attached to the bridge
|
|
||||||
- Sequential IP allocation from `.2` onwards
|
|
||||||
- iptables NAT masquerade for outbound internet
|
|
||||||
- Deterministic MAC generation (`02:xx:xx:xx:xx:xx` locally-administered)
|
|
||||||
- TAP names fit Linux's 15-char IFNAMSIZ limit
|
|
||||||
|
|
||||||
### `VMConfig` — Config Transformer
|
|
||||||
|
|
||||||
Converts your camelCase TypeScript config into Firecracker's snake_case API payloads. Also validates configuration before boot.
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { VMConfig } from '@push.rocks/smartvm';
|
import { VMConfig } from '@push.rocks/smartvm';
|
||||||
|
|
||||||
const vmConfig = new VMConfig({
|
const vmConfig = new VMConfig({
|
||||||
bootSource: { kernelImagePath: '/path/to/vmlinux' },
|
bootSource: { kernelImagePath: '/images/vmlinux', bootArgs: 'console=ttyS0 reboot=k panic=1 pci=off' },
|
||||||
machineConfig: { vcpuCount: 2, memSizeMib: 256 },
|
machineConfig: { vcpuCount: 2, memSizeMib: 256 },
|
||||||
|
drives: [{ driveId: 'rootfs', pathOnHost: '/images/rootfs.ext4', isRootDevice: true }],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validate
|
const validation = vmConfig.validate();
|
||||||
const result = vmConfig.validate();
|
if (!validation.valid) {
|
||||||
// { valid: true, errors: [] }
|
throw new Error(validation.errors.join('; '));
|
||||||
|
}
|
||||||
|
|
||||||
// Generate API payloads
|
console.log(vmConfig.toBootSourcePayload());
|
||||||
vmConfig.toBootSourcePayload();
|
console.log(vmConfig.toMachineConfigPayload());
|
||||||
// { kernel_image_path: '/path/to/vmlinux' }
|
console.log(vmConfig.toDrivePayload(vmConfig.config.drives![0]));
|
||||||
|
|
||||||
vmConfig.toMachineConfigPayload();
|
|
||||||
// { vcpu_count: 2, mem_size_mib: 256 }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### `SocketClient` — Low-Level HTTP Client
|
The constructor clones caller-provided config, so internal normalization does not mutate your original object.
|
||||||
|
|
||||||
Direct HTTP-over-Unix-socket communication with Firecracker. You typically don't need this directly — `MicroVM` handles it — but it's available if you want raw API access.
|
## SocketClient
|
||||||
|
|
||||||
|
`SocketClient` is the raw Firecracker API client. Most users should go through `MicroVM`, but the low-level client is exported for tooling and diagnostics.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SocketClient } from '@push.rocks/smartvm';
|
import { SocketClient } from '@push.rocks/smartvm';
|
||||||
|
|
||||||
const client = new SocketClient({ socketPath: '/tmp/firecracker.sock' });
|
const client = new SocketClient({ socketPath: '/dev/shm/.smartvm/runtime/vm/firecracker.sock' });
|
||||||
|
|
||||||
const info = await client.get('/');
|
const version = await client.get('/version');
|
||||||
const putResult = await client.put('/machine-config', { vcpu_count: 2, mem_size_mib: 256 });
|
const machineConfig = await client.put('/machine-config', {
|
||||||
const patchResult = await client.patch('/vm', { state: 'Paused' });
|
vcpu_count: 1,
|
||||||
|
mem_size_mib: 256,
|
||||||
|
});
|
||||||
|
const paused = await client.patch('/vm', { state: 'Paused' });
|
||||||
|
|
||||||
// Check if socket is alive (polls with timeout)
|
console.log(version.ok, version.statusCode, version.body);
|
||||||
const ready = await client.isReady(5000);
|
console.log(machineConfig.ok, machineConfig.statusCode, machineConfig.body);
|
||||||
|
console.log(paused.ok, paused.statusCode, paused.body);
|
||||||
```
|
```
|
||||||
|
|
||||||
### `SmartVMError` — Error Handling
|
`SocketClient` returns `{ ok, statusCode, body }`. Non-2xx responses do not become `API_ERROR` until higher-level `MicroVM` helpers validate them.
|
||||||
|
|
||||||
All errors thrown by this module are `SmartVMError` instances with structured error codes.
|
## Metadata Service
|
||||||
|
|
||||||
```typescript
|
Firecracker MMDS lets the host pass structured metadata to a running VM.
|
||||||
import { SmartVMError } from '@push.rocks/smartvm';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await vm.start();
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof SmartVMError) {
|
|
||||||
console.log(err.code); // 'INVALID_CONFIG', 'SOCKET_TIMEOUT', 'API_ERROR', etc.
|
|
||||||
console.log(err.statusCode); // HTTP status from Firecracker (if applicable)
|
|
||||||
console.log(err.details); // Raw error body from Firecracker (if applicable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error codes:**
|
|
||||||
|
|
||||||
| Code | Description |
|
|
||||||
|---|---|
|
|
||||||
| `INVALID_STATE` | Operation not valid for current VM state |
|
|
||||||
| `INVALID_CONFIG` | Config validation failed |
|
|
||||||
| `SOCKET_TIMEOUT` | Firecracker socket didn't become ready |
|
|
||||||
| `API_TIMEOUT` | Firecracker API didn't respond in time |
|
|
||||||
| `SOCKET_REQUEST_FAILED` | HTTP request to socket failed |
|
|
||||||
| `API_ERROR` | Firecracker returned a non-2xx response |
|
|
||||||
| `BINARY_NOT_FOUND` | Firecracker binary not at expected path |
|
|
||||||
| `DOWNLOAD_FAILED` | Failed to download binary/kernel/rootfs |
|
|
||||||
| `VERSION_FETCH_FAILED` | Couldn't query GitHub for latest version |
|
|
||||||
| `BRIDGE_SETUP_FAILED` | Failed to create network bridge |
|
|
||||||
| `TAP_CREATE_FAILED` | Failed to create TAP device |
|
|
||||||
| `ROOTFS_CREATE_FAILED` | Failed to create blank rootfs |
|
|
||||||
| `ROOTFS_CLONE_FAILED` | Failed to clone rootfs image |
|
|
||||||
| `START_FAILED` | VM start sequence failed |
|
|
||||||
| `NO_CLIENT` | Socket client not initialized |
|
|
||||||
|
|
||||||
## Snapshots
|
|
||||||
|
|
||||||
Create and restore VM snapshots for fast cold-start or live migration:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Pause first (required for snapshots)
|
|
||||||
await vm.pause();
|
|
||||||
|
|
||||||
// Create a snapshot
|
|
||||||
await vm.createSnapshot({
|
|
||||||
snapshotPath: '/tmp/snapshot.bin',
|
|
||||||
memFilePath: '/tmp/snapshot-mem.bin',
|
|
||||||
snapshotType: 'Full', // or 'Diff' for incremental
|
|
||||||
});
|
|
||||||
|
|
||||||
// Later: restore from snapshot
|
|
||||||
const freshVm = await smartvm.createVM({
|
|
||||||
bootSource: { kernelImagePath: '/path/to/vmlinux' },
|
|
||||||
machineConfig: { vcpuCount: 2, memSizeMib: 256 },
|
|
||||||
});
|
|
||||||
await freshVm.loadSnapshot({
|
|
||||||
snapshotPath: '/tmp/snapshot.bin',
|
|
||||||
memFilePath: '/tmp/snapshot-mem.bin',
|
|
||||||
resumeVm: true,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## MMDS (Metadata Service)
|
|
||||||
|
|
||||||
Pass metadata to your guest VM via Firecracker's Microvm Metadata Service:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const vm = await smartvm.createVM({
|
const vm = await smartvm.createVM({
|
||||||
bootSource: { /* ... */ },
|
bootSource: { kernelImagePath: baseImage.kernelImagePath, bootArgs: baseImage.bootArgs },
|
||||||
machineConfig: { /* ... */ },
|
machineConfig: { vcpuCount: 1, memSizeMib: 256 },
|
||||||
|
drives: [{ driveId: 'rootfs', pathOnHost: baseImage.rootfsPath, isRootDevice: true, isReadOnly: baseImage.rootfsIsReadOnly }],
|
||||||
networkInterfaces: [{ ifaceId: 'eth0' }],
|
networkInterfaces: [{ ifaceId: 'eth0' }],
|
||||||
mmds: {
|
mmds: {
|
||||||
version: 'V2',
|
version: 'V2',
|
||||||
@@ -403,44 +533,114 @@ const vm = await smartvm.createVM({
|
|||||||
|
|
||||||
await vm.start();
|
await vm.start();
|
||||||
|
|
||||||
// Set metadata from host
|
|
||||||
await vm.setMetadata({
|
await vm.setMetadata({
|
||||||
instance: { id: 'my-instance', region: 'eu-central-1' },
|
instance: { id: 'api-worker-1', region: 'local' },
|
||||||
secrets: { apiKey: 'sk-...' },
|
config: { mode: 'ephemeral' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Guest can access via: curl http://169.254.169.254/latest/meta-data/
|
console.log(await vm.getMetadata());
|
||||||
const data = await vm.getMetadata();
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Graceful Cleanup
|
## Error Handling
|
||||||
|
|
||||||
The module registers cleanup handlers via `@push.rocks/smartexit` so resources are released even if your process crashes:
|
All package-level failures use `SmartVMError` with structured codes.
|
||||||
|
|
||||||
- 🔌 Firecracker child processes are killed
|
|
||||||
- 🧹 Unix socket files are removed
|
|
||||||
- 🌐 TAP devices are deleted
|
|
||||||
- 🌉 Bridge and NAT rules are torn down
|
|
||||||
|
|
||||||
You can also trigger cleanup manually:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Stop one VM
|
import { SmartVMError } from '@push.rocks/smartvm';
|
||||||
await vm.stop();
|
|
||||||
await vm.cleanup();
|
|
||||||
|
|
||||||
// Stop all VMs and clean everything
|
try {
|
||||||
await smartvm.stopAll();
|
await vm.start();
|
||||||
await smartvm.cleanup();
|
} catch (err) {
|
||||||
|
if (err instanceof SmartVMError) {
|
||||||
|
console.error(err.code);
|
||||||
|
console.error(err.statusCode);
|
||||||
|
console.error(err.details);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## TypeScript Interfaces
|
| Code | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `INVALID_STATE` | Operation is invalid for the current VM state. |
|
||||||
|
| `INVALID_CONFIG` | VM configuration failed validation. |
|
||||||
|
| `SOCKET_TIMEOUT` | Firecracker did not create its socket in time. |
|
||||||
|
| `API_TIMEOUT` | Firecracker API readiness check timed out. |
|
||||||
|
| `SOCKET_REQUEST_FAILED` | Unix-socket HTTP request failed. |
|
||||||
|
| `API_ERROR` | Firecracker returned a non-2xx response through a high-level VM call. |
|
||||||
|
| `BINARY_NOT_FOUND` | Custom Firecracker binary path does not exist. |
|
||||||
|
| `DOWNLOAD_FAILED` | Binary, kernel, or rootfs download failed. |
|
||||||
|
| `VERSION_FETCH_FAILED` | Latest Firecracker version lookup failed. |
|
||||||
|
| `BASE_IMAGE_RESOLVE_FAILED` | Firecracker CI base-image artifact resolution failed. |
|
||||||
|
| `BASE_IMAGE_MANIFEST_FAILED` | Hosted manifest could not be loaded or used. |
|
||||||
|
| `BASE_IMAGE_PREPARE_FAILED` | Base-image download/copy/verification failed. |
|
||||||
|
| `INVALID_BASE_IMAGE_MANIFEST` | Hosted manifest schema or artifact metadata is invalid. |
|
||||||
|
| `INVALID_BASE_IMAGE_CACHE_LIMIT` | Base-image retention limit is invalid. |
|
||||||
|
| `INVALID_SUBNET` | Subnet is not a supported IPv4 CIDR. |
|
||||||
|
| `INVALID_INTERFACE_NAME` | Bridge or TAP name is invalid. |
|
||||||
|
| `IP_EXHAUSTED` | No guest IPs remain in the configured subnet. |
|
||||||
|
| `BRIDGE_SETUP_FAILED` | Bridge/NAT setup failed. |
|
||||||
|
| `TAP_CREATE_FAILED` | TAP creation failed. |
|
||||||
|
| `ROOTFS_CREATE_FAILED` | Blank rootfs creation failed. |
|
||||||
|
| `ROOTFS_CLONE_FAILED` | Rootfs clone failed. |
|
||||||
|
| `START_FAILED` | VM start sequence failed. |
|
||||||
|
| `NO_CLIENT` | Socket client is not initialized. |
|
||||||
|
|
||||||
All configuration interfaces are fully exported for type-safe usage:
|
## Testing
|
||||||
|
|
||||||
|
Default tests are safe on machines without KVM or root privileges:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
pnpm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The default suite covers config validation, payload generation, lifecycle guards, base-image cache behavior, hosted manifest validation, VM tracking, ephemeral drive staging, and subnet/IP behavior.
|
||||||
|
|
||||||
|
Opt into real Firecracker boot tests on a Linux/KVM host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SMARTVM_RUN_INTEGRATION=true pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepted truthy values for `SMARTVM_RUN_INTEGRATION`: `1`, `true`, `yes`.
|
||||||
|
|
||||||
|
Useful integration-test environment variables:
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `SMARTVM_BASE_IMAGE_PRESET` | `latest` or `lts`; default is `latest`. |
|
||||||
|
| `SMARTVM_BASE_IMAGE_MANIFEST_URL` | Hosted/project-owned base-image manifest URL. |
|
||||||
|
| `SMARTVM_BASE_IMAGE_MANIFEST_PATH` | Local hosted manifest path. |
|
||||||
|
| `SMARTVM_BASE_IMAGE_CACHE_DIR` | Override `/tmp/.smartvm/base-images`. |
|
||||||
|
| `SMARTVM_MAX_STORED_BASE_IMAGES` | Override default retention of `2`. |
|
||||||
|
| `SMARTVM_FIRECRACKER_VERSION` | Override the Firecracker binary version. |
|
||||||
|
| `SMARTVM_ARCH` | `x86_64` or `aarch64`; defaults from host architecture. |
|
||||||
|
| `SMARTVM_INTEGRATION_DATA_DIR` | Override the Firecracker binary data directory used by integration tests. |
|
||||||
|
|
||||||
|
## TypeScript Surface
|
||||||
|
|
||||||
|
Main exports:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export {
|
||||||
|
SmartVM,
|
||||||
|
MicroVM,
|
||||||
|
NetworkManager,
|
||||||
|
FirecrackerProcess,
|
||||||
|
BaseImageManager,
|
||||||
|
ImageManager,
|
||||||
|
SocketClient,
|
||||||
|
VMConfig,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Important exported types:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import type {
|
import type {
|
||||||
ISmartVMOptions,
|
ISmartVMOptions,
|
||||||
|
IMicroVMRuntimeOptions,
|
||||||
IMicroVMConfig,
|
IMicroVMConfig,
|
||||||
IBootSource,
|
IBootSource,
|
||||||
IMachineConfig,
|
IMachineConfig,
|
||||||
@@ -451,6 +651,11 @@ import type {
|
|||||||
IMmdsConfig,
|
IMmdsConfig,
|
||||||
ILoggerConfig,
|
ILoggerConfig,
|
||||||
IMetricsConfig,
|
IMetricsConfig,
|
||||||
|
IBaseImageManagerOptions,
|
||||||
|
IEnsureBaseImageOptions,
|
||||||
|
IBaseImageBundle,
|
||||||
|
IBaseImageHostedManifest,
|
||||||
|
IBaseImageArtifactManifest,
|
||||||
ISnapshotCreateParams,
|
ISnapshotCreateParams,
|
||||||
ISnapshotLoadParams,
|
ISnapshotLoadParams,
|
||||||
IRateLimiter,
|
IRateLimiter,
|
||||||
@@ -463,6 +668,8 @@ import type {
|
|||||||
TCacheType,
|
TCacheType,
|
||||||
TSnapshotType,
|
TSnapshotType,
|
||||||
TLogLevel,
|
TLogLevel,
|
||||||
|
TBaseImagePreset,
|
||||||
|
TBaseImageRootfsType,
|
||||||
} from '@push.rocks/smartvm';
|
} from '@push.rocks/smartvm';
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import { BaseImageManager, SmartVM } from '../ts/index.js';
|
||||||
|
import type { TBaseImagePreset, TFirecrackerArch } from '../ts/index.js';
|
||||||
|
|
||||||
|
const integrationEnabled = ['1', 'true', 'yes'].includes(
|
||||||
|
(process.env.SMARTVM_RUN_INTEGRATION || '').toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
function getHostArch(): TFirecrackerArch {
|
||||||
|
return process.arch === 'arm64' ? 'aarch64' : 'x86_64';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertHostReady(): Promise<void> {
|
||||||
|
if (process.platform !== 'linux') {
|
||||||
|
throw new Error('Firecracker integration tests require Linux');
|
||||||
|
}
|
||||||
|
await fs.promises.access('/dev/kvm', fs.constants.R_OK | fs.constants.W_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
tap.test('SmartVM integration - boots a Firecracker CI base image when explicitly enabled', async () => {
|
||||||
|
if (!integrationEnabled) {
|
||||||
|
console.log('Skipping SmartVM integration test. Set SMARTVM_RUN_INTEGRATION=true to enable it.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertHostReady();
|
||||||
|
|
||||||
|
const arch = (process.env.SMARTVM_ARCH as TFirecrackerArch | undefined) || getHostArch();
|
||||||
|
const preset = (process.env.SMARTVM_BASE_IMAGE_PRESET as TBaseImagePreset | undefined) || 'latest';
|
||||||
|
const maxStoredBaseImages = process.env.SMARTVM_MAX_STORED_BASE_IMAGES
|
||||||
|
? Number(process.env.SMARTVM_MAX_STORED_BASE_IMAGES)
|
||||||
|
: undefined;
|
||||||
|
const baseImageManager = new BaseImageManager({
|
||||||
|
arch,
|
||||||
|
cacheDir: process.env.SMARTVM_BASE_IMAGE_CACHE_DIR,
|
||||||
|
maxStoredBaseImages,
|
||||||
|
hostedManifestUrl: process.env.SMARTVM_BASE_IMAGE_MANIFEST_URL,
|
||||||
|
hostedManifestPath: process.env.SMARTVM_BASE_IMAGE_MANIFEST_PATH,
|
||||||
|
});
|
||||||
|
const baseImage = await baseImageManager.ensureBaseImage({
|
||||||
|
preset,
|
||||||
|
manifestUrl: process.env.SMARTVM_BASE_IMAGE_MANIFEST_URL,
|
||||||
|
manifestPath: process.env.SMARTVM_BASE_IMAGE_MANIFEST_PATH,
|
||||||
|
});
|
||||||
|
|
||||||
|
const smartvm = new SmartVM({
|
||||||
|
arch,
|
||||||
|
dataDir: process.env.SMARTVM_INTEGRATION_DATA_DIR || path.join(os.tmpdir(), '.smartvm-integration'),
|
||||||
|
firecrackerVersion: process.env.SMARTVM_FIRECRACKER_VERSION || baseImage.firecrackerVersion,
|
||||||
|
baseImageCacheDir: process.env.SMARTVM_BASE_IMAGE_CACHE_DIR,
|
||||||
|
maxStoredBaseImages,
|
||||||
|
baseImageManifestUrl: process.env.SMARTVM_BASE_IMAGE_MANIFEST_URL,
|
||||||
|
baseImageManifestPath: process.env.SMARTVM_BASE_IMAGE_MANIFEST_PATH,
|
||||||
|
});
|
||||||
|
const vm = await smartvm.createVM({
|
||||||
|
id: `smartvm-it-${Date.now()}`,
|
||||||
|
bootSource: {
|
||||||
|
kernelImagePath: baseImage.kernelImagePath,
|
||||||
|
bootArgs: baseImage.bootArgs,
|
||||||
|
},
|
||||||
|
machineConfig: {
|
||||||
|
vcpuCount: 1,
|
||||||
|
memSizeMib: 256,
|
||||||
|
},
|
||||||
|
drives: [
|
||||||
|
{
|
||||||
|
driveId: 'rootfs',
|
||||||
|
pathOnHost: baseImage.rootfsPath,
|
||||||
|
isRootDevice: true,
|
||||||
|
isReadOnly: baseImage.rootfsIsReadOnly,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await vm.start();
|
||||||
|
expect(vm.state).toEqual('running');
|
||||||
|
expect(await vm.getInfo()).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
if (vm.state === 'running' || vm.state === 'paused') {
|
||||||
|
await vm.stop();
|
||||||
|
}
|
||||||
|
await vm.cleanup();
|
||||||
|
await smartvm.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
+548
-1
@@ -1,11 +1,31 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import {
|
import {
|
||||||
|
BaseImageManager,
|
||||||
VMConfig,
|
VMConfig,
|
||||||
SocketClient,
|
SocketClient,
|
||||||
NetworkManager,
|
NetworkManager,
|
||||||
|
MicroVM,
|
||||||
SmartVM,
|
SmartVM,
|
||||||
|
SmartVMError,
|
||||||
} from '../ts/index.js';
|
} from '../ts/index.js';
|
||||||
import type { IMicroVMConfig } from '../ts/index.js';
|
import type { IBaseImageBundle, IBaseImageHostedManifest, IMicroVMConfig } from '../ts/index.js';
|
||||||
|
|
||||||
|
async function getRejectedError(promise: Promise<unknown>): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
await promise;
|
||||||
|
} catch (err) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256Buffer(buffer: Buffer): string {
|
||||||
|
return crypto.createHash('sha256').update(buffer).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// VMConfig Tests
|
// VMConfig Tests
|
||||||
@@ -87,6 +107,33 @@ tap.test('VMConfig - validate() should fail for multiple root drives', async ()
|
|||||||
expect(result.valid).toBeFalse();
|
expect(result.valid).toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('VMConfig - validate() should fail for invalid vsock guestCid', async () => {
|
||||||
|
const vmConfig = new VMConfig({
|
||||||
|
bootSource: { kernelImagePath: '/vmlinux' },
|
||||||
|
machineConfig: { vcpuCount: 1, memSizeMib: 128 },
|
||||||
|
vsock: { guestCid: 2, udsPath: '/tmp/vsock.sock' },
|
||||||
|
});
|
||||||
|
const result = vmConfig.validate();
|
||||||
|
expect(result.valid).toBeFalse();
|
||||||
|
expect(result.errors).toContain('vsock.guestCid must be >= 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('VMConfig - constructor should not retain caller references', async () => {
|
||||||
|
const config: IMicroVMConfig = {
|
||||||
|
...sampleConfig,
|
||||||
|
networkInterfaces: [{ ifaceId: 'eth0', guestMac: '02:00:00:00:00:01' }],
|
||||||
|
mmds: { version: 'V2', networkInterfaces: ['eth0'] },
|
||||||
|
};
|
||||||
|
const vmConfig = new VMConfig(config);
|
||||||
|
|
||||||
|
config.networkInterfaces![0].guestMac = '02:00:00:00:00:02';
|
||||||
|
config.mmds!.networkInterfaces.push('eth1');
|
||||||
|
|
||||||
|
expect(vmConfig.toNetworkInterfacePayload(vmConfig.config.networkInterfaces![0]).guest_mac)
|
||||||
|
.toEqual('02:00:00:00:00:01');
|
||||||
|
expect(vmConfig.toMmdsConfigPayload()!.network_interfaces).toEqual(['eth0']);
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('VMConfig - toBootSourcePayload() should generate correct snake_case', async () => {
|
tap.test('VMConfig - toBootSourcePayload() should generate correct snake_case', async () => {
|
||||||
const vmConfig = new VMConfig(sampleConfig);
|
const vmConfig = new VMConfig(sampleConfig);
|
||||||
const payload = vmConfig.toBootSourcePayload();
|
const payload = vmConfig.toBootSourcePayload();
|
||||||
@@ -144,6 +191,65 @@ tap.test('VMConfig - toBalloonPayload() should generate correct payload', async
|
|||||||
expect(payload!.stats_polling_interval_s).toEqual(5);
|
expect(payload!.stats_polling_interval_s).toEqual(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('VMConfig - toVsockPayload() should generate correct payload', async () => {
|
||||||
|
const config: IMicroVMConfig = {
|
||||||
|
...sampleConfig,
|
||||||
|
vsock: { guestCid: 3, udsPath: '/tmp/vsock.sock' },
|
||||||
|
};
|
||||||
|
const vmConfig = new VMConfig(config);
|
||||||
|
const payload = vmConfig.toVsockPayload();
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
expect(payload!.guest_cid).toEqual(3);
|
||||||
|
expect(payload!.uds_path).toEqual('/tmp/vsock.sock');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('VMConfig - toMmdsConfigPayload() should generate correct payload', async () => {
|
||||||
|
const config: IMicroVMConfig = {
|
||||||
|
...sampleConfig,
|
||||||
|
mmds: { version: 'V2', networkInterfaces: ['eth0'] },
|
||||||
|
};
|
||||||
|
const vmConfig = new VMConfig(config);
|
||||||
|
const payload = vmConfig.toMmdsConfigPayload();
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
expect(payload!.version).toEqual('V2');
|
||||||
|
expect(payload!.network_interfaces).toEqual(['eth0']);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('VMConfig - toMetricsPayload() should generate correct payload', async () => {
|
||||||
|
const config: IMicroVMConfig = {
|
||||||
|
...sampleConfig,
|
||||||
|
metrics: { metricsPath: '/tmp/firecracker-metrics.fifo' },
|
||||||
|
};
|
||||||
|
const vmConfig = new VMConfig(config);
|
||||||
|
const payload = vmConfig.toMetricsPayload();
|
||||||
|
expect(payload).not.toBeNull();
|
||||||
|
expect(payload!.metrics_path).toEqual('/tmp/firecracker-metrics.fifo');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('VMConfig - toDrivePayload() should include rate limiter payloads', async () => {
|
||||||
|
const vmConfig = new VMConfig(sampleConfig);
|
||||||
|
const payload = vmConfig.toDrivePayload({
|
||||||
|
driveId: 'data',
|
||||||
|
pathOnHost: '/path/to/data.ext4',
|
||||||
|
isRootDevice: false,
|
||||||
|
rateLimiter: {
|
||||||
|
bandwidth: { size: 1000, refillTime: 2000, oneTimeBurst: 3000 },
|
||||||
|
ops: { size: 10, refillTime: 20, oneTimeBurst: 30 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.rate_limiter.bandwidth).toEqual({
|
||||||
|
size: 1000,
|
||||||
|
refill_time: 2000,
|
||||||
|
one_time_burst: 3000,
|
||||||
|
});
|
||||||
|
expect(payload.rate_limiter.ops).toEqual({
|
||||||
|
size: 10,
|
||||||
|
refill_time: 20,
|
||||||
|
one_time_burst: 30,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('VMConfig - toLoggerPayload() should generate correct payload', async () => {
|
tap.test('VMConfig - toLoggerPayload() should generate correct payload', async () => {
|
||||||
const config: IMicroVMConfig = {
|
const config: IMicroVMConfig = {
|
||||||
...sampleConfig,
|
...sampleConfig,
|
||||||
@@ -167,6 +273,269 @@ tap.test('SocketClient - URL construction', async () => {
|
|||||||
expect(client).toBeTruthy();
|
expect(client).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// BaseImageManager Tests
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
tap.test('BaseImageManager - instantiation with defaults', async () => {
|
||||||
|
const manager = new BaseImageManager();
|
||||||
|
expect(manager.getCacheDir()).toEqual(path.join(os.tmpdir(), '.smartvm', 'base-images'));
|
||||||
|
expect(manager.getMaxStoredBaseImages()).toEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('BaseImageManager - rejects invalid maxStoredBaseImages', async () => {
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
new BaseImageManager({ maxStoredBaseImages: 0 });
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((error as SmartVMError).code).toEqual('INVALID_BASE_IMAGE_CACHE_LIMIT');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('BaseImageManager - pruneBaseImageCache() should evict old bundles', async () => {
|
||||||
|
const cacheDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-base-image-test-'));
|
||||||
|
const manager = new BaseImageManager({ cacheDir, maxStoredBaseImages: 2 });
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
const warnings: string[] = [];
|
||||||
|
console.warn = (message?: any) => {
|
||||||
|
warnings.push(String(message));
|
||||||
|
};
|
||||||
|
|
||||||
|
const createManifest = async (bundleId: string, lastAccessedAt: string) => {
|
||||||
|
const bundleDir = path.join(cacheDir, bundleId);
|
||||||
|
await fs.promises.mkdir(bundleDir, { recursive: true });
|
||||||
|
const bundle: IBaseImageBundle = {
|
||||||
|
preset: 'lts',
|
||||||
|
arch: 'x86_64',
|
||||||
|
ciVersion: 'v1.7',
|
||||||
|
firecrackerVersion: 'v1.7.0',
|
||||||
|
bundleId,
|
||||||
|
bundleDir,
|
||||||
|
kernelImagePath: path.join(bundleDir, 'vmlinux'),
|
||||||
|
rootfsPath: path.join(bundleDir, 'rootfs.ext4'),
|
||||||
|
rootfsType: 'ext4',
|
||||||
|
rootfsIsReadOnly: false,
|
||||||
|
bootArgs: 'console=ttyS0 reboot=k panic=1 pci=off',
|
||||||
|
source: {
|
||||||
|
bucketUrl: 'https://s3.amazonaws.com/spec.ccfc.min',
|
||||||
|
kernelKey: 'kernel',
|
||||||
|
rootfsKey: 'rootfs',
|
||||||
|
},
|
||||||
|
createdAt: lastAccessedAt,
|
||||||
|
lastAccessedAt,
|
||||||
|
};
|
||||||
|
await fs.promises.writeFile(path.join(bundleDir, 'manifest.json'), `${JSON.stringify(bundle, null, 2)}\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createManifest('old', '2024-01-01T00:00:00.000Z');
|
||||||
|
await createManifest('middle', '2024-01-02T00:00:00.000Z');
|
||||||
|
await createManifest('new', '2024-01-03T00:00:00.000Z');
|
||||||
|
|
||||||
|
const evicted = await manager.pruneBaseImageCache('new');
|
||||||
|
expect(evicted).toEqual(['old']);
|
||||||
|
expect(warnings.length).toEqual(1);
|
||||||
|
expect(warnings[0]).toInclude('Evicting old');
|
||||||
|
expect(fs.existsSync(path.join(cacheDir, 'old'))).toBeFalse();
|
||||||
|
expect(fs.existsSync(path.join(cacheDir, 'middle'))).toBeTrue();
|
||||||
|
expect(fs.existsSync(path.join(cacheDir, 'new'))).toBeTrue();
|
||||||
|
} finally {
|
||||||
|
console.warn = originalWarn;
|
||||||
|
await fs.promises.rm(cacheDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('BaseImageManager - ensureBaseImage() should copy hosted manifest artifacts', async () => {
|
||||||
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-test-'));
|
||||||
|
const cacheDir = path.join(workDir, 'cache');
|
||||||
|
const assetsDir = path.join(workDir, 'assets');
|
||||||
|
await fs.promises.mkdir(assetsDir, { recursive: true });
|
||||||
|
|
||||||
|
const kernelBuffer = Buffer.from('fake-kernel');
|
||||||
|
const rootfsBuffer = Buffer.from('fake-rootfs');
|
||||||
|
const kernelPath = path.join(assetsDir, 'vmlinux-test');
|
||||||
|
const rootfsPath = path.join(assetsDir, 'rootfs-test.ext4');
|
||||||
|
await fs.promises.writeFile(kernelPath, kernelBuffer);
|
||||||
|
await fs.promises.writeFile(rootfsPath, rootfsBuffer);
|
||||||
|
|
||||||
|
const manifest: IBaseImageHostedManifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
bundleId: 'smartvm-minimal-test',
|
||||||
|
name: 'SmartVM minimal test bundle',
|
||||||
|
arch: 'x86_64',
|
||||||
|
firecrackerVersion: 'v1.15.1',
|
||||||
|
rootfsType: 'ext4',
|
||||||
|
rootfsIsReadOnly: false,
|
||||||
|
bootArgs: 'console=ttyS0 reboot=k panic=1 pci=off',
|
||||||
|
kernel: {
|
||||||
|
path: kernelPath,
|
||||||
|
fileName: 'vmlinux',
|
||||||
|
sha256: sha256Buffer(kernelBuffer),
|
||||||
|
sizeBytes: kernelBuffer.length,
|
||||||
|
},
|
||||||
|
rootfs: {
|
||||||
|
path: rootfsPath,
|
||||||
|
fileName: 'rootfs.ext4',
|
||||||
|
sha256: sha256Buffer(rootfsBuffer),
|
||||||
|
sizeBytes: rootfsBuffer.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const manifestPath = path.join(workDir, 'manifest.json');
|
||||||
|
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = new BaseImageManager({ cacheDir });
|
||||||
|
const bundle = await manager.ensureBaseImage({ manifestPath });
|
||||||
|
|
||||||
|
expect(bundle.preset).toEqual('hosted');
|
||||||
|
expect(bundle.bundleId).toEqual('smartvm-minimal-test');
|
||||||
|
expect(bundle.firecrackerVersion).toEqual('v1.15.1');
|
||||||
|
expect(bundle.source.type).toEqual('hosted-manifest');
|
||||||
|
expect(bundle.source.manifestPath).toEqual(manifestPath);
|
||||||
|
expect(fs.existsSync(bundle.kernelImagePath)).toBeTrue();
|
||||||
|
expect(fs.existsSync(bundle.rootfsPath)).toBeTrue();
|
||||||
|
expect(bundle.checksums!.kernelSha256).toEqual(sha256Buffer(kernelBuffer));
|
||||||
|
expect(bundle.checksums!.rootfsSha256).toEqual(sha256Buffer(rootfsBuffer));
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('BaseImageManager - ensureBaseImage() should redownload corrupted cached artifacts', async () => {
|
||||||
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-cache-test-'));
|
||||||
|
const cacheDir = path.join(workDir, 'cache');
|
||||||
|
const assetsDir = path.join(workDir, 'assets');
|
||||||
|
await fs.promises.mkdir(assetsDir, { recursive: true });
|
||||||
|
|
||||||
|
const kernelBuffer = Buffer.from('fresh-kernel');
|
||||||
|
const rootfsBuffer = Buffer.from('fresh-rootfs');
|
||||||
|
const kernelPath = path.join(assetsDir, 'vmlinux-test');
|
||||||
|
const rootfsPath = path.join(assetsDir, 'rootfs-test.ext4');
|
||||||
|
await fs.promises.writeFile(kernelPath, kernelBuffer);
|
||||||
|
await fs.promises.writeFile(rootfsPath, rootfsBuffer);
|
||||||
|
|
||||||
|
const manifest: IBaseImageHostedManifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
bundleId: 'smartvm-corruption-test',
|
||||||
|
arch: 'x86_64',
|
||||||
|
firecrackerVersion: 'v1.15.1',
|
||||||
|
rootfsType: 'ext4',
|
||||||
|
kernel: {
|
||||||
|
path: kernelPath,
|
||||||
|
fileName: 'vmlinux',
|
||||||
|
sha256: sha256Buffer(kernelBuffer),
|
||||||
|
sizeBytes: kernelBuffer.length,
|
||||||
|
},
|
||||||
|
rootfs: {
|
||||||
|
path: rootfsPath,
|
||||||
|
fileName: 'rootfs.ext4',
|
||||||
|
sha256: sha256Buffer(rootfsBuffer),
|
||||||
|
sizeBytes: rootfsBuffer.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const manifestPath = path.join(workDir, 'manifest.json');
|
||||||
|
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = new BaseImageManager({ cacheDir });
|
||||||
|
const firstBundle = await manager.ensureBaseImage({ manifestPath });
|
||||||
|
await fs.promises.writeFile(firstBundle.kernelImagePath, 'tampered-kernel');
|
||||||
|
|
||||||
|
const secondBundle = await manager.ensureBaseImage({ manifestPath });
|
||||||
|
expect(await fs.promises.readFile(secondBundle.kernelImagePath, 'utf8')).toEqual('fresh-kernel');
|
||||||
|
expect(secondBundle.checksums!.kernelSha256).toEqual(sha256Buffer(kernelBuffer));
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('BaseImageManager - ensureBaseImage() should reject hosted manifest arch mismatch', async () => {
|
||||||
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-invalid-test-'));
|
||||||
|
const manifestPath = path.join(workDir, 'manifest.json');
|
||||||
|
const manifest: IBaseImageHostedManifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
bundleId: 'smartvm-invalid-arch-test',
|
||||||
|
arch: 'aarch64',
|
||||||
|
firecrackerVersion: 'v1.15.1',
|
||||||
|
rootfsType: 'ext4',
|
||||||
|
kernel: { path: path.join(workDir, 'vmlinux') },
|
||||||
|
rootfs: { path: path.join(workDir, 'rootfs.ext4') },
|
||||||
|
};
|
||||||
|
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = new BaseImageManager({ cacheDir: path.join(workDir, 'cache'), arch: 'x86_64' });
|
||||||
|
const error = await getRejectedError(manager.ensureBaseImage({ manifestPath }));
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((error as SmartVMError).code).toEqual('INVALID_BASE_IMAGE_MANIFEST');
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('BaseImageManager - ensureBaseImage() should reject hosted manifest fileName traversal', async () => {
|
||||||
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-filename-test-'));
|
||||||
|
const manifestPath = path.join(workDir, 'manifest.json');
|
||||||
|
const manifest: IBaseImageHostedManifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
bundleId: 'smartvm-invalid-filename-test',
|
||||||
|
arch: 'x86_64',
|
||||||
|
firecrackerVersion: 'v1.15.1',
|
||||||
|
rootfsType: 'ext4',
|
||||||
|
kernel: { path: path.join(workDir, 'vmlinux'), fileName: '../vmlinux' },
|
||||||
|
rootfs: { path: path.join(workDir, 'rootfs.ext4'), fileName: 'rootfs.ext4' },
|
||||||
|
};
|
||||||
|
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = new BaseImageManager({ cacheDir: path.join(workDir, 'cache') });
|
||||||
|
const error = await getRejectedError(manager.ensureBaseImage({ manifestPath }));
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((error as SmartVMError).code).toEqual('INVALID_BASE_IMAGE_MANIFEST');
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('BaseImageManager - ensureBaseImage() should reject hosted URL artifacts without sha256', async () => {
|
||||||
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-url-test-'));
|
||||||
|
const manifestPath = path.join(workDir, 'manifest.json');
|
||||||
|
const manifest: IBaseImageHostedManifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
bundleId: 'smartvm-invalid-url-test',
|
||||||
|
arch: 'x86_64',
|
||||||
|
firecrackerVersion: 'v1.15.1',
|
||||||
|
rootfsType: 'ext4',
|
||||||
|
kernel: { url: 'https://example.com/vmlinux' },
|
||||||
|
rootfs: { path: path.join(workDir, 'rootfs.ext4') },
|
||||||
|
};
|
||||||
|
await fs.promises.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = new BaseImageManager({ cacheDir: path.join(workDir, 'cache') });
|
||||||
|
const error = await getRejectedError(manager.ensureBaseImage({ manifestPath }));
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((error as SmartVMError).code).toEqual('INVALID_BASE_IMAGE_MANIFEST');
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('BaseImageManager - hosted preset should require a manifest', async () => {
|
||||||
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-hosted-image-missing-test-'));
|
||||||
|
try {
|
||||||
|
const manager = new BaseImageManager({ cacheDir: path.join(workDir, 'cache') });
|
||||||
|
const error = await getRejectedError(manager.ensureBaseImage({ preset: 'hosted' }));
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((error as SmartVMError).code).toEqual('BASE_IMAGE_MANIFEST_FAILED');
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// NetworkManager Tests
|
// NetworkManager Tests
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -181,6 +550,65 @@ tap.test('NetworkManager - allocateIp() should allocate sequential IPs', async (
|
|||||||
expect(ip3).toEqual('172.30.0.4');
|
expect(ip3).toEqual('172.30.0.4');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('NetworkManager - allocateIp() should normalize non-network CIDR input', async () => {
|
||||||
|
const nm = new NetworkManager({ subnet: '10.20.30.17/29' });
|
||||||
|
expect(nm.allocateIp()).toEqual('10.20.30.18');
|
||||||
|
expect(nm.allocateIp()).toEqual('10.20.30.19');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('NetworkManager - allocateIp() should fail when subnet is exhausted', async () => {
|
||||||
|
const nm = new NetworkManager({ subnet: '192.168.100.0/30' });
|
||||||
|
expect(nm.allocateIp()).toEqual('192.168.100.2');
|
||||||
|
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
nm.allocateIp();
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((error as SmartVMError).code).toEqual('IP_EXHAUSTED');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('NetworkManager - constructor should reject invalid subnet input', async () => {
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
new NetworkManager({ subnet: '10.0.0.0/31' });
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((error as SmartVMError).code).toEqual('INVALID_SUBNET');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('NetworkManager - constructor should reject malformed IPv4 octets', async () => {
|
||||||
|
for (const subnet of ['10..0.1/24', '10.0x10.0.1/24', '10.0.0.1 /24']) {
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
new NetworkManager({ subnet });
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((error as SmartVMError).code).toEqual('INVALID_SUBNET');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('NetworkManager - constructor should reject invalid bridge names', async () => {
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
new NetworkManager({ bridgeName: 'bad bridge' });
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((error as SmartVMError).code).toEqual('INVALID_INTERFACE_NAME');
|
||||||
|
});
|
||||||
|
|
||||||
tap.test('NetworkManager - generateMac() should generate locally-administered MACs', async () => {
|
tap.test('NetworkManager - generateMac() should generate locally-administered MACs', async () => {
|
||||||
const nm = new NetworkManager();
|
const nm = new NetworkManager();
|
||||||
const mac1 = nm.generateMac('vm1', 'eth0');
|
const mac1 = nm.generateMac('vm1', 'eth0');
|
||||||
@@ -228,6 +656,106 @@ tap.test('NetworkManager - getGuestNetworkBootArgs() should format correctly', a
|
|||||||
expect(bootArgs).toEqual('ip=172.30.0.2::172.30.0.1:255.255.255.0::eth0:off');
|
expect(bootArgs).toEqual('ip=172.30.0.2::172.30.0.1:255.255.255.0::eth0:off');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// MicroVM Tests
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
tap.test('MicroVM - invalid lifecycle calls should throw SmartVMError', async () => {
|
||||||
|
const vm = new MicroVM(
|
||||||
|
'lifecycle-vm',
|
||||||
|
sampleConfig,
|
||||||
|
'/bin/false',
|
||||||
|
'/tmp/smartvm-lifecycle.sock',
|
||||||
|
new NetworkManager(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pauseError = await getRejectedError(vm.pause());
|
||||||
|
expect(pauseError).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((pauseError as SmartVMError).code).toEqual('INVALID_STATE');
|
||||||
|
|
||||||
|
const infoError = await getRejectedError(vm.getInfo());
|
||||||
|
expect(infoError).toBeInstanceOf(SmartVMError);
|
||||||
|
expect((infoError as SmartVMError).code).toEqual('NO_CLIENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('MicroVM - start() should stage writable drives ephemerally and clean them up on failure', async () => {
|
||||||
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-ephemeral-drive-test-'));
|
||||||
|
const runtimeDir = path.join(workDir, 'runtime');
|
||||||
|
const sourceRootfs = path.join(workDir, 'rootfs.ext4');
|
||||||
|
await fs.promises.writeFile(sourceRootfs, 'persistent-rootfs');
|
||||||
|
|
||||||
|
const config: IMicroVMConfig = {
|
||||||
|
...sampleConfig,
|
||||||
|
id: 'ephemeral-vm',
|
||||||
|
drives: [
|
||||||
|
{
|
||||||
|
driveId: 'rootfs',
|
||||||
|
pathOnHost: sourceRootfs,
|
||||||
|
isRootDevice: true,
|
||||||
|
isReadOnly: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
networkInterfaces: [],
|
||||||
|
};
|
||||||
|
const vm = new MicroVM(
|
||||||
|
'ephemeral-vm',
|
||||||
|
config,
|
||||||
|
'/bin/false',
|
||||||
|
path.join(runtimeDir, 'ephemeral-vm', 'firecracker.sock'),
|
||||||
|
new NetworkManager(),
|
||||||
|
{ runtimeDir },
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const error = await getRejectedError(vm.start());
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect(fs.existsSync(path.join(runtimeDir, 'ephemeral-vm'))).toBeFalse();
|
||||||
|
expect(await fs.promises.readFile(sourceRootfs, 'utf8')).toEqual('persistent-rootfs');
|
||||||
|
expect(vm.getVMConfig().config.drives![0].pathOnHost).not.toEqual(sourceRootfs);
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('MicroVM - start() should honor per-drive ephemeral opt-out', async () => {
|
||||||
|
const workDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'smartvm-persistent-drive-test-'));
|
||||||
|
const runtimeDir = path.join(workDir, 'runtime');
|
||||||
|
const sourceRootfs = path.join(workDir, 'rootfs.ext4');
|
||||||
|
await fs.promises.writeFile(sourceRootfs, 'persistent-rootfs');
|
||||||
|
|
||||||
|
const config: IMicroVMConfig = {
|
||||||
|
...sampleConfig,
|
||||||
|
id: 'persistent-vm',
|
||||||
|
drives: [
|
||||||
|
{
|
||||||
|
driveId: 'rootfs',
|
||||||
|
pathOnHost: sourceRootfs,
|
||||||
|
isRootDevice: true,
|
||||||
|
isReadOnly: false,
|
||||||
|
ephemeral: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
networkInterfaces: [],
|
||||||
|
};
|
||||||
|
const vm = new MicroVM(
|
||||||
|
'persistent-vm',
|
||||||
|
config,
|
||||||
|
'/bin/false',
|
||||||
|
path.join(runtimeDir, 'persistent-vm', 'firecracker.sock'),
|
||||||
|
new NetworkManager(),
|
||||||
|
{ runtimeDir },
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const error = await getRejectedError(vm.start());
|
||||||
|
expect(error).toBeInstanceOf(SmartVMError);
|
||||||
|
expect(vm.getVMConfig().config.drives![0].pathOnHost).toEqual(sourceRootfs);
|
||||||
|
expect(fs.existsSync(path.join(runtimeDir, 'persistent-vm'))).toBeFalse();
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(workDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// SmartVM Tests
|
// SmartVM Tests
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -236,7 +764,11 @@ tap.test('SmartVM - instantiation with defaults', async () => {
|
|||||||
const smartvm = new SmartVM();
|
const smartvm = new SmartVM();
|
||||||
expect(smartvm).toBeTruthy();
|
expect(smartvm).toBeTruthy();
|
||||||
expect(smartvm.imageManager).toBeTruthy();
|
expect(smartvm.imageManager).toBeTruthy();
|
||||||
|
expect(smartvm.baseImageManager).toBeTruthy();
|
||||||
expect(smartvm.networkManager).toBeTruthy();
|
expect(smartvm.networkManager).toBeTruthy();
|
||||||
|
if (fs.existsSync('/dev/shm')) {
|
||||||
|
expect(smartvm.getRuntimeDir()).toEqual('/dev/shm/.smartvm/runtime');
|
||||||
|
}
|
||||||
expect(smartvm.vmCount).toEqual(0);
|
expect(smartvm.vmCount).toEqual(0);
|
||||||
expect(smartvm.listVMs()).toHaveLength(0);
|
expect(smartvm.listVMs()).toHaveLength(0);
|
||||||
});
|
});
|
||||||
@@ -251,4 +783,19 @@ tap.test('SmartVM - instantiation with custom options', async () => {
|
|||||||
expect(smartvm).toBeTruthy();
|
expect(smartvm).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('SmartVM - createVM() should track created VMs', async () => {
|
||||||
|
const smartvm = new SmartVM({
|
||||||
|
dataDir: '/tmp/smartvm-test',
|
||||||
|
firecrackerBinaryPath: '/bin/false',
|
||||||
|
});
|
||||||
|
const vm = await smartvm.createVM(sampleConfig);
|
||||||
|
|
||||||
|
expect(smartvm.vmCount).toEqual(1);
|
||||||
|
expect(smartvm.getVM(vm.id)).toEqual(vm);
|
||||||
|
expect(smartvm.listVMs()).toEqual([vm.id]);
|
||||||
|
|
||||||
|
await smartvm.cleanup();
|
||||||
|
expect(smartvm.vmCount).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
export default tap.start();
|
export default tap.start();
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartvm',
|
name: '@push.rocks/smartvm',
|
||||||
version: '1.1.0',
|
version: '1.3.1',
|
||||||
description: 'A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs'
|
description: 'A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,713 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import type {
|
||||||
|
IBaseImageArtifactManifest,
|
||||||
|
IBaseImageBundle,
|
||||||
|
IBaseImageHostedManifest,
|
||||||
|
IBaseImageManagerOptions,
|
||||||
|
IEnsureBaseImageOptions,
|
||||||
|
TBaseImagePreset,
|
||||||
|
TBaseImageRootfsType,
|
||||||
|
TFirecrackerArch,
|
||||||
|
} from './interfaces/index.js';
|
||||||
|
import { SmartVMError } from './interfaces/index.js';
|
||||||
|
|
||||||
|
const FIRECRACKER_CI_BUCKET_URL = 'https://s3.amazonaws.com/spec.ccfc.min';
|
||||||
|
const DEFAULT_MAX_STORED_BASE_IMAGES = 2;
|
||||||
|
const LTS_CI_VERSION = 'v1.7';
|
||||||
|
const LTS_FIRECRACKER_VERSION = 'v1.7.0';
|
||||||
|
|
||||||
|
type TShellExecResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawn']>>;
|
||||||
|
|
||||||
|
interface IResolvedBaseImageSource {
|
||||||
|
preset: TBaseImagePreset;
|
||||||
|
arch: TFirecrackerArch;
|
||||||
|
ciVersion: string;
|
||||||
|
firecrackerVersion: string;
|
||||||
|
kernelKey?: string;
|
||||||
|
rootfsKey?: string;
|
||||||
|
kernelUrl?: string;
|
||||||
|
rootfsUrl?: string;
|
||||||
|
kernelSourcePath?: string;
|
||||||
|
rootfsSourcePath?: string;
|
||||||
|
kernelFileName?: string;
|
||||||
|
rootfsFileName?: string;
|
||||||
|
expectedKernelSha256?: string;
|
||||||
|
expectedRootfsSha256?: string;
|
||||||
|
expectedKernelBytes?: number;
|
||||||
|
expectedRootfsBytes?: number;
|
||||||
|
rootfsType: TBaseImageRootfsType;
|
||||||
|
rootfsIsReadOnly: boolean;
|
||||||
|
bundleId: string;
|
||||||
|
bootArgs: string;
|
||||||
|
source: IBaseImageBundle['source'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads and retains Firecracker CI base images for integration testing.
|
||||||
|
*/
|
||||||
|
export class BaseImageManager {
|
||||||
|
private arch: TFirecrackerArch;
|
||||||
|
private cacheDir: string;
|
||||||
|
private maxStoredBaseImages: number;
|
||||||
|
private hostedManifestUrl?: string;
|
||||||
|
private hostedManifestPath?: string;
|
||||||
|
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
||||||
|
|
||||||
|
constructor(options: IBaseImageManagerOptions = {}) {
|
||||||
|
this.arch = options.arch || 'x86_64';
|
||||||
|
this.cacheDir = options.cacheDir || plugins.path.join(plugins.os.tmpdir(), '.smartvm', 'base-images');
|
||||||
|
this.maxStoredBaseImages = options.maxStoredBaseImages ?? DEFAULT_MAX_STORED_BASE_IMAGES;
|
||||||
|
this.hostedManifestUrl = options.hostedManifestUrl;
|
||||||
|
this.hostedManifestPath = options.hostedManifestPath;
|
||||||
|
if (!Number.isInteger(this.maxStoredBaseImages) || this.maxStoredBaseImages < 1) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
'maxStoredBaseImages must be a positive integer',
|
||||||
|
'INVALID_BASE_IMAGE_CACHE_LIMIT',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCacheDir(): string {
|
||||||
|
return this.cacheDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMaxStoredBaseImages(): number {
|
||||||
|
return this.maxStoredBaseImages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a base image bundle exists locally and return its paths.
|
||||||
|
*/
|
||||||
|
public async ensureBaseImage(options: IEnsureBaseImageOptions = {}): Promise<IBaseImageBundle> {
|
||||||
|
const source = await this.resolveBaseImageSource(options);
|
||||||
|
const bundleDir = plugins.path.join(this.cacheDir, source.bundleId);
|
||||||
|
const manifestPath = this.getManifestPath(bundleDir);
|
||||||
|
|
||||||
|
const cachedBundle = options.forceDownload ? undefined : await this.readCompleteBundle(bundleDir);
|
||||||
|
if (cachedBundle) {
|
||||||
|
const updatedBundle = {
|
||||||
|
...cachedBundle,
|
||||||
|
lastAccessedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await this.writeBundleManifest(updatedBundle);
|
||||||
|
await this.pruneBaseImageCache(updatedBundle.bundleId);
|
||||||
|
return updatedBundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
await plugins.fs.promises.mkdir(bundleDir, { recursive: true });
|
||||||
|
|
||||||
|
const kernelFileName = source.kernelFileName || this.getSourceFileName(source.kernelUrl || source.kernelSourcePath || source.kernelKey!, 'vmlinux');
|
||||||
|
const rootfsFileName = source.rootfsFileName || this.getSourceFileName(source.rootfsUrl || source.rootfsSourcePath || source.rootfsKey!, `rootfs.${source.rootfsType}`);
|
||||||
|
const kernelPath = this.resolveBundleFilePath(bundleDir, kernelFileName);
|
||||||
|
const rootfsPath = this.resolveBundleFilePath(bundleDir, rootfsFileName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.prepareArtifact({
|
||||||
|
url: source.kernelUrl || (source.kernelKey ? this.keyToUrl(source.kernelKey) : undefined),
|
||||||
|
sourcePath: source.kernelSourcePath,
|
||||||
|
targetPath: kernelPath,
|
||||||
|
expectedSha256: source.expectedKernelSha256,
|
||||||
|
expectedBytes: source.expectedKernelBytes,
|
||||||
|
});
|
||||||
|
await this.prepareArtifact({
|
||||||
|
url: source.rootfsUrl || (source.rootfsKey ? this.keyToUrl(source.rootfsKey) : undefined),
|
||||||
|
sourcePath: source.rootfsSourcePath,
|
||||||
|
targetPath: rootfsPath,
|
||||||
|
expectedSha256: source.expectedRootfsSha256,
|
||||||
|
expectedBytes: source.expectedRootfsBytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const bundle: IBaseImageBundle = {
|
||||||
|
preset: source.preset,
|
||||||
|
arch: source.arch,
|
||||||
|
ciVersion: source.ciVersion,
|
||||||
|
firecrackerVersion: source.firecrackerVersion,
|
||||||
|
bundleId: source.bundleId,
|
||||||
|
bundleDir,
|
||||||
|
kernelImagePath: kernelPath,
|
||||||
|
rootfsPath,
|
||||||
|
rootfsType: source.rootfsType,
|
||||||
|
rootfsIsReadOnly: source.rootfsIsReadOnly,
|
||||||
|
bootArgs: source.bootArgs,
|
||||||
|
source: source.source,
|
||||||
|
checksums: {
|
||||||
|
kernelSha256: await this.sha256File(kernelPath),
|
||||||
|
rootfsSha256: await this.sha256File(rootfsPath),
|
||||||
|
},
|
||||||
|
sizes: {
|
||||||
|
kernelBytes: (await plugins.fs.promises.stat(kernelPath)).size,
|
||||||
|
rootfsBytes: (await plugins.fs.promises.stat(rootfsPath)).size,
|
||||||
|
},
|
||||||
|
createdAt: now,
|
||||||
|
lastAccessedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.writeBundleManifest(bundle);
|
||||||
|
await this.pruneBaseImageCache(bundle.bundleId);
|
||||||
|
return bundle;
|
||||||
|
} catch (err) {
|
||||||
|
await plugins.fs.promises.rm(bundleDir, { recursive: true, force: true });
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Failed to prepare base image bundle ${source.bundleId}: ${getErrorMessage(err)}`,
|
||||||
|
'BASE_IMAGE_PREPARE_FAILED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prune cached base image bundles according to the retention limit.
|
||||||
|
*/
|
||||||
|
public async pruneBaseImageCache(keepBundleId?: string): Promise<string[]> {
|
||||||
|
await plugins.fs.promises.mkdir(this.cacheDir, { recursive: true });
|
||||||
|
const bundles = await this.listCachedBundles();
|
||||||
|
bundles.sort((a, b) => {
|
||||||
|
if (keepBundleId) {
|
||||||
|
if (a.bundleId === keepBundleId) return -1;
|
||||||
|
if (b.bundleId === keepBundleId) return 1;
|
||||||
|
}
|
||||||
|
return Date.parse(b.lastAccessedAt) - Date.parse(a.lastAccessedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
const evicted: string[] = [];
|
||||||
|
for (const bundle of bundles.slice(this.maxStoredBaseImages)) {
|
||||||
|
console.warn(
|
||||||
|
`[smartvm] Base image cache stores at most ${this.maxStoredBaseImages} bundle(s). ` +
|
||||||
|
`Evicting ${bundle.bundleId} from ${bundle.bundleDir}. Configure maxStoredBaseImages to change this behavior.`,
|
||||||
|
);
|
||||||
|
await plugins.fs.promises.rm(bundle.bundleDir, { recursive: true, force: true });
|
||||||
|
evicted.push(bundle.bundleId);
|
||||||
|
}
|
||||||
|
return evicted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveBaseImageSource(options: IEnsureBaseImageOptions): Promise<IResolvedBaseImageSource> {
|
||||||
|
const arch = options.arch || this.arch;
|
||||||
|
const manifestUrl = options.manifestUrl || this.hostedManifestUrl;
|
||||||
|
const manifestPath = options.manifestPath || this.hostedManifestPath;
|
||||||
|
if (manifestUrl || manifestPath) {
|
||||||
|
return this.resolveHostedManifestSource({ arch, manifestUrl, manifestPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = options.preset || 'latest';
|
||||||
|
if (preset === 'hosted') {
|
||||||
|
throw new SmartVMError(
|
||||||
|
'The hosted base image preset requires manifestUrl, manifestPath, or a manager-level hosted manifest option',
|
||||||
|
'BASE_IMAGE_MANIFEST_FAILED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const firecrackerVersion = preset === 'latest'
|
||||||
|
? await this.getLatestFirecrackerVersion()
|
||||||
|
: LTS_FIRECRACKER_VERSION;
|
||||||
|
const ciVersion = preset === 'latest'
|
||||||
|
? firecrackerVersion.split('.').slice(0, 2).join('.')
|
||||||
|
: LTS_CI_VERSION;
|
||||||
|
|
||||||
|
const keys = await this.listCiKeys(ciVersion, arch);
|
||||||
|
const kernelKey = this.selectKernelKey(keys);
|
||||||
|
const rootfsKey = this.selectRootfsKey(keys);
|
||||||
|
const rootfsType = rootfsKey.endsWith('.ext4') ? 'ext4' : 'squashfs';
|
||||||
|
const bundleId = this.buildBundleId(preset, ciVersion, arch, kernelKey, rootfsKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
preset,
|
||||||
|
arch,
|
||||||
|
ciVersion,
|
||||||
|
firecrackerVersion,
|
||||||
|
kernelKey,
|
||||||
|
rootfsKey,
|
||||||
|
rootfsType,
|
||||||
|
rootfsIsReadOnly: rootfsType === 'squashfs',
|
||||||
|
bundleId,
|
||||||
|
bootArgs: this.buildBootArgs(arch, rootfsType),
|
||||||
|
source: {
|
||||||
|
type: 'firecracker-ci',
|
||||||
|
bucketUrl: FIRECRACKER_CI_BUCKET_URL,
|
||||||
|
kernelKey,
|
||||||
|
rootfsKey,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveHostedManifestSource(options: {
|
||||||
|
arch: TFirecrackerArch;
|
||||||
|
manifestUrl?: string;
|
||||||
|
manifestPath?: string;
|
||||||
|
}): Promise<IResolvedBaseImageSource> {
|
||||||
|
const manifest = await this.loadHostedManifest(options);
|
||||||
|
this.validateHostedManifest(manifest, options.arch);
|
||||||
|
this.getArtifactSource(manifest.kernel, 'kernel');
|
||||||
|
this.getArtifactSource(manifest.rootfs, 'rootfs');
|
||||||
|
|
||||||
|
return {
|
||||||
|
preset: 'hosted',
|
||||||
|
arch: manifest.arch,
|
||||||
|
ciVersion: 'hosted',
|
||||||
|
firecrackerVersion: manifest.firecrackerVersion,
|
||||||
|
kernelUrl: manifest.kernel.url,
|
||||||
|
rootfsUrl: manifest.rootfs.url,
|
||||||
|
kernelSourcePath: manifest.kernel.path,
|
||||||
|
rootfsSourcePath: manifest.rootfs.path,
|
||||||
|
kernelFileName: manifest.kernel.fileName,
|
||||||
|
rootfsFileName: manifest.rootfs.fileName,
|
||||||
|
expectedKernelSha256: manifest.kernel.sha256,
|
||||||
|
expectedRootfsSha256: manifest.rootfs.sha256,
|
||||||
|
expectedKernelBytes: manifest.kernel.sizeBytes,
|
||||||
|
expectedRootfsBytes: manifest.rootfs.sizeBytes,
|
||||||
|
rootfsType: manifest.rootfsType,
|
||||||
|
rootfsIsReadOnly: manifest.rootfsIsReadOnly ?? manifest.rootfsType === 'squashfs',
|
||||||
|
bundleId: this.sanitizeBundleId(manifest.bundleId),
|
||||||
|
bootArgs: manifest.bootArgs || this.buildBootArgs(manifest.arch, manifest.rootfsType),
|
||||||
|
source: {
|
||||||
|
type: 'hosted-manifest',
|
||||||
|
manifestUrl: options.manifestUrl,
|
||||||
|
manifestPath: options.manifestPath,
|
||||||
|
kernelUrl: manifest.kernel.url,
|
||||||
|
rootfsUrl: manifest.rootfs.url,
|
||||||
|
kernelSourcePath: manifest.kernel.path,
|
||||||
|
rootfsSourcePath: manifest.rootfs.path,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getLatestFirecrackerVersion(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const result = await this.shell.execSpawn('curl', [
|
||||||
|
'-fsSLI',
|
||||||
|
'-o',
|
||||||
|
'/dev/null',
|
||||||
|
'-w',
|
||||||
|
'%{url_effective}',
|
||||||
|
'https://github.com/firecracker-microvm/firecracker/releases/latest',
|
||||||
|
], { silent: true });
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
const output = (result.stderr || result.stdout || '').trim();
|
||||||
|
throw new Error(`curl exited with code ${result.exitCode}${output ? `: ${output}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = result.stdout.trim().match(/\/releases\/tag\/(v\d+\.\d+\.\d+)$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Could not parse latest Firecracker release URL: ${result.stdout.trim()}`);
|
||||||
|
}
|
||||||
|
return match[1];
|
||||||
|
} catch (err) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Failed to resolve latest Firecracker version: ${getErrorMessage(err)}`,
|
||||||
|
'VERSION_FETCH_FAILED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadHostedManifest(options: {
|
||||||
|
manifestUrl?: string;
|
||||||
|
manifestPath?: string;
|
||||||
|
}): Promise<IBaseImageHostedManifest> {
|
||||||
|
try {
|
||||||
|
let raw: string;
|
||||||
|
if (options.manifestPath) {
|
||||||
|
raw = await plugins.fs.promises.readFile(options.manifestPath, 'utf8');
|
||||||
|
} else if (options.manifestUrl) {
|
||||||
|
const response = await plugins.SmartRequest.create()
|
||||||
|
.url(options.manifestUrl)
|
||||||
|
.get();
|
||||||
|
raw = await response.text();
|
||||||
|
} else {
|
||||||
|
throw new Error('manifestUrl or manifestPath is required');
|
||||||
|
}
|
||||||
|
return JSON.parse(raw) as IBaseImageHostedManifest;
|
||||||
|
} catch (err) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Failed to load hosted base image manifest: ${getErrorMessage(err)}`,
|
||||||
|
'BASE_IMAGE_MANIFEST_FAILED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateHostedManifest(manifest: IBaseImageHostedManifest, expectedArch: TFirecrackerArch): void {
|
||||||
|
if (manifest.schemaVersion !== 1) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
'Hosted base image manifest schemaVersion must be 1',
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!manifest.bundleId || !/^[a-zA-Z0-9._-]+$/.test(manifest.bundleId)) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
'Hosted base image manifest bundleId must use only letters, numbers, dot, underscore, and dash',
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (manifest.arch !== expectedArch) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Hosted base image arch '${manifest.arch}' does not match requested arch '${expectedArch}'`,
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!manifest.firecrackerVersion || !/^v\d+\.\d+\.\d+$/.test(manifest.firecrackerVersion)) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
'Hosted base image manifest firecrackerVersion must look like v1.15.1',
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (manifest.rootfsType !== 'ext4' && manifest.rootfsType !== 'squashfs') {
|
||||||
|
throw new SmartVMError(
|
||||||
|
'Hosted base image manifest rootfsType must be ext4 or squashfs',
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.validateArtifactManifest(manifest.kernel, 'kernel');
|
||||||
|
this.validateArtifactManifest(manifest.rootfs, 'rootfs');
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateArtifactManifest(artifact: IBaseImageArtifactManifest, label: string): void {
|
||||||
|
this.getArtifactSource(artifact, label);
|
||||||
|
if (artifact.fileName !== undefined) {
|
||||||
|
this.validateArtifactFileName(artifact.fileName, label);
|
||||||
|
}
|
||||||
|
if (artifact.url && !artifact.sha256) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Hosted base image manifest ${label} artifact with url requires sha256`,
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (artifact.sha256 !== undefined && !/^[a-fA-F0-9]{64}$/.test(artifact.sha256)) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Hosted base image manifest ${label} artifact sha256 must be a 64 character hex string`,
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
artifact.sizeBytes !== undefined &&
|
||||||
|
(!Number.isInteger(artifact.sizeBytes) || artifact.sizeBytes < 0)
|
||||||
|
) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Hosted base image manifest ${label} artifact sizeBytes must be a non-negative integer`,
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateArtifactFileName(fileName: string, label: string): void {
|
||||||
|
if (
|
||||||
|
!fileName ||
|
||||||
|
fileName === '.' ||
|
||||||
|
fileName === '..' ||
|
||||||
|
fileName !== plugins.path.basename(fileName) ||
|
||||||
|
!/^[a-zA-Z0-9._-]+$/.test(fileName)
|
||||||
|
) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Hosted base image manifest ${label} artifact fileName must be a plain file name using letters, numbers, dot, underscore, and dash`,
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getArtifactSource(artifact: { url?: string; path?: string }, label: string): string {
|
||||||
|
if (!artifact.url && !artifact.path) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Hosted base image manifest ${label} artifact requires url or path`,
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (artifact.url && artifact.path) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Hosted base image manifest ${label} artifact must not set both url and path`,
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return artifact.url || artifact.path!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSourceFileName(source: string, fallback: string): string {
|
||||||
|
let fileName: string;
|
||||||
|
try {
|
||||||
|
fileName = plugins.path.basename(new URL(source).pathname);
|
||||||
|
} catch {
|
||||||
|
fileName = plugins.path.basename(source);
|
||||||
|
}
|
||||||
|
return this.sanitizeFileName(fileName || fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveBundleFilePath(bundleDir: string, fileName: string): string {
|
||||||
|
const resolvedBundleDir = plugins.path.resolve(bundleDir);
|
||||||
|
const resolvedFilePath = plugins.path.resolve(resolvedBundleDir, this.sanitizeFileName(fileName));
|
||||||
|
if (!this.isPathInside(resolvedBundleDir, resolvedFilePath)) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Resolved base image artifact path escapes bundle directory: ${fileName}`,
|
||||||
|
'INVALID_BASE_IMAGE_MANIFEST',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return resolvedFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeFileName(fileName: string): string {
|
||||||
|
const sanitized = plugins.path.basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
if (!sanitized || sanitized === '.' || sanitized === '..') {
|
||||||
|
return 'artifact';
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeBundleId(bundleId: string): string {
|
||||||
|
return bundleId.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listCiKeys(ciVersion: string, arch: TFirecrackerArch): Promise<string[]> {
|
||||||
|
const prefix = `firecracker-ci/${ciVersion}/${arch}/`;
|
||||||
|
try {
|
||||||
|
const response = await plugins.SmartRequest.create()
|
||||||
|
.url(`${FIRECRACKER_CI_BUCKET_URL}/?prefix=${encodeURIComponent(prefix)}&list-type=2`)
|
||||||
|
.get();
|
||||||
|
const body = await response.text();
|
||||||
|
const keys = Array.from(body.matchAll(/<Key>([^<]+)<\/Key>/g)).map((match) => this.decodeXml(match[1]));
|
||||||
|
if (keys.length === 0) {
|
||||||
|
throw new Error(`No Firecracker CI artifacts found for ${ciVersion}/${arch}`);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
} catch (err) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Failed to list Firecracker CI artifacts for ${ciVersion}/${arch}: ${getErrorMessage(err)}`,
|
||||||
|
'BASE_IMAGE_RESOLVE_FAILED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectKernelKey(keys: string[]): string {
|
||||||
|
const kernelKeys = keys.filter((key) => /\/vmlinux-\d+\.\d+\.\d+$/.test(key) && !key.includes('/debug/'));
|
||||||
|
if (kernelKeys.length === 0) {
|
||||||
|
throw new SmartVMError('No suitable Firecracker CI kernel image found', 'BASE_IMAGE_RESOLVE_FAILED');
|
||||||
|
}
|
||||||
|
return kernelKeys.sort((a, b) => this.compareKernelKeys(a, b)).at(-1)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectRootfsKey(keys: string[]): string {
|
||||||
|
const squashfsKeys = keys.filter((key) => /\/ubuntu-[^/]+\.squashfs$/.test(key));
|
||||||
|
if (squashfsKeys.length > 0) {
|
||||||
|
return squashfsKeys.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1)!;
|
||||||
|
}
|
||||||
|
const ext4Keys = keys.filter((key) => /\/ubuntu-[^/]+\.ext4$/.test(key));
|
||||||
|
if (ext4Keys.length > 0) {
|
||||||
|
return ext4Keys.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1)!;
|
||||||
|
}
|
||||||
|
throw new SmartVMError('No suitable Firecracker CI rootfs image found', 'BASE_IMAGE_RESOLVE_FAILED');
|
||||||
|
}
|
||||||
|
|
||||||
|
private compareKernelKeys(a: string, b: string): number {
|
||||||
|
const aParts = this.extractKernelVersion(a);
|
||||||
|
const bParts = this.extractKernelVersion(b);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if (aParts[i] !== bParts[i]) {
|
||||||
|
return aParts[i] - bParts[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a.localeCompare(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractKernelVersion(key: string): [number, number, number] {
|
||||||
|
const match = key.match(/vmlinux-(\d+)\.(\d+)\.(\d+)$/);
|
||||||
|
if (!match) {
|
||||||
|
return [0, 0, 0];
|
||||||
|
}
|
||||||
|
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildBundleId(
|
||||||
|
preset: TBaseImagePreset,
|
||||||
|
ciVersion: string,
|
||||||
|
arch: TFirecrackerArch,
|
||||||
|
kernelKey: string,
|
||||||
|
rootfsKey: string,
|
||||||
|
): string {
|
||||||
|
const rawId = [
|
||||||
|
preset,
|
||||||
|
ciVersion,
|
||||||
|
arch,
|
||||||
|
plugins.path.basename(kernelKey),
|
||||||
|
plugins.path.basename(rootfsKey),
|
||||||
|
].join('-');
|
||||||
|
return this.sanitizeBundleId(rawId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildBootArgs(arch: TFirecrackerArch, rootfsType: TBaseImageRootfsType): string {
|
||||||
|
const args = ['console=ttyS0', 'reboot=k', 'panic=1', 'pci=off'];
|
||||||
|
if (arch === 'aarch64') {
|
||||||
|
args.unshift('keep_bootcon');
|
||||||
|
}
|
||||||
|
if (rootfsType === 'squashfs') {
|
||||||
|
args.push('ro', 'rootfstype=squashfs');
|
||||||
|
}
|
||||||
|
return args.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
private keyToUrl(key: string): string {
|
||||||
|
return `${FIRECRACKER_CI_BUCKET_URL}/${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepareArtifact(options: {
|
||||||
|
url?: string;
|
||||||
|
sourcePath?: string;
|
||||||
|
targetPath: string;
|
||||||
|
expectedSha256?: string;
|
||||||
|
expectedBytes?: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (options.sourcePath) {
|
||||||
|
await plugins.fs.promises.copyFile(options.sourcePath, options.targetPath);
|
||||||
|
} else if (options.url) {
|
||||||
|
await this.downloadFile(options.url, options.targetPath);
|
||||||
|
} else {
|
||||||
|
throw new Error('Artifact requires url or sourcePath');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = await plugins.fs.promises.stat(options.targetPath);
|
||||||
|
if (options.expectedBytes !== undefined && stat.size !== options.expectedBytes) {
|
||||||
|
throw new Error(
|
||||||
|
`Artifact ${options.targetPath} size mismatch: expected ${options.expectedBytes}, got ${stat.size}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (options.expectedSha256) {
|
||||||
|
const actualSha256 = await this.sha256File(options.targetPath);
|
||||||
|
if (actualSha256.toLowerCase() !== options.expectedSha256.toLowerCase()) {
|
||||||
|
throw new Error(
|
||||||
|
`Artifact ${options.targetPath} SHA256 mismatch: expected ${options.expectedSha256}, got ${actualSha256}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async downloadFile(url: string, targetPath: string): Promise<TShellExecResult> {
|
||||||
|
await plugins.fs.promises.mkdir(plugins.path.dirname(targetPath), { recursive: true });
|
||||||
|
const tempPath = `${targetPath}.download`;
|
||||||
|
await plugins.fs.promises.rm(tempPath, { force: true });
|
||||||
|
const result = await this.shell.execSpawn('curl', ['-fSL', '-o', tempPath, url], { silent: true });
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
const output = (result.stderr || result.stdout || '').trim();
|
||||||
|
throw new Error(`curl failed for ${url} with code ${result.exitCode}${output ? `: ${output}` : ''}`);
|
||||||
|
}
|
||||||
|
await plugins.fs.promises.rename(tempPath, targetPath);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sha256File(filePath: string): Promise<string> {
|
||||||
|
const hash = plugins.crypto.createHash('sha256');
|
||||||
|
const stream = plugins.fs.createReadStream(filePath);
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
hash.update(chunk);
|
||||||
|
}
|
||||||
|
return hash.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readCompleteBundle(bundleDir: string): Promise<IBaseImageBundle | undefined> {
|
||||||
|
const manifestPath = this.getManifestPath(bundleDir);
|
||||||
|
try {
|
||||||
|
const bundle = {
|
||||||
|
...await this.readBundleManifest(manifestPath),
|
||||||
|
bundleDir,
|
||||||
|
};
|
||||||
|
await this.verifyCachedBundle(bundle);
|
||||||
|
return bundle;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifyCachedBundle(bundle: IBaseImageBundle): Promise<void> {
|
||||||
|
if (!this.isPathInside(bundle.bundleDir, bundle.kernelImagePath)) {
|
||||||
|
throw new Error(`Cached kernel path escapes bundle directory: ${bundle.kernelImagePath}`);
|
||||||
|
}
|
||||||
|
if (!this.isPathInside(bundle.bundleDir, bundle.rootfsPath)) {
|
||||||
|
throw new Error(`Cached rootfs path escapes bundle directory: ${bundle.rootfsPath}`);
|
||||||
|
}
|
||||||
|
if (!bundle.checksums?.kernelSha256 || !bundle.checksums?.rootfsSha256) {
|
||||||
|
throw new Error(`Cached bundle ${bundle.bundleId} is missing checksums`);
|
||||||
|
}
|
||||||
|
if (bundle.sizes?.kernelBytes === undefined || bundle.sizes.rootfsBytes === undefined) {
|
||||||
|
throw new Error(`Cached bundle ${bundle.bundleId} is missing sizes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [kernelStat, rootfsStat] = await Promise.all([
|
||||||
|
plugins.fs.promises.stat(bundle.kernelImagePath),
|
||||||
|
plugins.fs.promises.stat(bundle.rootfsPath),
|
||||||
|
]);
|
||||||
|
if (kernelStat.size !== bundle.sizes.kernelBytes) {
|
||||||
|
throw new Error(`Cached kernel size mismatch for bundle ${bundle.bundleId}`);
|
||||||
|
}
|
||||||
|
if (rootfsStat.size !== bundle.sizes.rootfsBytes) {
|
||||||
|
throw new Error(`Cached rootfs size mismatch for bundle ${bundle.bundleId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [kernelSha256, rootfsSha256] = await Promise.all([
|
||||||
|
this.sha256File(bundle.kernelImagePath),
|
||||||
|
this.sha256File(bundle.rootfsPath),
|
||||||
|
]);
|
||||||
|
if (kernelSha256.toLowerCase() !== bundle.checksums.kernelSha256.toLowerCase()) {
|
||||||
|
throw new Error(`Cached kernel SHA256 mismatch for bundle ${bundle.bundleId}`);
|
||||||
|
}
|
||||||
|
if (rootfsSha256.toLowerCase() !== bundle.checksums.rootfsSha256.toLowerCase()) {
|
||||||
|
throw new Error(`Cached rootfs SHA256 mismatch for bundle ${bundle.bundleId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPathInside(baseDir: string, candidatePath: string): boolean {
|
||||||
|
const resolvedBase = plugins.path.resolve(baseDir);
|
||||||
|
const resolvedCandidate = plugins.path.resolve(candidatePath);
|
||||||
|
return resolvedCandidate === resolvedBase || resolvedCandidate.startsWith(`${resolvedBase}${plugins.path.sep}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getManifestPath(bundleDir: string): string {
|
||||||
|
return plugins.path.join(bundleDir, 'manifest.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readBundleManifest(manifestPath: string): Promise<IBaseImageBundle> {
|
||||||
|
const raw = await plugins.fs.promises.readFile(manifestPath, 'utf8');
|
||||||
|
return JSON.parse(raw) as IBaseImageBundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeBundleManifest(bundle: IBaseImageBundle): Promise<void> {
|
||||||
|
await plugins.fs.promises.mkdir(bundle.bundleDir, { recursive: true });
|
||||||
|
await plugins.fs.promises.writeFile(
|
||||||
|
this.getManifestPath(bundle.bundleDir),
|
||||||
|
`${JSON.stringify(bundle, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listCachedBundles(): Promise<IBaseImageBundle[]> {
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = await plugins.fs.promises.readdir(this.cacheDir);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundles: IBaseImageBundle[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const bundleDir = plugins.path.join(this.cacheDir, entry);
|
||||||
|
try {
|
||||||
|
const stat = await plugins.fs.promises.stat(bundleDir);
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bundle = await this.readBundleManifest(this.getManifestPath(bundleDir));
|
||||||
|
bundles.push({
|
||||||
|
...bundle,
|
||||||
|
bundleDir,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore incomplete cache entries.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bundles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeXml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,14 +3,23 @@ import type { IFirecrackerProcessOptions } from './interfaces/index.js';
|
|||||||
import { SmartVMError } from './interfaces/index.js';
|
import { SmartVMError } from './interfaces/index.js';
|
||||||
import { SocketClient } from './classes.socketclient.js';
|
import { SocketClient } from './classes.socketclient.js';
|
||||||
|
|
||||||
|
type TStreamingResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawnStreaming']>>;
|
||||||
|
type TExecResult = Awaited<TStreamingResult['finalPromise']>;
|
||||||
|
|
||||||
|
function getErrorMessage(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages a single Firecracker child process, including startup, readiness polling, and shutdown.
|
* Manages a single Firecracker child process, including startup, readiness polling, and shutdown.
|
||||||
*/
|
*/
|
||||||
export class FirecrackerProcess {
|
export class FirecrackerProcess {
|
||||||
private options: IFirecrackerProcessOptions;
|
private options: IFirecrackerProcessOptions;
|
||||||
private streaming: any | null = null;
|
private streaming: TStreamingResult | null = null;
|
||||||
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
||||||
private smartExitInstance: InstanceType<typeof plugins.smartexit.SmartExit> | null = null;
|
private smartExitInstance: InstanceType<typeof plugins.smartexit.SmartExit> | null = null;
|
||||||
|
private lastExitResult: TExecResult | null = null;
|
||||||
|
private lastExitError: string | null = null;
|
||||||
public socketClient: SocketClient;
|
public socketClient: SocketClient;
|
||||||
|
|
||||||
constructor(options: IFirecrackerProcessOptions) {
|
constructor(options: IFirecrackerProcessOptions) {
|
||||||
@@ -28,14 +37,21 @@ export class FirecrackerProcess {
|
|||||||
plugins.fs.unlinkSync(this.options.socketPath);
|
plugins.fs.unlinkSync(this.options.socketPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the command
|
// Build the command args without a shell so paths are not interpreted.
|
||||||
let cmd = `${this.options.binaryPath} --api-sock ${this.options.socketPath}`;
|
const args = ['--api-sock', this.options.socketPath];
|
||||||
if (this.options.logLevel) {
|
if (this.options.logLevel) {
|
||||||
cmd += ` --level ${this.options.logLevel}`;
|
args.push('--level', this.options.logLevel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn the process
|
// Spawn the process
|
||||||
this.streaming = await this.shell.execStreaming(cmd, true);
|
this.streaming = await this.shell.execSpawnStreaming(this.options.binaryPath, args, { silent: true });
|
||||||
|
this.streaming.finalPromise
|
||||||
|
.then((result) => {
|
||||||
|
this.lastExitResult = result;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
this.lastExitError = getErrorMessage(err);
|
||||||
|
});
|
||||||
|
|
||||||
// Register with smartexit for automatic cleanup
|
// Register with smartexit for automatic cleanup
|
||||||
if (this.streaming?.childProcess) {
|
if (this.streaming?.childProcess) {
|
||||||
@@ -46,9 +62,11 @@ export class FirecrackerProcess {
|
|||||||
// Wait for the socket file to appear
|
// Wait for the socket file to appear
|
||||||
const socketReady = await this.waitForSocket(10000);
|
const socketReady = await this.waitForSocket(10000);
|
||||||
if (!socketReady) {
|
if (!socketReady) {
|
||||||
|
const wasRunning = this.isRunning();
|
||||||
|
const diagnostics = this.formatDiagnostics();
|
||||||
await this.stop();
|
await this.stop();
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
'Firecracker socket did not become ready within timeout',
|
`Firecracker socket did not become ready within timeout${diagnostics || (wasRunning ? '' : this.formatDiagnostics())}`,
|
||||||
'SOCKET_TIMEOUT',
|
'SOCKET_TIMEOUT',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -56,9 +74,10 @@ export class FirecrackerProcess {
|
|||||||
// Wait for the API to be responsive
|
// Wait for the API to be responsive
|
||||||
const apiReady = await this.socketClient.isReady(5000);
|
const apiReady = await this.socketClient.isReady(5000);
|
||||||
if (!apiReady) {
|
if (!apiReady) {
|
||||||
|
const diagnostics = this.formatDiagnostics();
|
||||||
await this.stop();
|
await this.stop();
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
'Firecracker API did not become responsive within timeout',
|
`Firecracker API did not become responsive within timeout${diagnostics}`,
|
||||||
'API_TIMEOUT',
|
'API_TIMEOUT',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -73,36 +92,69 @@ export class FirecrackerProcess {
|
|||||||
if (plugins.fs.existsSync(this.options.socketPath)) {
|
if (plugins.fs.existsSync(this.options.socketPath)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (this.streaming && !this.isRunning()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
await plugins.smartdelay.delayFor(100);
|
await plugins.smartdelay.delayFor(100);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async waitForExit(streaming: TStreamingResult, timeoutMs: number): Promise<boolean> {
|
||||||
|
return Promise.race([
|
||||||
|
streaming.finalPromise.then((result) => {
|
||||||
|
this.lastExitResult = result;
|
||||||
|
return true;
|
||||||
|
}).catch((err) => {
|
||||||
|
this.lastExitError = getErrorMessage(err);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
plugins.smartdelay.delayFor(timeoutMs).then(() => false),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDiagnostics(): string {
|
||||||
|
if (this.lastExitError) {
|
||||||
|
return `: ${this.lastExitError}`;
|
||||||
|
}
|
||||||
|
if (this.lastExitResult) {
|
||||||
|
const output = (this.lastExitResult.stderr || this.lastExitResult.stdout || '').trim();
|
||||||
|
return `: process exited with code ${this.lastExitResult.exitCode}${output ? `: ${output}` : ''}`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gracefully stop the Firecracker process with SIGTERM, then SIGKILL after timeout.
|
* Gracefully stop the Firecracker process with SIGTERM, then SIGKILL after timeout.
|
||||||
*/
|
*/
|
||||||
public async stop(): Promise<void> {
|
public async stop(): Promise<void> {
|
||||||
if (!this.streaming) return;
|
const streaming = this.streaming;
|
||||||
|
if (!streaming) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try graceful termination first
|
// Try graceful termination first
|
||||||
await this.streaming.terminate();
|
await streaming.terminate();
|
||||||
|
|
||||||
// Wait up to 5 seconds for the process to exit
|
// Wait up to 5 seconds for the process to exit
|
||||||
const exitPromise = Promise.race([
|
const terminated = await this.waitForExit(streaming, 5000);
|
||||||
this.streaming.finalPromise,
|
if (!terminated) {
|
||||||
plugins.smartdelay.delayFor(5000),
|
await streaming.kill();
|
||||||
]);
|
await this.waitForExit(streaming, 1000);
|
||||||
await exitPromise;
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// If termination fails, force kill
|
// If termination fails, force kill
|
||||||
try {
|
try {
|
||||||
await this.streaming.kill();
|
await streaming.kill();
|
||||||
|
await this.waitForExit(streaming, 1000);
|
||||||
} catch {
|
} catch {
|
||||||
// Process may already be dead
|
// Process may already be dead
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.smartExitInstance) {
|
||||||
|
this.smartExitInstance.removeProcess(streaming.childProcess);
|
||||||
|
this.smartExitInstance = null;
|
||||||
|
}
|
||||||
this.streaming = null;
|
this.streaming = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,10 +174,11 @@ export class FirecrackerProcess {
|
|||||||
* Check if the process is currently running.
|
* Check if the process is currently running.
|
||||||
*/
|
*/
|
||||||
public isRunning(): boolean {
|
public isRunning(): boolean {
|
||||||
if (!this.streaming?.childProcess) return false;
|
const pid = this.streaming?.childProcess?.pid;
|
||||||
|
if (!pid) return false;
|
||||||
try {
|
try {
|
||||||
// Sending signal 0 tests if process exists without actually sending a signal
|
// Sending signal 0 tests if process exists without actually sending a signal
|
||||||
process.kill(this.streaming.childProcess.pid, 0);
|
process.kill(pid, 0);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
+51
-27
@@ -2,6 +2,12 @@ import * as plugins from './plugins.js';
|
|||||||
import type { TFirecrackerArch } from './interfaces/index.js';
|
import type { TFirecrackerArch } from './interfaces/index.js';
|
||||||
import { SmartVMError } from './interfaces/index.js';
|
import { SmartVMError } from './interfaces/index.js';
|
||||||
|
|
||||||
|
type TShellExecResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawn']>>;
|
||||||
|
|
||||||
|
function getErrorMessage(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to check if a file or directory exists.
|
* Helper to check if a file or directory exists.
|
||||||
*/
|
*/
|
||||||
@@ -21,10 +27,21 @@ async function pathExists(filePath: string): Promise<boolean> {
|
|||||||
export class ImageManager {
|
export class ImageManager {
|
||||||
private dataDir: string;
|
private dataDir: string;
|
||||||
private arch: TFirecrackerArch;
|
private arch: TFirecrackerArch;
|
||||||
|
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
||||||
|
|
||||||
constructor(dataDir: string, arch: TFirecrackerArch = 'x86_64') {
|
constructor(dataDir: string, arch: TFirecrackerArch = 'x86_64') {
|
||||||
this.dataDir = dataDir;
|
this.dataDir = dataDir;
|
||||||
this.arch = arch;
|
this.arch = arch;
|
||||||
|
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runChecked(command: string, args: string[]): Promise<TShellExecResult> {
|
||||||
|
const result = await this.shell.execSpawn(command, args, { silent: true });
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
const output = (result.stderr || result.stdout || '').trim();
|
||||||
|
throw new Error(`${command} ${args.join(' ')} exited with code ${result.exitCode}${output ? `: ${output}` : ''}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,14 +106,22 @@ export class ImageManager {
|
|||||||
*/
|
*/
|
||||||
public async getLatestVersion(): Promise<string> {
|
public async getLatestVersion(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const response = await plugins.SmartRequest.create()
|
const result = await this.runChecked('curl', [
|
||||||
.url('https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest')
|
'-fsSLI',
|
||||||
.get();
|
'-o',
|
||||||
const data = await response.json() as { tag_name: string };
|
'/dev/null',
|
||||||
return data.tag_name;
|
'-w',
|
||||||
|
'%{url_effective}',
|
||||||
|
'https://github.com/firecracker-microvm/firecracker/releases/latest',
|
||||||
|
]);
|
||||||
|
const match = result.stdout.trim().match(/\/releases\/tag\/(v\d+\.\d+\.\d+)$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Could not parse latest Firecracker release URL: ${result.stdout.trim()}`);
|
||||||
|
}
|
||||||
|
return match[1];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`Failed to fetch latest Firecracker version: ${(err as Error).message}`,
|
`Failed to fetch latest Firecracker version: ${getErrorMessage(err)}`,
|
||||||
'VERSION_FETCH_FAILED',
|
'VERSION_FETCH_FAILED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -119,11 +144,10 @@ export class ImageManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Download the archive
|
// Download the archive
|
||||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
await this.runChecked('curl', ['-fSL', '-o', archivePath, downloadUrl]);
|
||||||
await shell.exec(`curl -fSL -o "${archivePath}" "${downloadUrl}"`);
|
|
||||||
|
|
||||||
// Extract the archive
|
// Extract the archive
|
||||||
await shell.exec(`tar -xzf "${archivePath}" -C "${targetDir}"`);
|
await this.runChecked('tar', ['-xzf', archivePath, '-C', targetDir]);
|
||||||
|
|
||||||
// Firecracker archives contain a directory like release-v1.5.0-x86_64/
|
// Firecracker archives contain a directory like release-v1.5.0-x86_64/
|
||||||
// with binaries named like firecracker-v1.5.0-x86_64
|
// with binaries named like firecracker-v1.5.0-x86_64
|
||||||
@@ -134,21 +158,25 @@ export class ImageManager {
|
|||||||
const jailerDst = this.getJailerPath(version);
|
const jailerDst = this.getJailerPath(version);
|
||||||
|
|
||||||
// Move binaries to expected paths
|
// Move binaries to expected paths
|
||||||
await shell.exec(`mv "${firecrackerSrc}" "${firecrackerDst}"`);
|
await plugins.fs.promises.rename(firecrackerSrc, firecrackerDst);
|
||||||
if (await pathExists(jailerSrc)) {
|
if (await pathExists(jailerSrc)) {
|
||||||
await shell.exec(`mv "${jailerSrc}" "${jailerDst}"`);
|
await plugins.fs.promises.rename(jailerSrc, jailerDst);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make executable
|
// Make executable
|
||||||
await shell.exec(`chmod +x "${firecrackerDst}"`);
|
await plugins.fs.promises.chmod(firecrackerDst, 0o755);
|
||||||
|
if (await pathExists(jailerDst)) {
|
||||||
|
await plugins.fs.promises.chmod(jailerDst, 0o755);
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
await shell.exec(`rm -rf "${archivePath}" "${extractedDir}"`);
|
await plugins.fs.promises.rm(archivePath, { force: true });
|
||||||
|
await plugins.fs.promises.rm(extractedDir, { recursive: true, force: true });
|
||||||
|
|
||||||
return firecrackerDst;
|
return firecrackerDst;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`Failed to download Firecracker ${version}: ${(err as Error).message}`,
|
`Failed to download Firecracker ${version}: ${getErrorMessage(err)}`,
|
||||||
'DOWNLOAD_FAILED',
|
'DOWNLOAD_FAILED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -163,12 +191,11 @@ export class ImageManager {
|
|||||||
const kernelPath = plugins.path.join(kernelsDir, name);
|
const kernelPath = plugins.path.join(kernelsDir, name);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
await this.runChecked('curl', ['-fSL', '-o', kernelPath, url]);
|
||||||
await shell.exec(`curl -fSL -o "${kernelPath}" "${url}"`);
|
|
||||||
return kernelPath;
|
return kernelPath;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`Failed to download kernel from ${url}: ${(err as Error).message}`,
|
`Failed to download kernel from ${url}: ${getErrorMessage(err)}`,
|
||||||
'DOWNLOAD_FAILED',
|
'DOWNLOAD_FAILED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -183,12 +210,11 @@ export class ImageManager {
|
|||||||
const rootfsPath = plugins.path.join(rootfsDir, name);
|
const rootfsPath = plugins.path.join(rootfsDir, name);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
await this.runChecked('curl', ['-fSL', '-o', rootfsPath, url]);
|
||||||
await shell.exec(`curl -fSL -o "${rootfsPath}" "${url}"`);
|
|
||||||
return rootfsPath;
|
return rootfsPath;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`Failed to download rootfs from ${url}: ${(err as Error).message}`,
|
`Failed to download rootfs from ${url}: ${getErrorMessage(err)}`,
|
||||||
'DOWNLOAD_FAILED',
|
'DOWNLOAD_FAILED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -203,13 +229,12 @@ export class ImageManager {
|
|||||||
const rootfsPath = plugins.path.join(rootfsDir, name);
|
const rootfsPath = plugins.path.join(rootfsDir, name);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
await this.runChecked('dd', ['if=/dev/zero', `of=${rootfsPath}`, 'bs=1M', `count=${sizeMib}`]);
|
||||||
await shell.exec(`dd if=/dev/zero of="${rootfsPath}" bs=1M count=${sizeMib}`);
|
await this.runChecked('mkfs.ext4', [rootfsPath]);
|
||||||
await shell.exec(`mkfs.ext4 "${rootfsPath}"`);
|
|
||||||
return rootfsPath;
|
return rootfsPath;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`Failed to create blank rootfs: ${(err as Error).message}`,
|
`Failed to create blank rootfs: ${getErrorMessage(err)}`,
|
||||||
'ROOTFS_CREATE_FAILED',
|
'ROOTFS_CREATE_FAILED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -224,12 +249,11 @@ export class ImageManager {
|
|||||||
const targetPath = plugins.path.join(rootfsDir, targetName);
|
const targetPath = plugins.path.join(rootfsDir, targetName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
await plugins.fs.promises.copyFile(sourcePath, targetPath);
|
||||||
await shell.exec(`cp "${sourcePath}" "${targetPath}"`);
|
|
||||||
return targetPath;
|
return targetPath;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`Failed to clone rootfs: ${(err as Error).message}`,
|
`Failed to clone rootfs: ${getErrorMessage(err)}`,
|
||||||
'ROOTFS_CLONE_FAILED',
|
'ROOTFS_CLONE_FAILED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-12
@@ -2,8 +2,10 @@ import * as plugins from './plugins.js';
|
|||||||
import type {
|
import type {
|
||||||
TVMState,
|
TVMState,
|
||||||
IMicroVMConfig,
|
IMicroVMConfig,
|
||||||
|
IMicroVMRuntimeOptions,
|
||||||
ISnapshotCreateParams,
|
ISnapshotCreateParams,
|
||||||
ISnapshotLoadParams,
|
ISnapshotLoadParams,
|
||||||
|
IDriveConfig,
|
||||||
ITapDevice,
|
ITapDevice,
|
||||||
} from './interfaces/index.js';
|
} from './interfaces/index.js';
|
||||||
import { SmartVMError } from './interfaces/index.js';
|
import { SmartVMError } from './interfaces/index.js';
|
||||||
@@ -26,6 +28,9 @@ export class MicroVM {
|
|||||||
private networkManager: NetworkManager;
|
private networkManager: NetworkManager;
|
||||||
private binaryPath: string;
|
private binaryPath: string;
|
||||||
private socketPath: string;
|
private socketPath: string;
|
||||||
|
private runtimeDir: string;
|
||||||
|
private ephemeralWritableDrives: boolean;
|
||||||
|
private vmRuntimeDir: string | null = null;
|
||||||
private tapDevices: ITapDevice[] = [];
|
private tapDevices: ITapDevice[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -34,12 +39,15 @@ export class MicroVM {
|
|||||||
binaryPath: string,
|
binaryPath: string,
|
||||||
socketPath: string,
|
socketPath: string,
|
||||||
networkManager: NetworkManager,
|
networkManager: NetworkManager,
|
||||||
|
runtimeOptions: IMicroVMRuntimeOptions = {},
|
||||||
) {
|
) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.vmConfig = new VMConfig(config);
|
this.vmConfig = new VMConfig(config);
|
||||||
this.binaryPath = binaryPath;
|
this.binaryPath = binaryPath;
|
||||||
this.socketPath = socketPath;
|
this.socketPath = socketPath;
|
||||||
this.networkManager = networkManager;
|
this.networkManager = networkManager;
|
||||||
|
this.runtimeDir = runtimeOptions.runtimeDir || plugins.path.join(plugins.os.tmpdir(), '.smartvm', 'runtime');
|
||||||
|
this.ephemeralWritableDrives = runtimeOptions.ephemeralWritableDrives ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,6 +62,16 @@ export class MicroVM {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getSocketClient(operation: string): SocketClient {
|
||||||
|
if (!this.socketClient) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Cannot ${operation}: socket client not initialized`,
|
||||||
|
'NO_CLIENT',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.socketClient;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the MicroVM.
|
* Start the MicroVM.
|
||||||
* Validates config, starts the Firecracker process, applies pre-boot configuration, and boots the VM.
|
* Validates config, starts the Firecracker process, applies pre-boot configuration, and boots the VM.
|
||||||
@@ -73,6 +91,9 @@ export class MicroVM {
|
|||||||
this.state = 'configuring';
|
this.state = 'configuring';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await this.ensureVMRuntimeDir();
|
||||||
|
await this.prepareEphemeralDrives();
|
||||||
|
|
||||||
// Start the Firecracker process
|
// Start the Firecracker process
|
||||||
this.process = new FirecrackerProcess({
|
this.process = new FirecrackerProcess({
|
||||||
binaryPath: this.binaryPath,
|
binaryPath: this.binaryPath,
|
||||||
@@ -155,8 +176,9 @@ export class MicroVM {
|
|||||||
if (err instanceof SmartVMError) {
|
if (err instanceof SmartVMError) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`Failed to start VM ${this.id}: ${err.message}`,
|
`Failed to start VM ${this.id}: ${message}`,
|
||||||
'START_FAILED',
|
'START_FAILED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -243,7 +265,7 @@ export class MicroVM {
|
|||||||
*/
|
*/
|
||||||
public async getMetadata(): Promise<any> {
|
public async getMetadata(): Promise<any> {
|
||||||
this.assertState(['running', 'paused'], 'getMetadata');
|
this.assertState(['running', 'paused'], 'getMetadata');
|
||||||
const response = await this.socketClient!.get('/mmds');
|
const response = await this.getSocketClient('getMetadata').get('/mmds');
|
||||||
return response.body;
|
return response.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +303,7 @@ export class MicroVM {
|
|||||||
* Get VM instance info.
|
* Get VM instance info.
|
||||||
*/
|
*/
|
||||||
public async getInfo(): Promise<any> {
|
public async getInfo(): Promise<any> {
|
||||||
const response = await this.socketClient!.get('/');
|
const response = await this.getSocketClient('getInfo').get('/');
|
||||||
return response.body;
|
return response.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +311,7 @@ export class MicroVM {
|
|||||||
* Get Firecracker version info.
|
* Get Firecracker version info.
|
||||||
*/
|
*/
|
||||||
public async getVersion(): Promise<any> {
|
public async getVersion(): Promise<any> {
|
||||||
const response = await this.socketClient!.get('/version');
|
const response = await this.getSocketClient('getVersion').get('/version');
|
||||||
return response.body;
|
return response.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +329,13 @@ export class MicroVM {
|
|||||||
return this.vmConfig;
|
return this.vmConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the per-VM runtime directory if it has been created.
|
||||||
|
*/
|
||||||
|
public getRuntimeDir(): string | null {
|
||||||
|
return this.vmRuntimeDir;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full cleanup: stop process, remove socket, remove TAP devices.
|
* Full cleanup: stop process, remove socket, remove TAP devices.
|
||||||
*/
|
*/
|
||||||
@@ -323,20 +352,74 @@ export class MicroVM {
|
|||||||
}
|
}
|
||||||
this.tapDevices = [];
|
this.tapDevices = [];
|
||||||
|
|
||||||
|
if (this.vmRuntimeDir) {
|
||||||
|
await plugins.fs.promises.rm(this.vmRuntimeDir, { recursive: true, force: true });
|
||||||
|
this.vmRuntimeDir = null;
|
||||||
|
}
|
||||||
|
|
||||||
this.socketClient = null;
|
this.socketClient = null;
|
||||||
if (this.state !== 'error') {
|
if (this.state !== 'error') {
|
||||||
this.state = 'stopped';
|
this.state = 'stopped';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldStageDrive(drive: IDriveConfig): boolean {
|
||||||
|
if (!this.ephemeralWritableDrives) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (drive.ephemeral === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (drive.isReadOnly === true && drive.ephemeral !== true) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureVMRuntimeDir(): Promise<string> {
|
||||||
|
if (!this.vmRuntimeDir) {
|
||||||
|
this.vmRuntimeDir = plugins.path.join(this.runtimeDir, this.sanitizePathPart(this.id));
|
||||||
|
}
|
||||||
|
await plugins.fs.promises.mkdir(this.vmRuntimeDir, { recursive: true });
|
||||||
|
return this.vmRuntimeDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepareEphemeralDrives(): Promise<void> {
|
||||||
|
const drives = this.vmConfig.config.drives || [];
|
||||||
|
for (const drive of drives) {
|
||||||
|
if (!this.shouldStageDrive(drive)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vmRuntimeDir = await this.ensureVMRuntimeDir();
|
||||||
|
const drivesDir = plugins.path.join(vmRuntimeDir, 'drives');
|
||||||
|
await plugins.fs.promises.mkdir(drivesDir, { recursive: true });
|
||||||
|
|
||||||
|
const sourcePath = drive.pathOnHost;
|
||||||
|
const sourceFileName = plugins.path.basename(sourcePath) || 'drive.img';
|
||||||
|
const stagedPath = plugins.path.join(
|
||||||
|
drivesDir,
|
||||||
|
`${this.sanitizePathPart(drive.driveId)}-${this.sanitizePathPart(sourceFileName)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await plugins.fs.promises.copyFile(sourcePath, stagedPath);
|
||||||
|
drive.pathOnHost = stagedPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizePathPart(value: string): string {
|
||||||
|
const sanitized = value.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
if (!sanitized || sanitized === '.' || sanitized === '..') {
|
||||||
|
return 'item';
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: PUT request with error handling.
|
* Helper: PUT request with error handling.
|
||||||
*/
|
*/
|
||||||
private async apiPut(path: string, body: Record<string, any>): Promise<void> {
|
private async apiPut(path: string, body: Record<string, any>): Promise<void> {
|
||||||
if (!this.socketClient) {
|
const response = await this.getSocketClient(`PUT ${path}`).put(path, body);
|
||||||
throw new SmartVMError('Socket client not initialized', 'NO_CLIENT');
|
|
||||||
}
|
|
||||||
const response = await this.socketClient.put(path, body);
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`API PUT ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`,
|
`API PUT ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`,
|
||||||
@@ -351,10 +434,7 @@ export class MicroVM {
|
|||||||
* Helper: PATCH request with error handling.
|
* Helper: PATCH request with error handling.
|
||||||
*/
|
*/
|
||||||
private async apiPatch(path: string, body: Record<string, any>): Promise<void> {
|
private async apiPatch(path: string, body: Record<string, any>): Promise<void> {
|
||||||
if (!this.socketClient) {
|
const response = await this.getSocketClient(`PATCH ${path}`).patch(path, body);
|
||||||
throw new SmartVMError('Socket client not initialized', 'NO_CLIENT');
|
|
||||||
}
|
|
||||||
const response = await this.socketClient.patch(path, body);
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`API PATCH ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`,
|
`API PATCH ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`,
|
||||||
|
|||||||
+194
-48
@@ -2,6 +2,15 @@ import * as plugins from './plugins.js';
|
|||||||
import type { INetworkManagerOptions, ITapDevice } from './interfaces/index.js';
|
import type { INetworkManagerOptions, ITapDevice } from './interfaces/index.js';
|
||||||
import { SmartVMError } from './interfaces/index.js';
|
import { SmartVMError } from './interfaces/index.js';
|
||||||
|
|
||||||
|
type TShellExecResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawn']>>;
|
||||||
|
|
||||||
|
interface IParsedSubnet {
|
||||||
|
networkAddress: number;
|
||||||
|
broadcastAddress: number;
|
||||||
|
cidr: number;
|
||||||
|
subnetMask: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages host networking for Firecracker VMs.
|
* Manages host networking for Firecracker VMs.
|
||||||
* Creates TAP devices, Linux bridges, and configures NAT for VM internet access.
|
* Creates TAP devices, Linux bridges, and configures NAT for VM internet access.
|
||||||
@@ -12,53 +21,121 @@ export class NetworkManager {
|
|||||||
private subnetCidr: number;
|
private subnetCidr: number;
|
||||||
private gatewayIp: string;
|
private gatewayIp: string;
|
||||||
private subnetMask: string;
|
private subnetMask: string;
|
||||||
private nextIpOctet: number;
|
private nextIpAddress: number;
|
||||||
|
private lastUsableIpAddress: number;
|
||||||
private activeTaps: Map<string, ITapDevice> = new Map();
|
private activeTaps: Map<string, ITapDevice> = new Map();
|
||||||
private bridgeCreated: boolean = false;
|
private bridgeCreated: boolean = false;
|
||||||
|
private defaultRouteInterface: string | null = null;
|
||||||
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
||||||
|
|
||||||
constructor(options: INetworkManagerOptions = {}) {
|
constructor(options: INetworkManagerOptions = {}) {
|
||||||
this.bridgeName = options.bridgeName || 'svbr0';
|
this.bridgeName = options.bridgeName || 'svbr0';
|
||||||
|
this.validateInterfaceName(this.bridgeName, 'bridgeName');
|
||||||
const subnet = options.subnet || '172.30.0.0/24';
|
const subnet = options.subnet || '172.30.0.0/24';
|
||||||
|
const parsedSubnet = this.parseSubnet(subnet);
|
||||||
|
|
||||||
// Parse the subnet
|
this.subnetBase = this.intToIp(parsedSubnet.networkAddress);
|
||||||
const [baseIp, cidrStr] = subnet.split('/');
|
this.subnetCidr = parsedSubnet.cidr;
|
||||||
this.subnetBase = baseIp;
|
this.subnetMask = parsedSubnet.subnetMask;
|
||||||
this.subnetCidr = parseInt(cidrStr, 10);
|
this.gatewayIp = this.intToIp(parsedSubnet.networkAddress + 1);
|
||||||
this.subnetMask = this.cidrToSubnetMask(this.subnetCidr);
|
this.nextIpAddress = parsedSubnet.networkAddress + 2;
|
||||||
|
this.lastUsableIpAddress = parsedSubnet.broadcastAddress - 1;
|
||||||
// Gateway is .1 in the subnet
|
|
||||||
const parts = this.subnetBase.split('.').map(Number);
|
|
||||||
parts[3] = 1;
|
|
||||||
this.gatewayIp = parts.join('.');
|
|
||||||
|
|
||||||
// VMs start at .2
|
|
||||||
this.nextIpOctet = 2;
|
|
||||||
|
|
||||||
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a CIDR prefix length to a dotted-decimal subnet mask.
|
* Parse an IPv4 CIDR subnet and ensure there is room for a gateway and guests.
|
||||||
*/
|
*/
|
||||||
private cidrToSubnetMask(cidr: number): string {
|
private parseSubnet(subnet: string): IParsedSubnet {
|
||||||
const mask = (0xffffffff << (32 - cidr)) >>> 0;
|
const [ip, cidrText, extra] = subnet.split('/');
|
||||||
|
const cidr = Number(cidrText);
|
||||||
|
if (!ip || !cidrText || extra !== undefined || !Number.isInteger(cidr) || cidr < 1 || cidr > 30) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Invalid subnet '${subnet}': expected IPv4 CIDR with prefix length 1-30`,
|
||||||
|
'INVALID_SUBNET',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipAddress = this.ipToInt(ip);
|
||||||
|
const mask = this.cidrToMask(cidr);
|
||||||
|
const networkAddress = (ipAddress & mask) >>> 0;
|
||||||
|
const hostCount = 2 ** (32 - cidr);
|
||||||
|
const broadcastAddress = networkAddress + hostCount - 1;
|
||||||
|
if (hostCount < 4) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`Invalid subnet '${subnet}': at least two usable host addresses are required`,
|
||||||
|
'INVALID_SUBNET',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
networkAddress,
|
||||||
|
broadcastAddress,
|
||||||
|
cidr,
|
||||||
|
subnetMask: this.intToIp(mask),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private ipToInt(ip: string): number {
|
||||||
|
const octets = ip.split('.');
|
||||||
|
if (octets.length !== 4) {
|
||||||
|
throw new SmartVMError(`Invalid IPv4 address '${ip}'`, 'INVALID_SUBNET');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (octets.some((octet) => !/^[0-9]+$/.test(octet))) {
|
||||||
|
throw new SmartVMError(`Invalid IPv4 address '${ip}'`, 'INVALID_SUBNET');
|
||||||
|
}
|
||||||
|
|
||||||
|
const numbers = octets.map((octet) => Number(octet));
|
||||||
|
if (numbers.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
|
||||||
|
throw new SmartVMError(`Invalid IPv4 address '${ip}'`, 'INVALID_SUBNET');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
numbers[0] * 256 ** 3 +
|
||||||
|
numbers[1] * 256 ** 2 +
|
||||||
|
numbers[2] * 256 +
|
||||||
|
numbers[3]
|
||||||
|
) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private intToIp(address: number): string {
|
||||||
return [
|
return [
|
||||||
(mask >>> 24) & 0xff,
|
Math.floor(address / 256 ** 3) % 256,
|
||||||
(mask >>> 16) & 0xff,
|
Math.floor(address / 256 ** 2) % 256,
|
||||||
(mask >>> 8) & 0xff,
|
Math.floor(address / 256) % 256,
|
||||||
mask & 0xff,
|
address % 256,
|
||||||
].join('.');
|
].join('.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private cidrToMask(cidr: number): number {
|
||||||
|
return (0xffffffff << (32 - cidr)) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateInterfaceName(name: string, fieldName: string): void {
|
||||||
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,14}$/.test(name)) {
|
||||||
|
throw new SmartVMError(
|
||||||
|
`${fieldName} '${name}' is not a valid Linux interface name`,
|
||||||
|
'INVALID_INTERFACE_NAME',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allocate the next available IP address in the subnet.
|
* Allocate the next available IP address in the subnet.
|
||||||
*/
|
*/
|
||||||
public allocateIp(): string {
|
public allocateIp(): string {
|
||||||
const parts = this.subnetBase.split('.').map(Number);
|
if (this.nextIpAddress > this.lastUsableIpAddress) {
|
||||||
parts[3] = this.nextIpOctet;
|
throw new SmartVMError(
|
||||||
this.nextIpOctet++;
|
`Subnet ${this.subnetBase}/${this.subnetCidr} has no available guest IP addresses`,
|
||||||
return parts.join('.');
|
'IP_EXHAUSTED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ip = this.intToIp(this.nextIpAddress);
|
||||||
|
this.nextIpAddress++;
|
||||||
|
return ip;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -102,6 +179,36 @@ export class NetworkManager {
|
|||||||
return tapName.substring(0, 15);
|
return tapName.substring(0, 15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async run(command: string, args: string[]): Promise<TShellExecResult> {
|
||||||
|
return this.shell.execSpawn(command, args, { silent: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runChecked(command: string, args: string[]): Promise<TShellExecResult> {
|
||||||
|
const result = await this.run(command, args);
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
const output = (result.stderr || result.stdout || '').trim();
|
||||||
|
throw new Error(`${command} ${args.join(' ')} exited with code ${result.exitCode}${output ? `: ${output}` : ''}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDefaultRouteInterface(): Promise<string> {
|
||||||
|
if (this.defaultRouteInterface) {
|
||||||
|
return this.defaultRouteInterface;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.runChecked('ip', ['route', 'show', 'default']);
|
||||||
|
const match = result.stdout.match(/\bdev\s+([^\s]+)/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('Could not determine default route interface');
|
||||||
|
}
|
||||||
|
|
||||||
|
const iface = match[1];
|
||||||
|
this.validateInterfaceName(iface, 'default route interface');
|
||||||
|
this.defaultRouteInterface = iface;
|
||||||
|
return iface;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure the Linux bridge is created and configured.
|
* Ensure the Linux bridge is created and configured.
|
||||||
*/
|
*/
|
||||||
@@ -110,31 +217,52 @@ export class NetworkManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if bridge already exists
|
// Check if bridge already exists
|
||||||
const result = await this.shell.exec(`ip link show ${this.bridgeName} 2>/dev/null`);
|
const result = await this.run('ip', ['link', 'show', this.bridgeName]);
|
||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
// Create bridge
|
// Create bridge
|
||||||
await this.shell.exec(`ip link add ${this.bridgeName} type bridge`);
|
await this.runChecked('ip', ['link', 'add', this.bridgeName, 'type', 'bridge']);
|
||||||
await this.shell.exec(`ip addr add ${this.gatewayIp}/${this.subnetCidr} dev ${this.bridgeName}`);
|
await this.runChecked('ip', ['addr', 'add', `${this.gatewayIp}/${this.subnetCidr}`, 'dev', this.bridgeName]);
|
||||||
await this.shell.exec(`ip link set ${this.bridgeName} up`);
|
await this.runChecked('ip', ['link', 'set', this.bridgeName, 'up']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable IP forwarding
|
// Enable IP forwarding
|
||||||
await this.shell.exec('sysctl -w net.ipv4.ip_forward=1');
|
await this.runChecked('sysctl', ['-w', 'net.ipv4.ip_forward=1']);
|
||||||
|
|
||||||
// Set up NAT masquerade (idempotent with -C check)
|
// Set up NAT masquerade (idempotent with -C check)
|
||||||
const checkResult = await this.shell.exec(
|
const defaultIface = await this.getDefaultRouteInterface();
|
||||||
`iptables -t nat -C POSTROUTING -s ${this.subnetBase}/${this.subnetCidr} -o $(ip route | grep default | awk '{print $5}' | head -1) -j MASQUERADE 2>/dev/null`,
|
const natArgs = [
|
||||||
);
|
'-t',
|
||||||
|
'nat',
|
||||||
|
'-C',
|
||||||
|
'POSTROUTING',
|
||||||
|
'-s',
|
||||||
|
`${this.subnetBase}/${this.subnetCidr}`,
|
||||||
|
'-o',
|
||||||
|
defaultIface,
|
||||||
|
'-j',
|
||||||
|
'MASQUERADE',
|
||||||
|
];
|
||||||
|
const checkResult = await this.run('iptables', natArgs);
|
||||||
if (checkResult.exitCode !== 0) {
|
if (checkResult.exitCode !== 0) {
|
||||||
await this.shell.exec(
|
await this.runChecked('iptables', [
|
||||||
`iptables -t nat -A POSTROUTING -s ${this.subnetBase}/${this.subnetCidr} -o $(ip route | grep default | awk '{print $5}' | head -1) -j MASQUERADE`,
|
'-t',
|
||||||
);
|
'nat',
|
||||||
|
'-A',
|
||||||
|
'POSTROUTING',
|
||||||
|
'-s',
|
||||||
|
`${this.subnetBase}/${this.subnetCidr}`,
|
||||||
|
'-o',
|
||||||
|
defaultIface,
|
||||||
|
'-j',
|
||||||
|
'MASQUERADE',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.bridgeCreated = true;
|
this.bridgeCreated = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`Failed to set up network bridge: ${err.message}`,
|
`Failed to set up network bridge: ${message}`,
|
||||||
'BRIDGE_SETUP_FAILED',
|
'BRIDGE_SETUP_FAILED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -147,16 +275,19 @@ export class NetworkManager {
|
|||||||
await this.ensureBridge();
|
await this.ensureBridge();
|
||||||
|
|
||||||
const tapName = this.generateTapName(vmId, ifaceId);
|
const tapName = this.generateTapName(vmId, ifaceId);
|
||||||
|
this.validateInterfaceName(tapName, 'tapName');
|
||||||
const guestIp = this.allocateIp();
|
const guestIp = this.allocateIp();
|
||||||
const mac = this.generateMac(vmId, ifaceId);
|
const mac = this.generateMac(vmId, ifaceId);
|
||||||
|
let tapCreated = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create TAP device
|
// Create TAP device
|
||||||
await this.shell.exec(`ip tuntap add dev ${tapName} mode tap`);
|
await this.runChecked('ip', ['tuntap', 'add', 'dev', tapName, 'mode', 'tap']);
|
||||||
|
tapCreated = true;
|
||||||
// Attach to bridge
|
// Attach to bridge
|
||||||
await this.shell.exec(`ip link set ${tapName} master ${this.bridgeName}`);
|
await this.runChecked('ip', ['link', 'set', tapName, 'master', this.bridgeName]);
|
||||||
// Bring TAP device up
|
// Bring TAP device up
|
||||||
await this.shell.exec(`ip link set ${tapName} up`);
|
await this.runChecked('ip', ['link', 'set', tapName, 'up']);
|
||||||
|
|
||||||
const tap: ITapDevice = {
|
const tap: ITapDevice = {
|
||||||
tapName,
|
tapName,
|
||||||
@@ -169,8 +300,12 @@ export class NetworkManager {
|
|||||||
this.activeTaps.set(tapName, tap);
|
this.activeTaps.set(tapName, tap);
|
||||||
return tap;
|
return tap;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (tapCreated) {
|
||||||
|
await this.removeTapDevice(tapName);
|
||||||
|
}
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`Failed to create TAP device ${tapName}: ${err.message}`,
|
`Failed to create TAP device ${tapName}: ${message}`,
|
||||||
'TAP_CREATE_FAILED',
|
'TAP_CREATE_FAILED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -180,8 +315,9 @@ export class NetworkManager {
|
|||||||
* Remove a TAP device and free its resources.
|
* Remove a TAP device and free its resources.
|
||||||
*/
|
*/
|
||||||
public async removeTapDevice(tapName: string): Promise<void> {
|
public async removeTapDevice(tapName: string): Promise<void> {
|
||||||
|
this.validateInterfaceName(tapName, 'tapName');
|
||||||
try {
|
try {
|
||||||
await this.shell.exec(`ip link del ${tapName} 2>/dev/null`);
|
await this.run('ip', ['link', 'del', tapName]);
|
||||||
this.activeTaps.delete(tapName);
|
this.activeTaps.delete(tapName);
|
||||||
} catch {
|
} catch {
|
||||||
// Device may already be gone
|
// Device may already be gone
|
||||||
@@ -209,24 +345,34 @@ export class NetworkManager {
|
|||||||
*/
|
*/
|
||||||
public async cleanup(): Promise<void> {
|
public async cleanup(): Promise<void> {
|
||||||
// Remove all TAP devices
|
// Remove all TAP devices
|
||||||
for (const tapName of this.activeTaps.keys()) {
|
for (const tapName of Array.from(this.activeTaps.keys())) {
|
||||||
await this.removeTapDevice(tapName);
|
await this.removeTapDevice(tapName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove bridge if we created it
|
// Remove bridge if we created it
|
||||||
if (this.bridgeCreated) {
|
if (this.bridgeCreated) {
|
||||||
try {
|
try {
|
||||||
await this.shell.exec(`ip link set ${this.bridgeName} down 2>/dev/null`);
|
await this.run('ip', ['link', 'set', this.bridgeName, 'down']);
|
||||||
await this.shell.exec(`ip link del ${this.bridgeName} 2>/dev/null`);
|
await this.run('ip', ['link', 'del', this.bridgeName]);
|
||||||
} catch {
|
} catch {
|
||||||
// Bridge may already be gone
|
// Bridge may already be gone
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove NAT rule
|
// Remove NAT rule
|
||||||
try {
|
try {
|
||||||
await this.shell.exec(
|
const defaultIface = this.defaultRouteInterface || await this.getDefaultRouteInterface();
|
||||||
`iptables -t nat -D POSTROUTING -s ${this.subnetBase}/${this.subnetCidr} -o $(ip route | grep default | awk '{print $5}' | head -1) -j MASQUERADE 2>/dev/null`,
|
await this.run('iptables', [
|
||||||
);
|
'-t',
|
||||||
|
'nat',
|
||||||
|
'-D',
|
||||||
|
'POSTROUTING',
|
||||||
|
'-s',
|
||||||
|
`${this.subnetBase}/${this.subnetCidr}`,
|
||||||
|
'-o',
|
||||||
|
defaultIface,
|
||||||
|
'-j',
|
||||||
|
'MASQUERADE',
|
||||||
|
]);
|
||||||
} catch {
|
} catch {
|
||||||
// Rule may not exist
|
// Rule may not exist
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-5
@@ -1,16 +1,22 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
import type { ISmartVMOptions, IMicroVMConfig } from './interfaces/index.js';
|
import type { IBaseImageBundle, IEnsureBaseImageOptions, ISmartVMOptions, IMicroVMConfig } from './interfaces/index.js';
|
||||||
import { SmartVMError } from './interfaces/index.js';
|
import { SmartVMError } from './interfaces/index.js';
|
||||||
import { ImageManager } from './classes.imagemanager.js';
|
import { ImageManager } from './classes.imagemanager.js';
|
||||||
|
import { BaseImageManager } from './classes.baseimagemanager.js';
|
||||||
import { NetworkManager } from './classes.networkmanager.js';
|
import { NetworkManager } from './classes.networkmanager.js';
|
||||||
import { MicroVM } from './classes.microvm.js';
|
import { MicroVM } from './classes.microvm.js';
|
||||||
|
|
||||||
|
function getErrorMessage(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-level orchestrator for creating and managing Firecracker MicroVMs.
|
* Top-level orchestrator for creating and managing Firecracker MicroVMs.
|
||||||
*/
|
*/
|
||||||
export class SmartVM {
|
export class SmartVM {
|
||||||
private options: ISmartVMOptions;
|
private options: ISmartVMOptions;
|
||||||
public imageManager: ImageManager;
|
public imageManager: ImageManager;
|
||||||
|
public baseImageManager: BaseImageManager;
|
||||||
public networkManager: NetworkManager;
|
public networkManager: NetworkManager;
|
||||||
private activeVMs: Map<string, MicroVM> = new Map();
|
private activeVMs: Map<string, MicroVM> = new Map();
|
||||||
private smartExitInstance: InstanceType<typeof plugins.smartexit.SmartExit>;
|
private smartExitInstance: InstanceType<typeof plugins.smartexit.SmartExit>;
|
||||||
@@ -20,6 +26,8 @@ export class SmartVM {
|
|||||||
constructor(options: ISmartVMOptions = {}) {
|
constructor(options: ISmartVMOptions = {}) {
|
||||||
this.options = {
|
this.options = {
|
||||||
dataDir: options.dataDir || '/tmp/.smartvm',
|
dataDir: options.dataDir || '/tmp/.smartvm',
|
||||||
|
runtimeDir: options.runtimeDir || this.getDefaultRuntimeDir(),
|
||||||
|
ephemeralWritableDrives: options.ephemeralWritableDrives ?? true,
|
||||||
arch: options.arch || 'x86_64',
|
arch: options.arch || 'x86_64',
|
||||||
bridgeName: options.bridgeName || 'svbr0',
|
bridgeName: options.bridgeName || 'svbr0',
|
||||||
subnet: options.subnet || '172.30.0.0/24',
|
subnet: options.subnet || '172.30.0.0/24',
|
||||||
@@ -27,6 +35,13 @@ export class SmartVM {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.imageManager = new ImageManager(this.options.dataDir!, this.options.arch);
|
this.imageManager = new ImageManager(this.options.dataDir!, this.options.arch);
|
||||||
|
this.baseImageManager = new BaseImageManager({
|
||||||
|
arch: this.options.arch,
|
||||||
|
cacheDir: this.options.baseImageCacheDir,
|
||||||
|
maxStoredBaseImages: this.options.maxStoredBaseImages,
|
||||||
|
hostedManifestUrl: this.options.baseImageManifestUrl,
|
||||||
|
hostedManifestPath: this.options.baseImageManifestPath,
|
||||||
|
});
|
||||||
this.networkManager = new NetworkManager({
|
this.networkManager = new NetworkManager({
|
||||||
bridgeName: this.options.bridgeName,
|
bridgeName: this.options.bridgeName,
|
||||||
subnet: this.options.subnet,
|
subnet: this.options.subnet,
|
||||||
@@ -44,6 +59,30 @@ export class SmartVM {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getDefaultRuntimeDir(): string {
|
||||||
|
const tmpfsDir = '/dev/shm';
|
||||||
|
try {
|
||||||
|
if (plugins.fs.existsSync(tmpfsDir) && plugins.fs.statSync(tmpfsDir).isDirectory()) {
|
||||||
|
return plugins.path.join(tmpfsDir, '.smartvm', 'runtime');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to os.tmpdir() below.
|
||||||
|
}
|
||||||
|
return plugins.path.join(plugins.os.tmpdir(), '.smartvm', 'runtime');
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRuntimeDir(): string {
|
||||||
|
return this.options.runtimeDir!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizePathPart(value: string): string {
|
||||||
|
const sanitized = value.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
|
if (!sanitized || sanitized === '.' || sanitized === '..') {
|
||||||
|
return 'item';
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure the Firecracker binary is available.
|
* Ensure the Firecracker binary is available.
|
||||||
* Downloads it if not present.
|
* Downloads it if not present.
|
||||||
@@ -97,8 +136,9 @@ export class SmartVM {
|
|||||||
// Generate VM ID if not provided
|
// Generate VM ID if not provided
|
||||||
const vmId = config.id || plugins.smartunique.uuid4();
|
const vmId = config.id || plugins.smartunique.uuid4();
|
||||||
|
|
||||||
// Generate socket path
|
// Keep per-VM runtime artifacts in tmpfs by default.
|
||||||
const socketPath = this.imageManager.getSocketPath(vmId);
|
const vmRuntimeDir = plugins.path.join(this.options.runtimeDir!, this.sanitizePathPart(vmId));
|
||||||
|
const socketPath = plugins.path.join(vmRuntimeDir, 'firecracker.sock');
|
||||||
|
|
||||||
// Create MicroVM instance
|
// Create MicroVM instance
|
||||||
const vm = new MicroVM(
|
const vm = new MicroVM(
|
||||||
@@ -107,6 +147,10 @@ export class SmartVM {
|
|||||||
this.firecrackerBinaryPath!,
|
this.firecrackerBinaryPath!,
|
||||||
socketPath,
|
socketPath,
|
||||||
this.networkManager,
|
this.networkManager,
|
||||||
|
{
|
||||||
|
runtimeDir: this.options.runtimeDir,
|
||||||
|
ephemeralWritableDrives: this.options.ephemeralWritableDrives,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Register in active VMs
|
// Register in active VMs
|
||||||
@@ -115,6 +159,13 @@ export class SmartVM {
|
|||||||
return vm;
|
return vm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a Firecracker CI base image bundle is available locally.
|
||||||
|
*/
|
||||||
|
public async ensureBaseImage(options: IEnsureBaseImageOptions = {}): Promise<IBaseImageBundle> {
|
||||||
|
return this.baseImageManager.ensureBaseImage(options);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get an active VM by ID.
|
* Get an active VM by ID.
|
||||||
*/
|
*/
|
||||||
@@ -145,7 +196,7 @@ export class SmartVM {
|
|||||||
if (vm.state === 'running' || vm.state === 'paused') {
|
if (vm.state === 'running' || vm.state === 'paused') {
|
||||||
stopPromises.push(
|
stopPromises.push(
|
||||||
vm.stop().catch((err) => {
|
vm.stop().catch((err) => {
|
||||||
console.error(`Failed to stop VM ${vm.id}: ${err.message}`);
|
console.error(`Failed to stop VM ${vm.id}: ${getErrorMessage(err)}`);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -162,7 +213,7 @@ export class SmartVM {
|
|||||||
for (const vm of this.activeVMs.values()) {
|
for (const vm of this.activeVMs.values()) {
|
||||||
cleanupPromises.push(
|
cleanupPromises.push(
|
||||||
vm.cleanup().catch((err) => {
|
vm.cleanup().catch((err) => {
|
||||||
console.error(`Failed to clean up VM ${vm.id}: ${err.message}`);
|
console.error(`Failed to clean up VM ${vm.id}: ${getErrorMessage(err)}`);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-26
@@ -2,6 +2,10 @@ import * as plugins from './plugins.js';
|
|||||||
import type { ISocketClientOptions, IApiResponse } from './interfaces/index.js';
|
import type { ISocketClientOptions, IApiResponse } from './interfaces/index.js';
|
||||||
import { SmartVMError } from './interfaces/index.js';
|
import { SmartVMError } from './interfaces/index.js';
|
||||||
|
|
||||||
|
function getErrorMessage(err: unknown): string {
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP client that communicates with Firecracker over a Unix domain socket.
|
* HTTP client that communicates with Firecracker over a Unix domain socket.
|
||||||
* Uses @push.rocks/smartrequest with the `http://unix:<socket>:<path>` URL format.
|
* Uses @push.rocks/smartrequest with the `http://unix:<socket>:<path>` URL format.
|
||||||
@@ -20,6 +24,22 @@ export class SocketClient {
|
|||||||
return `http://unix:${this.socketPath}:${apiPath}`;
|
return `http://unix:${this.socketPath}:${apiPath}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async parseResponseBody<T>(response: any): Promise<T> {
|
||||||
|
try {
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
} catch {
|
||||||
|
return text as T;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform a GET request.
|
* Perform a GET request.
|
||||||
*/
|
*/
|
||||||
@@ -31,12 +51,7 @@ export class SocketClient {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
const statusCode = response.status;
|
const statusCode = response.status;
|
||||||
let body: T;
|
const body = await this.parseResponseBody<T>(response);
|
||||||
try {
|
|
||||||
body = await response.json() as T;
|
|
||||||
} catch {
|
|
||||||
body = undefined as any;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
statusCode,
|
statusCode,
|
||||||
body,
|
body,
|
||||||
@@ -44,7 +59,7 @@ export class SocketClient {
|
|||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`GET ${apiPath} failed: ${(err as Error).message}`,
|
`GET ${apiPath} failed: ${getErrorMessage(err)}`,
|
||||||
'SOCKET_REQUEST_FAILED',
|
'SOCKET_REQUEST_FAILED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -58,17 +73,15 @@ export class SocketClient {
|
|||||||
try {
|
try {
|
||||||
let request = plugins.SmartRequest.create().url(url);
|
let request = plugins.SmartRequest.create().url(url);
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
request = request.json(body);
|
const bodyBuffer = Buffer.from(JSON.stringify(body));
|
||||||
|
request = request
|
||||||
|
.buffer(bodyBuffer, 'application/json')
|
||||||
|
.header('Content-Length', String(bodyBuffer.length));
|
||||||
}
|
}
|
||||||
const response = await request.put();
|
const response = await request.put();
|
||||||
|
|
||||||
const statusCode = response.status;
|
const statusCode = response.status;
|
||||||
let responseBody: T;
|
const responseBody = await this.parseResponseBody<T>(response);
|
||||||
try {
|
|
||||||
responseBody = await response.json() as T;
|
|
||||||
} catch {
|
|
||||||
responseBody = undefined as any;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
statusCode,
|
statusCode,
|
||||||
body: responseBody,
|
body: responseBody,
|
||||||
@@ -76,7 +89,7 @@ export class SocketClient {
|
|||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`PUT ${apiPath} failed: ${(err as Error).message}`,
|
`PUT ${apiPath} failed: ${getErrorMessage(err)}`,
|
||||||
'SOCKET_REQUEST_FAILED',
|
'SOCKET_REQUEST_FAILED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -90,17 +103,15 @@ export class SocketClient {
|
|||||||
try {
|
try {
|
||||||
let request = plugins.SmartRequest.create().url(url);
|
let request = plugins.SmartRequest.create().url(url);
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
request = request.json(body);
|
const bodyBuffer = Buffer.from(JSON.stringify(body));
|
||||||
|
request = request
|
||||||
|
.buffer(bodyBuffer, 'application/json')
|
||||||
|
.header('Content-Length', String(bodyBuffer.length));
|
||||||
}
|
}
|
||||||
const response = await request.patch();
|
const response = await request.patch();
|
||||||
|
|
||||||
const statusCode = response.status;
|
const statusCode = response.status;
|
||||||
let responseBody: T;
|
const responseBody = await this.parseResponseBody<T>(response);
|
||||||
try {
|
|
||||||
responseBody = await response.json() as T;
|
|
||||||
} catch {
|
|
||||||
responseBody = undefined as any;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
statusCode,
|
statusCode,
|
||||||
body: responseBody,
|
body: responseBody,
|
||||||
@@ -108,21 +119,21 @@ export class SocketClient {
|
|||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new SmartVMError(
|
throw new SmartVMError(
|
||||||
`PATCH ${apiPath} failed: ${(err as Error).message}`,
|
`PATCH ${apiPath} failed: ${getErrorMessage(err)}`,
|
||||||
'SOCKET_REQUEST_FAILED',
|
'SOCKET_REQUEST_FAILED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the Firecracker API socket is ready by polling GET /.
|
* Check if the Firecracker API socket is ready by polling GET /version.
|
||||||
*/
|
*/
|
||||||
public async isReady(timeoutMs: number = 5000): Promise<boolean> {
|
public async isReady(timeoutMs: number = 5000): Promise<boolean> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
while (Date.now() - start < timeoutMs) {
|
while (Date.now() - start < timeoutMs) {
|
||||||
try {
|
try {
|
||||||
const response = await this.get('/');
|
const response = await this.get('/version');
|
||||||
if (response.ok || response.statusCode === 200 || response.statusCode === 400) {
|
if (response.ok) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+39
-1
@@ -12,7 +12,45 @@ export class VMConfig {
|
|||||||
public config: IMicroVMConfig;
|
public config: IMicroVMConfig;
|
||||||
|
|
||||||
constructor(config: IMicroVMConfig) {
|
constructor(config: IMicroVMConfig) {
|
||||||
this.config = config;
|
this.config = this.cloneConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep internal normalization from mutating the caller's config object.
|
||||||
|
*/
|
||||||
|
private cloneConfig(config: IMicroVMConfig): IMicroVMConfig {
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
bootSource: config.bootSource ? { ...config.bootSource } : config.bootSource,
|
||||||
|
machineConfig: config.machineConfig ? { ...config.machineConfig } : config.machineConfig,
|
||||||
|
drives: config.drives?.map((drive) => ({
|
||||||
|
...drive,
|
||||||
|
rateLimiter: drive.rateLimiter ? this.cloneRateLimiter(drive.rateLimiter) : undefined,
|
||||||
|
ephemeral: drive.ephemeral,
|
||||||
|
})),
|
||||||
|
networkInterfaces: config.networkInterfaces?.map((iface) => ({
|
||||||
|
...iface,
|
||||||
|
rxRateLimiter: iface.rxRateLimiter ? this.cloneRateLimiter(iface.rxRateLimiter) : undefined,
|
||||||
|
txRateLimiter: iface.txRateLimiter ? this.cloneRateLimiter(iface.txRateLimiter) : undefined,
|
||||||
|
})),
|
||||||
|
vsock: config.vsock ? { ...config.vsock } : undefined,
|
||||||
|
balloon: config.balloon ? { ...config.balloon } : undefined,
|
||||||
|
mmds: config.mmds ? {
|
||||||
|
...config.mmds,
|
||||||
|
networkInterfaces: config.mmds.networkInterfaces
|
||||||
|
? [...config.mmds.networkInterfaces]
|
||||||
|
: config.mmds.networkInterfaces,
|
||||||
|
} : undefined,
|
||||||
|
logger: config.logger ? { ...config.logger } : undefined,
|
||||||
|
metrics: config.metrics ? { ...config.metrics } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneRateLimiter(rateLimiter: IRateLimiter): IRateLimiter {
|
||||||
|
return {
|
||||||
|
bandwidth: rateLimiter.bandwidth ? { ...rateLimiter.bandwidth } : undefined,
|
||||||
|
ops: rateLimiter.ops ? { ...rateLimiter.ops } : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export * from './interfaces/index.js';
|
|||||||
export { VMConfig } from './classes.vmconfig.js';
|
export { VMConfig } from './classes.vmconfig.js';
|
||||||
export { SocketClient } from './classes.socketclient.js';
|
export { SocketClient } from './classes.socketclient.js';
|
||||||
export { ImageManager } from './classes.imagemanager.js';
|
export { ImageManager } from './classes.imagemanager.js';
|
||||||
|
export { BaseImageManager } from './classes.baseimagemanager.js';
|
||||||
export { FirecrackerProcess } from './classes.firecrackerprocess.js';
|
export { FirecrackerProcess } from './classes.firecrackerprocess.js';
|
||||||
export { NetworkManager } from './classes.networkmanager.js';
|
export { NetworkManager } from './classes.networkmanager.js';
|
||||||
export { MicroVM } from './classes.microvm.js';
|
export { MicroVM } from './classes.microvm.js';
|
||||||
|
|||||||
+139
-2
@@ -6,6 +6,10 @@ import type { TFirecrackerArch, TCacheType, TSnapshotType, TLogLevel } from './c
|
|||||||
export interface ISmartVMOptions {
|
export interface ISmartVMOptions {
|
||||||
/** Directory for storing binaries, kernels, rootfs images, and sockets. Defaults to /tmp/.smartvm */
|
/** Directory for storing binaries, kernels, rootfs images, and sockets. Defaults to /tmp/.smartvm */
|
||||||
dataDir?: string;
|
dataDir?: string;
|
||||||
|
/** Directory for VM sockets and ephemeral per-VM files. Defaults to /dev/shm/.smartvm/runtime on Linux when available. */
|
||||||
|
runtimeDir?: string;
|
||||||
|
/** Copy writable drives into the VM runtime directory before boot and delete them on cleanup. Defaults to true. */
|
||||||
|
ephemeralWritableDrives?: boolean;
|
||||||
/** Firecracker version to use. Defaults to latest. */
|
/** Firecracker version to use. Defaults to latest. */
|
||||||
firecrackerVersion?: string;
|
firecrackerVersion?: string;
|
||||||
/** Target architecture. Defaults to x86_64. */
|
/** Target architecture. Defaults to x86_64. */
|
||||||
@@ -16,6 +20,137 @@ export interface ISmartVMOptions {
|
|||||||
bridgeName?: string;
|
bridgeName?: string;
|
||||||
/** Network subnet in CIDR notation. Defaults to '172.30.0.0/24'. */
|
/** Network subnet in CIDR notation. Defaults to '172.30.0.0/24'. */
|
||||||
subnet?: string;
|
subnet?: string;
|
||||||
|
/** Directory for cached base images. Defaults to /tmp/.smartvm/base-images. */
|
||||||
|
baseImageCacheDir?: string;
|
||||||
|
/** Maximum number of cached base image bundles. Defaults to 2. */
|
||||||
|
maxStoredBaseImages?: number;
|
||||||
|
/** Hosted/project-owned base image manifest URL. */
|
||||||
|
baseImageManifestUrl?: string;
|
||||||
|
/** Local hosted/project-owned base image manifest path for development and tests. */
|
||||||
|
baseImageManifestPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Predefined base image sources for integration testing and quick starts.
|
||||||
|
*/
|
||||||
|
export type TBaseImagePreset = 'latest' | 'lts' | 'hosted';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root filesystem image type used by a base image bundle.
|
||||||
|
*/
|
||||||
|
export type TBaseImageRootfsType = 'ext4' | 'squashfs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for the BaseImageManager.
|
||||||
|
*/
|
||||||
|
export interface IBaseImageManagerOptions {
|
||||||
|
/** Architecture to resolve. Defaults to x86_64. */
|
||||||
|
arch?: TFirecrackerArch;
|
||||||
|
/** Directory for cached base image bundles. Defaults to /tmp/.smartvm/base-images. */
|
||||||
|
cacheDir?: string;
|
||||||
|
/** Maximum number of cached base image bundles. Defaults to 2. */
|
||||||
|
maxStoredBaseImages?: number;
|
||||||
|
/** Hosted base image manifest URL for project-owned bundles. */
|
||||||
|
hostedManifestUrl?: string;
|
||||||
|
/** Local hosted base image manifest path for development and tests. */
|
||||||
|
hostedManifestPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options when resolving or downloading a base image bundle.
|
||||||
|
*/
|
||||||
|
export interface IEnsureBaseImageOptions {
|
||||||
|
/** Preset to use. Defaults to latest. */
|
||||||
|
preset?: TBaseImagePreset;
|
||||||
|
/** Architecture to resolve. Defaults to manager architecture. */
|
||||||
|
arch?: TFirecrackerArch;
|
||||||
|
/** Redownload even if the bundle already exists locally. */
|
||||||
|
forceDownload?: boolean;
|
||||||
|
/** Hosted base image manifest URL. Overrides preset resolution. */
|
||||||
|
manifestUrl?: string;
|
||||||
|
/** Local hosted base image manifest path. Overrides preset resolution. */
|
||||||
|
manifestPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single hosted base image artifact in a manifest.
|
||||||
|
*/
|
||||||
|
export interface IBaseImageArtifactManifest {
|
||||||
|
/** Public URL for hosted artifacts. */
|
||||||
|
url?: string;
|
||||||
|
/** Local path for development/tests. */
|
||||||
|
path?: string;
|
||||||
|
/** Optional plain output filename. Defaults to basename of url/path. */
|
||||||
|
fileName?: string;
|
||||||
|
/** Expected SHA256 for verification. Required when url is used. */
|
||||||
|
sha256?: string;
|
||||||
|
/** Expected file size in bytes. */
|
||||||
|
sizeBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosted/project-owned base image manifest format.
|
||||||
|
*/
|
||||||
|
export interface IBaseImageHostedManifest {
|
||||||
|
schemaVersion: 1;
|
||||||
|
bundleId: string;
|
||||||
|
name?: string;
|
||||||
|
arch: TFirecrackerArch;
|
||||||
|
firecrackerVersion: string;
|
||||||
|
rootfsType: TBaseImageRootfsType;
|
||||||
|
rootfsIsReadOnly?: boolean;
|
||||||
|
bootArgs?: string;
|
||||||
|
kernel: IBaseImageArtifactManifest;
|
||||||
|
rootfs: IBaseImageArtifactManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached base image bundle metadata.
|
||||||
|
*/
|
||||||
|
export interface IBaseImageBundle {
|
||||||
|
preset: TBaseImagePreset;
|
||||||
|
arch: TFirecrackerArch;
|
||||||
|
ciVersion: string;
|
||||||
|
firecrackerVersion: string;
|
||||||
|
bundleId: string;
|
||||||
|
bundleDir: string;
|
||||||
|
kernelImagePath: string;
|
||||||
|
rootfsPath: string;
|
||||||
|
rootfsType: TBaseImageRootfsType;
|
||||||
|
rootfsIsReadOnly: boolean;
|
||||||
|
bootArgs: string;
|
||||||
|
source: {
|
||||||
|
type?: 'firecracker-ci' | 'hosted-manifest';
|
||||||
|
bucketUrl?: string;
|
||||||
|
kernelKey?: string;
|
||||||
|
rootfsKey?: string;
|
||||||
|
manifestUrl?: string;
|
||||||
|
manifestPath?: string;
|
||||||
|
kernelUrl?: string;
|
||||||
|
rootfsUrl?: string;
|
||||||
|
kernelSourcePath?: string;
|
||||||
|
rootfsSourcePath?: string;
|
||||||
|
};
|
||||||
|
checksums?: {
|
||||||
|
kernelSha256?: string;
|
||||||
|
rootfsSha256?: string;
|
||||||
|
};
|
||||||
|
sizes?: {
|
||||||
|
kernelBytes?: number;
|
||||||
|
rootfsBytes?: number;
|
||||||
|
};
|
||||||
|
createdAt: string;
|
||||||
|
lastAccessedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime behavior for a MicroVM instance.
|
||||||
|
*/
|
||||||
|
export interface IMicroVMRuntimeOptions {
|
||||||
|
/** Directory for VM sockets and ephemeral per-VM files. */
|
||||||
|
runtimeDir?: string;
|
||||||
|
/** Copy writable drives into runtimeDir before boot and delete them on cleanup. Defaults to true. */
|
||||||
|
ephemeralWritableDrives?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,6 +219,8 @@ export interface IDriveConfig {
|
|||||||
rateLimiter?: IRateLimiter;
|
rateLimiter?: IRateLimiter;
|
||||||
/** Path to a file that backs the device for I/O. */
|
/** Path to a file that backs the device for I/O. */
|
||||||
ioEngine?: string;
|
ioEngine?: string;
|
||||||
|
/** Whether this drive should be staged into per-VM ephemeral storage. Defaults to true for writable drives. */
|
||||||
|
ephemeral?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -142,9 +279,9 @@ export interface ILoggerConfig {
|
|||||||
logPath: string;
|
logPath: string;
|
||||||
/** Log level. */
|
/** Log level. */
|
||||||
level?: TLogLevel;
|
level?: TLogLevel;
|
||||||
/** Whether to show log origin (file, line). */
|
|
||||||
showLevel?: boolean;
|
|
||||||
/** Whether to show log level. */
|
/** Whether to show log level. */
|
||||||
|
showLevel?: boolean;
|
||||||
|
/** Whether to show log origin (file, line). */
|
||||||
showLogOrigin?: boolean;
|
showLogOrigin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -2,8 +2,9 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
export { fs, path, os };
|
export { fs, path, os, crypto };
|
||||||
|
|
||||||
// @push.rocks scope
|
// @push.rocks scope
|
||||||
import * as smartdelay from '@push.rocks/smartdelay';
|
import * as smartdelay from '@push.rocks/smartdelay';
|
||||||
|
|||||||
+3
-1
@@ -3,8 +3,10 @@
|
|||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
|
"noImplicitAny": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"verbatimModuleSyntax": true
|
"verbatimModuleSyntax": true,
|
||||||
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"dist_*/**/*.d.ts"
|
"dist_*/**/*.d.ts"
|
||||||
|
|||||||
Reference in New Issue
Block a user