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