feat(base-images): add managed base image bundles with cache retention, hosted manifests, and opt-in integration boot testing

This commit is contained in:
2026-05-01 13:30:51 +00:00
parent 0ace928886
commit 9d0a57c5de
19 changed files with 2015 additions and 148 deletions
+1 -1
View File
@@ -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'
}
+713
View File
@@ -0,0 +1,713 @@
import * as plugins from './plugins.js';
import type {
IBaseImageArtifactManifest,
IBaseImageBundle,
IBaseImageHostedManifest,
IBaseImageManagerOptions,
IEnsureBaseImageOptions,
TBaseImagePreset,
TBaseImageRootfsType,
TFirecrackerArch,
} from './interfaces/index.js';
import { SmartVMError } from './interfaces/index.js';
const FIRECRACKER_CI_BUCKET_URL = 'https://s3.amazonaws.com/spec.ccfc.min';
const DEFAULT_MAX_STORED_BASE_IMAGES = 2;
const LTS_CI_VERSION = 'v1.7';
const LTS_FIRECRACKER_VERSION = 'v1.7.0';
type TShellExecResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawn']>>;
interface IResolvedBaseImageSource {
preset: TBaseImagePreset;
arch: TFirecrackerArch;
ciVersion: string;
firecrackerVersion: string;
kernelKey?: string;
rootfsKey?: string;
kernelUrl?: string;
rootfsUrl?: string;
kernelSourcePath?: string;
rootfsSourcePath?: string;
kernelFileName?: string;
rootfsFileName?: string;
expectedKernelSha256?: string;
expectedRootfsSha256?: string;
expectedKernelBytes?: number;
expectedRootfsBytes?: number;
rootfsType: TBaseImageRootfsType;
rootfsIsReadOnly: boolean;
bundleId: string;
bootArgs: string;
source: IBaseImageBundle['source'];
}
function getErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/**
* Downloads and retains Firecracker CI base images for integration testing.
*/
export class BaseImageManager {
private arch: TFirecrackerArch;
private cacheDir: string;
private maxStoredBaseImages: number;
private hostedManifestUrl?: string;
private hostedManifestPath?: string;
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
constructor(options: IBaseImageManagerOptions = {}) {
this.arch = options.arch || 'x86_64';
this.cacheDir = options.cacheDir || plugins.path.join(plugins.os.tmpdir(), '.smartvm', 'base-images');
this.maxStoredBaseImages = options.maxStoredBaseImages ?? DEFAULT_MAX_STORED_BASE_IMAGES;
this.hostedManifestUrl = options.hostedManifestUrl;
this.hostedManifestPath = options.hostedManifestPath;
if (!Number.isInteger(this.maxStoredBaseImages) || this.maxStoredBaseImages < 1) {
throw new SmartVMError(
'maxStoredBaseImages must be a positive integer',
'INVALID_BASE_IMAGE_CACHE_LIMIT',
);
}
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
}
public getCacheDir(): string {
return this.cacheDir;
}
public getMaxStoredBaseImages(): number {
return this.maxStoredBaseImages;
}
/**
* Ensure a base image bundle exists locally and return its paths.
*/
public async ensureBaseImage(options: IEnsureBaseImageOptions = {}): Promise<IBaseImageBundle> {
const source = await this.resolveBaseImageSource(options);
const bundleDir = plugins.path.join(this.cacheDir, source.bundleId);
const manifestPath = this.getManifestPath(bundleDir);
const cachedBundle = options.forceDownload ? undefined : await this.readCompleteBundle(bundleDir);
if (cachedBundle) {
const updatedBundle = {
...cachedBundle,
lastAccessedAt: new Date().toISOString(),
};
await this.writeBundleManifest(updatedBundle);
await this.pruneBaseImageCache(updatedBundle.bundleId);
return updatedBundle;
}
await plugins.fs.promises.mkdir(bundleDir, { recursive: true });
const kernelFileName = source.kernelFileName || this.getSourceFileName(source.kernelUrl || source.kernelSourcePath || source.kernelKey!, 'vmlinux');
const rootfsFileName = source.rootfsFileName || this.getSourceFileName(source.rootfsUrl || source.rootfsSourcePath || source.rootfsKey!, `rootfs.${source.rootfsType}`);
const kernelPath = this.resolveBundleFilePath(bundleDir, kernelFileName);
const rootfsPath = this.resolveBundleFilePath(bundleDir, rootfsFileName);
try {
await this.prepareArtifact({
url: source.kernelUrl || (source.kernelKey ? this.keyToUrl(source.kernelKey) : undefined),
sourcePath: source.kernelSourcePath,
targetPath: kernelPath,
expectedSha256: source.expectedKernelSha256,
expectedBytes: source.expectedKernelBytes,
});
await this.prepareArtifact({
url: source.rootfsUrl || (source.rootfsKey ? this.keyToUrl(source.rootfsKey) : undefined),
sourcePath: source.rootfsSourcePath,
targetPath: rootfsPath,
expectedSha256: source.expectedRootfsSha256,
expectedBytes: source.expectedRootfsBytes,
});
const now = new Date().toISOString();
const bundle: IBaseImageBundle = {
preset: source.preset,
arch: source.arch,
ciVersion: source.ciVersion,
firecrackerVersion: source.firecrackerVersion,
bundleId: source.bundleId,
bundleDir,
kernelImagePath: kernelPath,
rootfsPath,
rootfsType: source.rootfsType,
rootfsIsReadOnly: source.rootfsIsReadOnly,
bootArgs: source.bootArgs,
source: source.source,
checksums: {
kernelSha256: await this.sha256File(kernelPath),
rootfsSha256: await this.sha256File(rootfsPath),
},
sizes: {
kernelBytes: (await plugins.fs.promises.stat(kernelPath)).size,
rootfsBytes: (await plugins.fs.promises.stat(rootfsPath)).size,
},
createdAt: now,
lastAccessedAt: now,
};
await this.writeBundleManifest(bundle);
await this.pruneBaseImageCache(bundle.bundleId);
return bundle;
} catch (err) {
await plugins.fs.promises.rm(bundleDir, { recursive: true, force: true });
throw new SmartVMError(
`Failed to prepare base image bundle ${source.bundleId}: ${getErrorMessage(err)}`,
'BASE_IMAGE_PREPARE_FAILED',
);
}
}
/**
* Prune cached base image bundles according to the retention limit.
*/
public async pruneBaseImageCache(keepBundleId?: string): Promise<string[]> {
await plugins.fs.promises.mkdir(this.cacheDir, { recursive: true });
const bundles = await this.listCachedBundles();
bundles.sort((a, b) => {
if (keepBundleId) {
if (a.bundleId === keepBundleId) return -1;
if (b.bundleId === keepBundleId) return 1;
}
return Date.parse(b.lastAccessedAt) - Date.parse(a.lastAccessedAt);
});
const evicted: string[] = [];
for (const bundle of bundles.slice(this.maxStoredBaseImages)) {
console.warn(
`[smartvm] Base image cache stores at most ${this.maxStoredBaseImages} bundle(s). ` +
`Evicting ${bundle.bundleId} from ${bundle.bundleDir}. Configure maxStoredBaseImages to change this behavior.`,
);
await plugins.fs.promises.rm(bundle.bundleDir, { recursive: true, force: true });
evicted.push(bundle.bundleId);
}
return evicted;
}
private async resolveBaseImageSource(options: IEnsureBaseImageOptions): Promise<IResolvedBaseImageSource> {
const arch = options.arch || this.arch;
const manifestUrl = options.manifestUrl || this.hostedManifestUrl;
const manifestPath = options.manifestPath || this.hostedManifestPath;
if (manifestUrl || manifestPath) {
return this.resolveHostedManifestSource({ arch, manifestUrl, manifestPath });
}
const preset = options.preset || 'latest';
if (preset === 'hosted') {
throw new SmartVMError(
'The hosted base image preset requires manifestUrl, manifestPath, or a manager-level hosted manifest option',
'BASE_IMAGE_MANIFEST_FAILED',
);
}
const firecrackerVersion = preset === 'latest'
? await this.getLatestFirecrackerVersion()
: LTS_FIRECRACKER_VERSION;
const ciVersion = preset === 'latest'
? firecrackerVersion.split('.').slice(0, 2).join('.')
: LTS_CI_VERSION;
const keys = await this.listCiKeys(ciVersion, arch);
const kernelKey = this.selectKernelKey(keys);
const rootfsKey = this.selectRootfsKey(keys);
const rootfsType = rootfsKey.endsWith('.ext4') ? 'ext4' : 'squashfs';
const bundleId = this.buildBundleId(preset, ciVersion, arch, kernelKey, rootfsKey);
return {
preset,
arch,
ciVersion,
firecrackerVersion,
kernelKey,
rootfsKey,
rootfsType,
rootfsIsReadOnly: rootfsType === 'squashfs',
bundleId,
bootArgs: this.buildBootArgs(arch, rootfsType),
source: {
type: 'firecracker-ci',
bucketUrl: FIRECRACKER_CI_BUCKET_URL,
kernelKey,
rootfsKey,
},
};
}
private async resolveHostedManifestSource(options: {
arch: TFirecrackerArch;
manifestUrl?: string;
manifestPath?: string;
}): Promise<IResolvedBaseImageSource> {
const manifest = await this.loadHostedManifest(options);
this.validateHostedManifest(manifest, options.arch);
this.getArtifactSource(manifest.kernel, 'kernel');
this.getArtifactSource(manifest.rootfs, 'rootfs');
return {
preset: 'hosted',
arch: manifest.arch,
ciVersion: 'hosted',
firecrackerVersion: manifest.firecrackerVersion,
kernelUrl: manifest.kernel.url,
rootfsUrl: manifest.rootfs.url,
kernelSourcePath: manifest.kernel.path,
rootfsSourcePath: manifest.rootfs.path,
kernelFileName: manifest.kernel.fileName,
rootfsFileName: manifest.rootfs.fileName,
expectedKernelSha256: manifest.kernel.sha256,
expectedRootfsSha256: manifest.rootfs.sha256,
expectedKernelBytes: manifest.kernel.sizeBytes,
expectedRootfsBytes: manifest.rootfs.sizeBytes,
rootfsType: manifest.rootfsType,
rootfsIsReadOnly: manifest.rootfsIsReadOnly ?? manifest.rootfsType === 'squashfs',
bundleId: this.sanitizeBundleId(manifest.bundleId),
bootArgs: manifest.bootArgs || this.buildBootArgs(manifest.arch, manifest.rootfsType),
source: {
type: 'hosted-manifest',
manifestUrl: options.manifestUrl,
manifestPath: options.manifestPath,
kernelUrl: manifest.kernel.url,
rootfsUrl: manifest.rootfs.url,
kernelSourcePath: manifest.kernel.path,
rootfsSourcePath: manifest.rootfs.path,
},
};
}
private async getLatestFirecrackerVersion(): Promise<string> {
try {
const result = await this.shell.execSpawn('curl', [
'-fsSLI',
'-o',
'/dev/null',
'-w',
'%{url_effective}',
'https://github.com/firecracker-microvm/firecracker/releases/latest',
], { silent: true });
if (result.exitCode !== 0) {
const output = (result.stderr || result.stdout || '').trim();
throw new Error(`curl exited with code ${result.exitCode}${output ? `: ${output}` : ''}`);
}
const match = result.stdout.trim().match(/\/releases\/tag\/(v\d+\.\d+\.\d+)$/);
if (!match) {
throw new Error(`Could not parse latest Firecracker release URL: ${result.stdout.trim()}`);
}
return match[1];
} catch (err) {
throw new SmartVMError(
`Failed to resolve latest Firecracker version: ${getErrorMessage(err)}`,
'VERSION_FETCH_FAILED',
);
}
}
private async loadHostedManifest(options: {
manifestUrl?: string;
manifestPath?: string;
}): Promise<IBaseImageHostedManifest> {
try {
let raw: string;
if (options.manifestPath) {
raw = await plugins.fs.promises.readFile(options.manifestPath, 'utf8');
} else if (options.manifestUrl) {
const response = await plugins.SmartRequest.create()
.url(options.manifestUrl)
.get();
raw = await response.text();
} else {
throw new Error('manifestUrl or manifestPath is required');
}
return JSON.parse(raw) as IBaseImageHostedManifest;
} catch (err) {
throw new SmartVMError(
`Failed to load hosted base image manifest: ${getErrorMessage(err)}`,
'BASE_IMAGE_MANIFEST_FAILED',
);
}
}
private validateHostedManifest(manifest: IBaseImageHostedManifest, expectedArch: TFirecrackerArch): void {
if (manifest.schemaVersion !== 1) {
throw new SmartVMError(
'Hosted base image manifest schemaVersion must be 1',
'INVALID_BASE_IMAGE_MANIFEST',
);
}
if (!manifest.bundleId || !/^[a-zA-Z0-9._-]+$/.test(manifest.bundleId)) {
throw new SmartVMError(
'Hosted base image manifest bundleId must use only letters, numbers, dot, underscore, and dash',
'INVALID_BASE_IMAGE_MANIFEST',
);
}
if (manifest.arch !== expectedArch) {
throw new SmartVMError(
`Hosted base image arch '${manifest.arch}' does not match requested arch '${expectedArch}'`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
if (!manifest.firecrackerVersion || !/^v\d+\.\d+\.\d+$/.test(manifest.firecrackerVersion)) {
throw new SmartVMError(
'Hosted base image manifest firecrackerVersion must look like v1.15.1',
'INVALID_BASE_IMAGE_MANIFEST',
);
}
if (manifest.rootfsType !== 'ext4' && manifest.rootfsType !== 'squashfs') {
throw new SmartVMError(
'Hosted base image manifest rootfsType must be ext4 or squashfs',
'INVALID_BASE_IMAGE_MANIFEST',
);
}
this.validateArtifactManifest(manifest.kernel, 'kernel');
this.validateArtifactManifest(manifest.rootfs, 'rootfs');
}
private validateArtifactManifest(artifact: IBaseImageArtifactManifest, label: string): void {
this.getArtifactSource(artifact, label);
if (artifact.fileName !== undefined) {
this.validateArtifactFileName(artifact.fileName, label);
}
if (artifact.url && !artifact.sha256) {
throw new SmartVMError(
`Hosted base image manifest ${label} artifact with url requires sha256`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
if (artifact.sha256 !== undefined && !/^[a-fA-F0-9]{64}$/.test(artifact.sha256)) {
throw new SmartVMError(
`Hosted base image manifest ${label} artifact sha256 must be a 64 character hex string`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
if (
artifact.sizeBytes !== undefined &&
(!Number.isInteger(artifact.sizeBytes) || artifact.sizeBytes < 0)
) {
throw new SmartVMError(
`Hosted base image manifest ${label} artifact sizeBytes must be a non-negative integer`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
}
private validateArtifactFileName(fileName: string, label: string): void {
if (
!fileName ||
fileName === '.' ||
fileName === '..' ||
fileName !== plugins.path.basename(fileName) ||
!/^[a-zA-Z0-9._-]+$/.test(fileName)
) {
throw new SmartVMError(
`Hosted base image manifest ${label} artifact fileName must be a plain file name using letters, numbers, dot, underscore, and dash`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
}
private getArtifactSource(artifact: { url?: string; path?: string }, label: string): string {
if (!artifact.url && !artifact.path) {
throw new SmartVMError(
`Hosted base image manifest ${label} artifact requires url or path`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
if (artifact.url && artifact.path) {
throw new SmartVMError(
`Hosted base image manifest ${label} artifact must not set both url and path`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
return artifact.url || artifact.path!;
}
private getSourceFileName(source: string, fallback: string): string {
let fileName: string;
try {
fileName = plugins.path.basename(new URL(source).pathname);
} catch {
fileName = plugins.path.basename(source);
}
return this.sanitizeFileName(fileName || fallback);
}
private resolveBundleFilePath(bundleDir: string, fileName: string): string {
const resolvedBundleDir = plugins.path.resolve(bundleDir);
const resolvedFilePath = plugins.path.resolve(resolvedBundleDir, this.sanitizeFileName(fileName));
if (!this.isPathInside(resolvedBundleDir, resolvedFilePath)) {
throw new SmartVMError(
`Resolved base image artifact path escapes bundle directory: ${fileName}`,
'INVALID_BASE_IMAGE_MANIFEST',
);
}
return resolvedFilePath;
}
private sanitizeFileName(fileName: string): string {
const sanitized = plugins.path.basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '_');
if (!sanitized || sanitized === '.' || sanitized === '..') {
return 'artifact';
}
return sanitized;
}
private sanitizeBundleId(bundleId: string): string {
return bundleId.replace(/[^a-zA-Z0-9._-]/g, '_');
}
private async listCiKeys(ciVersion: string, arch: TFirecrackerArch): Promise<string[]> {
const prefix = `firecracker-ci/${ciVersion}/${arch}/`;
try {
const response = await plugins.SmartRequest.create()
.url(`${FIRECRACKER_CI_BUCKET_URL}/?prefix=${encodeURIComponent(prefix)}&list-type=2`)
.get();
const body = await response.text();
const keys = Array.from(body.matchAll(/<Key>([^<]+)<\/Key>/g)).map((match) => this.decodeXml(match[1]));
if (keys.length === 0) {
throw new Error(`No Firecracker CI artifacts found for ${ciVersion}/${arch}`);
}
return keys;
} catch (err) {
throw new SmartVMError(
`Failed to list Firecracker CI artifacts for ${ciVersion}/${arch}: ${getErrorMessage(err)}`,
'BASE_IMAGE_RESOLVE_FAILED',
);
}
}
private selectKernelKey(keys: string[]): string {
const kernelKeys = keys.filter((key) => /\/vmlinux-\d+\.\d+\.\d+$/.test(key) && !key.includes('/debug/'));
if (kernelKeys.length === 0) {
throw new SmartVMError('No suitable Firecracker CI kernel image found', 'BASE_IMAGE_RESOLVE_FAILED');
}
return kernelKeys.sort((a, b) => this.compareKernelKeys(a, b)).at(-1)!;
}
private selectRootfsKey(keys: string[]): string {
const ext4Keys = keys.filter((key) => /\/ubuntu-[^/]+\.ext4$/.test(key));
if (ext4Keys.length > 0) {
return ext4Keys.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1)!;
}
const squashfsKeys = keys.filter((key) => /\/ubuntu-[^/]+\.squashfs$/.test(key));
if (squashfsKeys.length > 0) {
return squashfsKeys.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).at(-1)!;
}
throw new SmartVMError('No suitable Firecracker CI rootfs image found', 'BASE_IMAGE_RESOLVE_FAILED');
}
private compareKernelKeys(a: string, b: string): number {
const aParts = this.extractKernelVersion(a);
const bParts = this.extractKernelVersion(b);
for (let i = 0; i < 3; i++) {
if (aParts[i] !== bParts[i]) {
return aParts[i] - bParts[i];
}
}
return a.localeCompare(b);
}
private extractKernelVersion(key: string): [number, number, number] {
const match = key.match(/vmlinux-(\d+)\.(\d+)\.(\d+)$/);
if (!match) {
return [0, 0, 0];
}
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
private buildBundleId(
preset: TBaseImagePreset,
ciVersion: string,
arch: TFirecrackerArch,
kernelKey: string,
rootfsKey: string,
): string {
const rawId = [
preset,
ciVersion,
arch,
plugins.path.basename(kernelKey),
plugins.path.basename(rootfsKey),
].join('-');
return this.sanitizeBundleId(rawId);
}
private buildBootArgs(arch: TFirecrackerArch, rootfsType: TBaseImageRootfsType): string {
const args = ['console=ttyS0', 'reboot=k', 'panic=1', 'pci=off'];
if (arch === 'aarch64') {
args.unshift('keep_bootcon');
}
if (rootfsType === 'squashfs') {
args.push('ro', 'rootfstype=squashfs');
}
return args.join(' ');
}
private keyToUrl(key: string): string {
return `${FIRECRACKER_CI_BUCKET_URL}/${key}`;
}
private async prepareArtifact(options: {
url?: string;
sourcePath?: string;
targetPath: string;
expectedSha256?: string;
expectedBytes?: number;
}): Promise<void> {
if (options.sourcePath) {
await plugins.fs.promises.copyFile(options.sourcePath, options.targetPath);
} else if (options.url) {
await this.downloadFile(options.url, options.targetPath);
} else {
throw new Error('Artifact requires url or sourcePath');
}
const stat = await plugins.fs.promises.stat(options.targetPath);
if (options.expectedBytes !== undefined && stat.size !== options.expectedBytes) {
throw new Error(
`Artifact ${options.targetPath} size mismatch: expected ${options.expectedBytes}, got ${stat.size}`,
);
}
if (options.expectedSha256) {
const actualSha256 = await this.sha256File(options.targetPath);
if (actualSha256.toLowerCase() !== options.expectedSha256.toLowerCase()) {
throw new Error(
`Artifact ${options.targetPath} SHA256 mismatch: expected ${options.expectedSha256}, got ${actualSha256}`,
);
}
}
}
private async downloadFile(url: string, targetPath: string): Promise<TShellExecResult> {
await plugins.fs.promises.mkdir(plugins.path.dirname(targetPath), { recursive: true });
const tempPath = `${targetPath}.download`;
await plugins.fs.promises.rm(tempPath, { force: true });
const result = await this.shell.execSpawn('curl', ['-fSL', '-o', tempPath, url], { silent: true });
if (result.exitCode !== 0) {
const output = (result.stderr || result.stdout || '').trim();
throw new Error(`curl failed for ${url} with code ${result.exitCode}${output ? `: ${output}` : ''}`);
}
await plugins.fs.promises.rename(tempPath, targetPath);
return result;
}
private async sha256File(filePath: string): Promise<string> {
const hash = plugins.crypto.createHash('sha256');
const stream = plugins.fs.createReadStream(filePath);
for await (const chunk of stream) {
hash.update(chunk);
}
return hash.digest('hex');
}
private async readCompleteBundle(bundleDir: string): Promise<IBaseImageBundle | undefined> {
const manifestPath = this.getManifestPath(bundleDir);
try {
const bundle = {
...await this.readBundleManifest(manifestPath),
bundleDir,
};
await this.verifyCachedBundle(bundle);
return bundle;
} catch {
return undefined;
}
}
private async verifyCachedBundle(bundle: IBaseImageBundle): Promise<void> {
if (!this.isPathInside(bundle.bundleDir, bundle.kernelImagePath)) {
throw new Error(`Cached kernel path escapes bundle directory: ${bundle.kernelImagePath}`);
}
if (!this.isPathInside(bundle.bundleDir, bundle.rootfsPath)) {
throw new Error(`Cached rootfs path escapes bundle directory: ${bundle.rootfsPath}`);
}
if (!bundle.checksums?.kernelSha256 || !bundle.checksums?.rootfsSha256) {
throw new Error(`Cached bundle ${bundle.bundleId} is missing checksums`);
}
if (bundle.sizes?.kernelBytes === undefined || bundle.sizes.rootfsBytes === undefined) {
throw new Error(`Cached bundle ${bundle.bundleId} is missing sizes`);
}
const [kernelStat, rootfsStat] = await Promise.all([
plugins.fs.promises.stat(bundle.kernelImagePath),
plugins.fs.promises.stat(bundle.rootfsPath),
]);
if (kernelStat.size !== bundle.sizes.kernelBytes) {
throw new Error(`Cached kernel size mismatch for bundle ${bundle.bundleId}`);
}
if (rootfsStat.size !== bundle.sizes.rootfsBytes) {
throw new Error(`Cached rootfs size mismatch for bundle ${bundle.bundleId}`);
}
const [kernelSha256, rootfsSha256] = await Promise.all([
this.sha256File(bundle.kernelImagePath),
this.sha256File(bundle.rootfsPath),
]);
if (kernelSha256.toLowerCase() !== bundle.checksums.kernelSha256.toLowerCase()) {
throw new Error(`Cached kernel SHA256 mismatch for bundle ${bundle.bundleId}`);
}
if (rootfsSha256.toLowerCase() !== bundle.checksums.rootfsSha256.toLowerCase()) {
throw new Error(`Cached rootfs SHA256 mismatch for bundle ${bundle.bundleId}`);
}
}
private isPathInside(baseDir: string, candidatePath: string): boolean {
const resolvedBase = plugins.path.resolve(baseDir);
const resolvedCandidate = plugins.path.resolve(candidatePath);
return resolvedCandidate === resolvedBase || resolvedCandidate.startsWith(`${resolvedBase}${plugins.path.sep}`);
}
private getManifestPath(bundleDir: string): string {
return plugins.path.join(bundleDir, 'manifest.json');
}
private async readBundleManifest(manifestPath: string): Promise<IBaseImageBundle> {
const raw = await plugins.fs.promises.readFile(manifestPath, 'utf8');
return JSON.parse(raw) as IBaseImageBundle;
}
private async writeBundleManifest(bundle: IBaseImageBundle): Promise<void> {
await plugins.fs.promises.mkdir(bundle.bundleDir, { recursive: true });
await plugins.fs.promises.writeFile(
this.getManifestPath(bundle.bundleDir),
`${JSON.stringify(bundle, null, 2)}\n`,
);
}
private async listCachedBundles(): Promise<IBaseImageBundle[]> {
let entries: string[];
try {
entries = await plugins.fs.promises.readdir(this.cacheDir);
} catch {
return [];
}
const bundles: IBaseImageBundle[] = [];
for (const entry of entries) {
const bundleDir = plugins.path.join(this.cacheDir, entry);
try {
const stat = await plugins.fs.promises.stat(bundleDir);
if (!stat.isDirectory()) {
continue;
}
const bundle = await this.readBundleManifest(this.getManifestPath(bundleDir));
bundles.push({
...bundle,
bundleDir,
});
} catch {
// Ignore incomplete cache entries.
}
}
return bundles;
}
private decodeXml(value: string): string {
return value
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
}
+70 -17
View File
@@ -3,14 +3,23 @@ import type { IFirecrackerProcessOptions } from './interfaces/index.js';
import { SmartVMError } from './interfaces/index.js';
import { SocketClient } from './classes.socketclient.js';
type TStreamingResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawnStreaming']>>;
type TExecResult = Awaited<TStreamingResult['finalPromise']>;
function getErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/**
* Manages a single Firecracker child process, including startup, readiness polling, and shutdown.
*/
export class FirecrackerProcess {
private options: IFirecrackerProcessOptions;
private streaming: any | null = null;
private streaming: TStreamingResult | null = null;
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
private smartExitInstance: InstanceType<typeof plugins.smartexit.SmartExit> | null = null;
private lastExitResult: TExecResult | null = null;
private lastExitError: string | null = null;
public socketClient: SocketClient;
constructor(options: IFirecrackerProcessOptions) {
@@ -28,14 +37,21 @@ export class FirecrackerProcess {
plugins.fs.unlinkSync(this.options.socketPath);
}
// Build the command
let cmd = `${this.options.binaryPath} --api-sock ${this.options.socketPath}`;
// Build the command args without a shell so paths are not interpreted.
const args = ['--api-sock', this.options.socketPath];
if (this.options.logLevel) {
cmd += ` --level ${this.options.logLevel}`;
args.push('--level', this.options.logLevel);
}
// Spawn the process
this.streaming = await this.shell.execStreaming(cmd, true);
this.streaming = await this.shell.execSpawnStreaming(this.options.binaryPath, args, { silent: true });
this.streaming.finalPromise
.then((result) => {
this.lastExitResult = result;
})
.catch((err) => {
this.lastExitError = getErrorMessage(err);
});
// Register with smartexit for automatic cleanup
if (this.streaming?.childProcess) {
@@ -46,9 +62,11 @@ export class FirecrackerProcess {
// Wait for the socket file to appear
const socketReady = await this.waitForSocket(10000);
if (!socketReady) {
const wasRunning = this.isRunning();
const diagnostics = this.formatDiagnostics();
await this.stop();
throw new SmartVMError(
'Firecracker socket did not become ready within timeout',
`Firecracker socket did not become ready within timeout${diagnostics || (wasRunning ? '' : this.formatDiagnostics())}`,
'SOCKET_TIMEOUT',
);
}
@@ -56,9 +74,10 @@ export class FirecrackerProcess {
// Wait for the API to be responsive
const apiReady = await this.socketClient.isReady(5000);
if (!apiReady) {
const diagnostics = this.formatDiagnostics();
await this.stop();
throw new SmartVMError(
'Firecracker API did not become responsive within timeout',
`Firecracker API did not become responsive within timeout${diagnostics}`,
'API_TIMEOUT',
);
}
@@ -73,36 +92,69 @@ export class FirecrackerProcess {
if (plugins.fs.existsSync(this.options.socketPath)) {
return true;
}
if (this.streaming && !this.isRunning()) {
return false;
}
await plugins.smartdelay.delayFor(100);
}
return false;
}
private async waitForExit(streaming: TStreamingResult, timeoutMs: number): Promise<boolean> {
return Promise.race([
streaming.finalPromise.then((result) => {
this.lastExitResult = result;
return true;
}).catch((err) => {
this.lastExitError = getErrorMessage(err);
return true;
}),
plugins.smartdelay.delayFor(timeoutMs).then(() => false),
]);
}
private formatDiagnostics(): string {
if (this.lastExitError) {
return `: ${this.lastExitError}`;
}
if (this.lastExitResult) {
const output = (this.lastExitResult.stderr || this.lastExitResult.stdout || '').trim();
return `: process exited with code ${this.lastExitResult.exitCode}${output ? `: ${output}` : ''}`;
}
return '';
}
/**
* Gracefully stop the Firecracker process with SIGTERM, then SIGKILL after timeout.
*/
public async stop(): Promise<void> {
if (!this.streaming) return;
const streaming = this.streaming;
if (!streaming) return;
try {
// Try graceful termination first
await this.streaming.terminate();
await streaming.terminate();
// Wait up to 5 seconds for the process to exit
const exitPromise = Promise.race([
this.streaming.finalPromise,
plugins.smartdelay.delayFor(5000),
]);
await exitPromise;
const terminated = await this.waitForExit(streaming, 5000);
if (!terminated) {
await streaming.kill();
await this.waitForExit(streaming, 1000);
}
} catch {
// If termination fails, force kill
try {
await this.streaming.kill();
await streaming.kill();
await this.waitForExit(streaming, 1000);
} catch {
// Process may already be dead
}
}
if (this.smartExitInstance) {
this.smartExitInstance.removeProcess(streaming.childProcess);
this.smartExitInstance = null;
}
this.streaming = null;
}
@@ -122,10 +174,11 @@ export class FirecrackerProcess {
* Check if the process is currently running.
*/
public isRunning(): boolean {
if (!this.streaming?.childProcess) return false;
const pid = this.streaming?.childProcess?.pid;
if (!pid) return false;
try {
// Sending signal 0 tests if process exists without actually sending a signal
process.kill(this.streaming.childProcess.pid, 0);
process.kill(pid, 0);
return true;
} catch {
return false;
+51 -27
View File
@@ -2,6 +2,12 @@ import * as plugins from './plugins.js';
import type { TFirecrackerArch } from './interfaces/index.js';
import { SmartVMError } from './interfaces/index.js';
type TShellExecResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawn']>>;
function getErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/**
* Helper to check if a file or directory exists.
*/
@@ -21,10 +27,21 @@ async function pathExists(filePath: string): Promise<boolean> {
export class ImageManager {
private dataDir: string;
private arch: TFirecrackerArch;
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
constructor(dataDir: string, arch: TFirecrackerArch = 'x86_64') {
this.dataDir = dataDir;
this.arch = arch;
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
}
private async runChecked(command: string, args: string[]): Promise<TShellExecResult> {
const result = await this.shell.execSpawn(command, args, { silent: true });
if (result.exitCode !== 0) {
const output = (result.stderr || result.stdout || '').trim();
throw new Error(`${command} ${args.join(' ')} exited with code ${result.exitCode}${output ? `: ${output}` : ''}`);
}
return result;
}
/**
@@ -89,14 +106,22 @@ export class ImageManager {
*/
public async getLatestVersion(): Promise<string> {
try {
const response = await plugins.SmartRequest.create()
.url('https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest')
.get();
const data = await response.json() as { tag_name: string };
return data.tag_name;
const result = await this.runChecked('curl', [
'-fsSLI',
'-o',
'/dev/null',
'-w',
'%{url_effective}',
'https://github.com/firecracker-microvm/firecracker/releases/latest',
]);
const match = result.stdout.trim().match(/\/releases\/tag\/(v\d+\.\d+\.\d+)$/);
if (!match) {
throw new Error(`Could not parse latest Firecracker release URL: ${result.stdout.trim()}`);
}
return match[1];
} catch (err) {
throw new SmartVMError(
`Failed to fetch latest Firecracker version: ${(err as Error).message}`,
`Failed to fetch latest Firecracker version: ${getErrorMessage(err)}`,
'VERSION_FETCH_FAILED',
);
}
@@ -119,11 +144,10 @@ export class ImageManager {
try {
// Download the archive
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
await shell.exec(`curl -fSL -o "${archivePath}" "${downloadUrl}"`);
await this.runChecked('curl', ['-fSL', '-o', archivePath, downloadUrl]);
// Extract the archive
await shell.exec(`tar -xzf "${archivePath}" -C "${targetDir}"`);
await this.runChecked('tar', ['-xzf', archivePath, '-C', targetDir]);
// Firecracker archives contain a directory like release-v1.5.0-x86_64/
// with binaries named like firecracker-v1.5.0-x86_64
@@ -134,21 +158,25 @@ export class ImageManager {
const jailerDst = this.getJailerPath(version);
// Move binaries to expected paths
await shell.exec(`mv "${firecrackerSrc}" "${firecrackerDst}"`);
await plugins.fs.promises.rename(firecrackerSrc, firecrackerDst);
if (await pathExists(jailerSrc)) {
await shell.exec(`mv "${jailerSrc}" "${jailerDst}"`);
await plugins.fs.promises.rename(jailerSrc, jailerDst);
}
// Make executable
await shell.exec(`chmod +x "${firecrackerDst}"`);
await plugins.fs.promises.chmod(firecrackerDst, 0o755);
if (await pathExists(jailerDst)) {
await plugins.fs.promises.chmod(jailerDst, 0o755);
}
// Clean up
await shell.exec(`rm -rf "${archivePath}" "${extractedDir}"`);
await plugins.fs.promises.rm(archivePath, { force: true });
await plugins.fs.promises.rm(extractedDir, { recursive: true, force: true });
return firecrackerDst;
} catch (err) {
throw new SmartVMError(
`Failed to download Firecracker ${version}: ${(err as Error).message}`,
`Failed to download Firecracker ${version}: ${getErrorMessage(err)}`,
'DOWNLOAD_FAILED',
);
}
@@ -163,12 +191,11 @@ export class ImageManager {
const kernelPath = plugins.path.join(kernelsDir, name);
try {
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
await shell.exec(`curl -fSL -o "${kernelPath}" "${url}"`);
await this.runChecked('curl', ['-fSL', '-o', kernelPath, url]);
return kernelPath;
} catch (err) {
throw new SmartVMError(
`Failed to download kernel from ${url}: ${(err as Error).message}`,
`Failed to download kernel from ${url}: ${getErrorMessage(err)}`,
'DOWNLOAD_FAILED',
);
}
@@ -183,12 +210,11 @@ export class ImageManager {
const rootfsPath = plugins.path.join(rootfsDir, name);
try {
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
await shell.exec(`curl -fSL -o "${rootfsPath}" "${url}"`);
await this.runChecked('curl', ['-fSL', '-o', rootfsPath, url]);
return rootfsPath;
} catch (err) {
throw new SmartVMError(
`Failed to download rootfs from ${url}: ${(err as Error).message}`,
`Failed to download rootfs from ${url}: ${getErrorMessage(err)}`,
'DOWNLOAD_FAILED',
);
}
@@ -203,13 +229,12 @@ export class ImageManager {
const rootfsPath = plugins.path.join(rootfsDir, name);
try {
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
await shell.exec(`dd if=/dev/zero of="${rootfsPath}" bs=1M count=${sizeMib}`);
await shell.exec(`mkfs.ext4 "${rootfsPath}"`);
await this.runChecked('dd', ['if=/dev/zero', `of=${rootfsPath}`, 'bs=1M', `count=${sizeMib}`]);
await this.runChecked('mkfs.ext4', [rootfsPath]);
return rootfsPath;
} catch (err) {
throw new SmartVMError(
`Failed to create blank rootfs: ${(err as Error).message}`,
`Failed to create blank rootfs: ${getErrorMessage(err)}`,
'ROOTFS_CREATE_FAILED',
);
}
@@ -224,12 +249,11 @@ export class ImageManager {
const targetPath = plugins.path.join(rootfsDir, targetName);
try {
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
await shell.exec(`cp "${sourcePath}" "${targetPath}"`);
await plugins.fs.promises.copyFile(sourcePath, targetPath);
return targetPath;
} catch (err) {
throw new SmartVMError(
`Failed to clone rootfs: ${(err as Error).message}`,
`Failed to clone rootfs: ${getErrorMessage(err)}`,
'ROOTFS_CLONE_FAILED',
);
}
+15 -11
View File
@@ -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<any> {
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<any> {
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<any> {
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<string, any>): Promise<void> {
if (!this.socketClient) {
throw new SmartVMError('Socket client not initialized', 'NO_CLIENT');
}
const response = await this.socketClient.put(path, body);
const response = await this.getSocketClient(`PUT ${path}`).put(path, body);
if (!response.ok) {
throw new SmartVMError(
`API PUT ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`,
@@ -352,10 +359,7 @@ export class MicroVM {
* Helper: PATCH request with error handling.
*/
private async apiPatch(path: string, body: Record<string, any>): Promise<void> {
if (!this.socketClient) {
throw new SmartVMError('Socket client not initialized', 'NO_CLIENT');
}
const response = await this.socketClient.patch(path, body);
const response = await this.getSocketClient(`PATCH ${path}`).patch(path, body);
if (!response.ok) {
throw new SmartVMError(
`API PATCH ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`,
+190 -46
View File
@@ -2,6 +2,15 @@ import * as plugins from './plugins.js';
import type { INetworkManagerOptions, ITapDevice } from './interfaces/index.js';
import { SmartVMError } from './interfaces/index.js';
type TShellExecResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawn']>>;
interface IParsedSubnet {
networkAddress: number;
broadcastAddress: number;
cidr: number;
subnetMask: string;
}
/**
* Manages host networking for Firecracker VMs.
* Creates TAP devices, Linux bridges, and configures NAT for VM internet access.
@@ -12,53 +21,121 @@ export class NetworkManager {
private subnetCidr: number;
private gatewayIp: string;
private subnetMask: string;
private nextIpOctet: number;
private nextIpAddress: number;
private lastUsableIpAddress: number;
private activeTaps: Map<string, ITapDevice> = new Map();
private bridgeCreated: boolean = false;
private defaultRouteInterface: string | null = null;
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
constructor(options: INetworkManagerOptions = {}) {
this.bridgeName = options.bridgeName || 'svbr0';
this.validateInterfaceName(this.bridgeName, 'bridgeName');
const subnet = options.subnet || '172.30.0.0/24';
const parsedSubnet = this.parseSubnet(subnet);
// Parse the subnet
const [baseIp, cidrStr] = subnet.split('/');
this.subnetBase = baseIp;
this.subnetCidr = parseInt(cidrStr, 10);
this.subnetMask = this.cidrToSubnetMask(this.subnetCidr);
// Gateway is .1 in the subnet
const parts = this.subnetBase.split('.').map(Number);
parts[3] = 1;
this.gatewayIp = parts.join('.');
// VMs start at .2
this.nextIpOctet = 2;
this.subnetBase = this.intToIp(parsedSubnet.networkAddress);
this.subnetCidr = parsedSubnet.cidr;
this.subnetMask = parsedSubnet.subnetMask;
this.gatewayIp = this.intToIp(parsedSubnet.networkAddress + 1);
this.nextIpAddress = parsedSubnet.networkAddress + 2;
this.lastUsableIpAddress = parsedSubnet.broadcastAddress - 1;
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
}
/**
* Convert a CIDR prefix length to a dotted-decimal subnet mask.
* Parse an IPv4 CIDR subnet and ensure there is room for a gateway and guests.
*/
private cidrToSubnetMask(cidr: number): string {
const mask = (0xffffffff << (32 - cidr)) >>> 0;
private parseSubnet(subnet: string): IParsedSubnet {
const [ip, cidrText, extra] = subnet.split('/');
const cidr = Number(cidrText);
if (!ip || !cidrText || extra !== undefined || !Number.isInteger(cidr) || cidr < 1 || cidr > 30) {
throw new SmartVMError(
`Invalid subnet '${subnet}': expected IPv4 CIDR with prefix length 1-30`,
'INVALID_SUBNET',
);
}
const ipAddress = this.ipToInt(ip);
const mask = this.cidrToMask(cidr);
const networkAddress = (ipAddress & mask) >>> 0;
const hostCount = 2 ** (32 - cidr);
const broadcastAddress = networkAddress + hostCount - 1;
if (hostCount < 4) {
throw new SmartVMError(
`Invalid subnet '${subnet}': at least two usable host addresses are required`,
'INVALID_SUBNET',
);
}
return {
networkAddress,
broadcastAddress,
cidr,
subnetMask: this.intToIp(mask),
};
}
private ipToInt(ip: string): number {
const octets = ip.split('.');
if (octets.length !== 4) {
throw new SmartVMError(`Invalid IPv4 address '${ip}'`, 'INVALID_SUBNET');
}
if (octets.some((octet) => !/^[0-9]+$/.test(octet))) {
throw new SmartVMError(`Invalid IPv4 address '${ip}'`, 'INVALID_SUBNET');
}
const numbers = octets.map((octet) => Number(octet));
if (numbers.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
throw new SmartVMError(`Invalid IPv4 address '${ip}'`, 'INVALID_SUBNET');
}
return (
numbers[0] * 256 ** 3 +
numbers[1] * 256 ** 2 +
numbers[2] * 256 +
numbers[3]
) >>> 0;
}
private intToIp(address: number): string {
return [
(mask >>> 24) & 0xff,
(mask >>> 16) & 0xff,
(mask >>> 8) & 0xff,
mask & 0xff,
Math.floor(address / 256 ** 3) % 256,
Math.floor(address / 256 ** 2) % 256,
Math.floor(address / 256) % 256,
address % 256,
].join('.');
}
private cidrToMask(cidr: number): number {
return (0xffffffff << (32 - cidr)) >>> 0;
}
private validateInterfaceName(name: string, fieldName: string): void {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,14}$/.test(name)) {
throw new SmartVMError(
`${fieldName} '${name}' is not a valid Linux interface name`,
'INVALID_INTERFACE_NAME',
);
}
}
/**
* Allocate the next available IP address in the subnet.
*/
public allocateIp(): string {
const parts = this.subnetBase.split('.').map(Number);
parts[3] = this.nextIpOctet;
this.nextIpOctet++;
return parts.join('.');
if (this.nextIpAddress > this.lastUsableIpAddress) {
throw new SmartVMError(
`Subnet ${this.subnetBase}/${this.subnetCidr} has no available guest IP addresses`,
'IP_EXHAUSTED',
);
}
const ip = this.intToIp(this.nextIpAddress);
this.nextIpAddress++;
return ip;
}
/**
@@ -102,6 +179,36 @@ export class NetworkManager {
return tapName.substring(0, 15);
}
private async run(command: string, args: string[]): Promise<TShellExecResult> {
return this.shell.execSpawn(command, args, { silent: true });
}
private async runChecked(command: string, args: string[]): Promise<TShellExecResult> {
const result = await this.run(command, args);
if (result.exitCode !== 0) {
const output = (result.stderr || result.stdout || '').trim();
throw new Error(`${command} ${args.join(' ')} exited with code ${result.exitCode}${output ? `: ${output}` : ''}`);
}
return result;
}
private async getDefaultRouteInterface(): Promise<string> {
if (this.defaultRouteInterface) {
return this.defaultRouteInterface;
}
const result = await this.runChecked('ip', ['route', 'show', 'default']);
const match = result.stdout.match(/\bdev\s+([^\s]+)/);
if (!match) {
throw new Error('Could not determine default route interface');
}
const iface = match[1];
this.validateInterfaceName(iface, 'default route interface');
this.defaultRouteInterface = iface;
return iface;
}
/**
* Ensure the Linux bridge is created and configured.
*/
@@ -110,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<void> {
this.validateInterfaceName(tapName, 'tapName');
try {
await this.shell.exec(`ip link del ${tapName} 2>/dev/null`);
await this.run('ip', ['link', 'del', tapName]);
this.activeTaps.delete(tapName);
} catch {
// Device may already be gone
@@ -211,24 +345,34 @@ export class NetworkManager {
*/
public async cleanup(): Promise<void> {
// Remove all TAP devices
for (const tapName of this.activeTaps.keys()) {
for (const tapName of Array.from(this.activeTaps.keys())) {
await this.removeTapDevice(tapName);
}
// Remove bridge if we created it
if (this.bridgeCreated) {
try {
await this.shell.exec(`ip link set ${this.bridgeName} down 2>/dev/null`);
await this.shell.exec(`ip link del ${this.bridgeName} 2>/dev/null`);
await this.run('ip', ['link', 'set', this.bridgeName, 'down']);
await this.run('ip', ['link', 'del', this.bridgeName]);
} catch {
// Bridge may already be gone
}
// Remove NAT rule
try {
await this.shell.exec(
`iptables -t nat -D POSTROUTING -s ${this.subnetBase}/${this.subnetCidr} -o $(ip route | grep default | awk '{print $5}' | head -1) -j MASQUERADE 2>/dev/null`,
);
const defaultIface = this.defaultRouteInterface || await this.getDefaultRouteInterface();
await this.run('iptables', [
'-t',
'nat',
'-D',
'POSTROUTING',
'-s',
`${this.subnetBase}/${this.subnetCidr}`,
'-o',
defaultIface,
'-j',
'MASQUERADE',
]);
} catch {
// Rule may not exist
}
+23 -3
View File
@@ -1,16 +1,22 @@
import * as plugins from './plugins.js';
import type { ISmartVMOptions, IMicroVMConfig } from './interfaces/index.js';
import type { IBaseImageBundle, IEnsureBaseImageOptions, ISmartVMOptions, IMicroVMConfig } from './interfaces/index.js';
import { SmartVMError } from './interfaces/index.js';
import { ImageManager } from './classes.imagemanager.js';
import { BaseImageManager } from './classes.baseimagemanager.js';
import { NetworkManager } from './classes.networkmanager.js';
import { MicroVM } from './classes.microvm.js';
function getErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/**
* Top-level orchestrator for creating and managing Firecracker MicroVMs.
*/
export class SmartVM {
private options: ISmartVMOptions;
public imageManager: ImageManager;
public baseImageManager: BaseImageManager;
public networkManager: NetworkManager;
private activeVMs: Map<string, MicroVM> = new Map();
private smartExitInstance: InstanceType<typeof plugins.smartexit.SmartExit>;
@@ -27,6 +33,13 @@ export class SmartVM {
};
this.imageManager = new ImageManager(this.options.dataDir!, this.options.arch);
this.baseImageManager = new BaseImageManager({
arch: this.options.arch,
cacheDir: this.options.baseImageCacheDir,
maxStoredBaseImages: this.options.maxStoredBaseImages,
hostedManifestUrl: this.options.baseImageManifestUrl,
hostedManifestPath: this.options.baseImageManifestPath,
});
this.networkManager = new NetworkManager({
bridgeName: this.options.bridgeName,
subnet: this.options.subnet,
@@ -115,6 +128,13 @@ export class SmartVM {
return vm;
}
/**
* Ensure a Firecracker CI base image bundle is available locally.
*/
public async ensureBaseImage(options: IEnsureBaseImageOptions = {}): Promise<IBaseImageBundle> {
return this.baseImageManager.ensureBaseImage(options);
}
/**
* Get an active VM by ID.
*/
@@ -145,7 +165,7 @@ export class SmartVM {
if (vm.state === 'running' || vm.state === 'paused') {
stopPromises.push(
vm.stop().catch((err) => {
console.error(`Failed to stop VM ${vm.id}: ${err.message}`);
console.error(`Failed to stop VM ${vm.id}: ${getErrorMessage(err)}`);
}),
);
}
@@ -162,7 +182,7 @@ export class SmartVM {
for (const vm of this.activeVMs.values()) {
cleanupPromises.push(
vm.cleanup().catch((err) => {
console.error(`Failed to clean up VM ${vm.id}: ${err.message}`);
console.error(`Failed to clean up VM ${vm.id}: ${getErrorMessage(err)}`);
}),
);
}
+47 -36
View File
@@ -2,6 +2,10 @@ import * as plugins from './plugins.js';
import type { ISocketClientOptions, IApiResponse } from './interfaces/index.js';
import { SmartVMError } from './interfaces/index.js';
function getErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/**
* HTTP client that communicates with Firecracker over a Unix domain socket.
* Uses @push.rocks/smartrequest with the `http://unix:<socket>:<path>` URL format.
@@ -20,6 +24,22 @@ export class SocketClient {
return `http://unix:${this.socketPath}:${apiPath}`;
}
private async parseResponseBody<T>(response: any): Promise<T> {
try {
const text = await response.text();
if (!text) {
return undefined as T;
}
try {
return JSON.parse(text) as T;
} catch {
return text as T;
}
} catch {
return undefined as T;
}
}
/**
* Perform a GET request.
*/
@@ -31,12 +51,7 @@ export class SocketClient {
.get();
const statusCode = response.status;
let body: T;
try {
body = await response.json() as T;
} catch {
body = undefined as any;
}
const body = await this.parseResponseBody<T>(response);
return {
statusCode,
body,
@@ -44,7 +59,7 @@ export class SocketClient {
};
} catch (err) {
throw new SmartVMError(
`GET ${apiPath} failed: ${(err as Error).message}`,
`GET ${apiPath} failed: ${getErrorMessage(err)}`,
'SOCKET_REQUEST_FAILED',
);
}
@@ -54,21 +69,19 @@ export class SocketClient {
* Perform a PUT request with a JSON body.
*/
public async put<T = any>(apiPath: string, body?: Record<string, any>): Promise<IApiResponse<T>> {
const url = this.buildUrl(apiPath);
try {
let request = plugins.SmartRequest.create().url(url);
if (body !== undefined) {
request = request.json(body);
}
const url = this.buildUrl(apiPath);
try {
let request = plugins.SmartRequest.create().url(url);
if (body !== undefined) {
const bodyBuffer = Buffer.from(JSON.stringify(body));
request = request
.buffer(bodyBuffer, 'application/json')
.header('Content-Length', String(bodyBuffer.length));
}
const response = await request.put();
const statusCode = response.status;
let responseBody: T;
try {
responseBody = await response.json() as T;
} catch {
responseBody = undefined as any;
}
const responseBody = await this.parseResponseBody<T>(response);
return {
statusCode,
body: responseBody,
@@ -76,7 +89,7 @@ export class SocketClient {
};
} catch (err) {
throw new SmartVMError(
`PUT ${apiPath} failed: ${(err as Error).message}`,
`PUT ${apiPath} failed: ${getErrorMessage(err)}`,
'SOCKET_REQUEST_FAILED',
);
}
@@ -86,21 +99,19 @@ export class SocketClient {
* Perform a PATCH request with a JSON body.
*/
public async patch<T = any>(apiPath: string, body?: Record<string, any>): Promise<IApiResponse<T>> {
const url = this.buildUrl(apiPath);
try {
let request = plugins.SmartRequest.create().url(url);
if (body !== undefined) {
request = request.json(body);
}
const url = this.buildUrl(apiPath);
try {
let request = plugins.SmartRequest.create().url(url);
if (body !== undefined) {
const bodyBuffer = Buffer.from(JSON.stringify(body));
request = request
.buffer(bodyBuffer, 'application/json')
.header('Content-Length', String(bodyBuffer.length));
}
const response = await request.patch();
const statusCode = response.status;
let responseBody: T;
try {
responseBody = await response.json() as T;
} catch {
responseBody = undefined as any;
}
const responseBody = await this.parseResponseBody<T>(response);
return {
statusCode,
body: responseBody,
@@ -108,21 +119,21 @@ export class SocketClient {
};
} catch (err) {
throw new SmartVMError(
`PATCH ${apiPath} failed: ${(err as Error).message}`,
`PATCH ${apiPath} failed: ${getErrorMessage(err)}`,
'SOCKET_REQUEST_FAILED',
);
}
}
/**
* Check if the Firecracker API socket is ready by polling GET /.
* Check if the Firecracker API socket is ready by polling GET /version.
*/
public async isReady(timeoutMs: number = 5000): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const response = await this.get('/');
if (response.ok || response.statusCode === 200 || response.statusCode === 400) {
const response = await this.get('/version');
if (response.ok) {
return true;
}
} catch {
+38 -1
View File
@@ -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,
};
}
/**
+1
View File
@@ -2,6 +2,7 @@ export * from './interfaces/index.js';
export { VMConfig } from './classes.vmconfig.js';
export { SocketClient } from './classes.socketclient.js';
export { ImageManager } from './classes.imagemanager.js';
export { BaseImageManager } from './classes.baseimagemanager.js';
export { FirecrackerProcess } from './classes.firecrackerprocess.js';
export { NetworkManager } from './classes.networkmanager.js';
export { MicroVM } from './classes.microvm.js';
+123 -2
View File
@@ -16,6 +16,127 @@ export interface ISmartVMOptions {
bridgeName?: string;
/** Network subnet in CIDR notation. Defaults to '172.30.0.0/24'. */
subnet?: string;
/** Directory for cached base images. Defaults to /tmp/.smartvm/base-images. */
baseImageCacheDir?: string;
/** Maximum number of cached base image bundles. Defaults to 2. */
maxStoredBaseImages?: number;
/** Hosted/project-owned base image manifest URL. */
baseImageManifestUrl?: string;
/** Local hosted/project-owned base image manifest path for development and tests. */
baseImageManifestPath?: string;
}
/**
* Predefined base image sources for integration testing and quick starts.
*/
export type TBaseImagePreset = 'latest' | 'lts' | 'hosted';
/**
* Root filesystem image type used by a base image bundle.
*/
export type TBaseImageRootfsType = 'ext4' | 'squashfs';
/**
* Options for the BaseImageManager.
*/
export interface IBaseImageManagerOptions {
/** Architecture to resolve. Defaults to x86_64. */
arch?: TFirecrackerArch;
/** Directory for cached base image bundles. Defaults to /tmp/.smartvm/base-images. */
cacheDir?: string;
/** Maximum number of cached base image bundles. Defaults to 2. */
maxStoredBaseImages?: number;
/** Hosted base image manifest URL for project-owned bundles. */
hostedManifestUrl?: string;
/** Local hosted base image manifest path for development and tests. */
hostedManifestPath?: string;
}
/**
* Options when resolving or downloading a base image bundle.
*/
export interface IEnsureBaseImageOptions {
/** Preset to use. Defaults to latest. */
preset?: TBaseImagePreset;
/** Architecture to resolve. Defaults to manager architecture. */
arch?: TFirecrackerArch;
/** Redownload even if the bundle already exists locally. */
forceDownload?: boolean;
/** Hosted base image manifest URL. Overrides preset resolution. */
manifestUrl?: string;
/** Local hosted base image manifest path. Overrides preset resolution. */
manifestPath?: string;
}
/**
* Single hosted base image artifact in a manifest.
*/
export interface IBaseImageArtifactManifest {
/** Public URL for hosted artifacts. */
url?: string;
/** Local path for development/tests. */
path?: string;
/** Optional plain output filename. Defaults to basename of url/path. */
fileName?: string;
/** Expected SHA256 for verification. Required when url is used. */
sha256?: string;
/** Expected file size in bytes. */
sizeBytes?: number;
}
/**
* Hosted/project-owned base image manifest format.
*/
export interface IBaseImageHostedManifest {
schemaVersion: 1;
bundleId: string;
name?: string;
arch: TFirecrackerArch;
firecrackerVersion: string;
rootfsType: TBaseImageRootfsType;
rootfsIsReadOnly?: boolean;
bootArgs?: string;
kernel: IBaseImageArtifactManifest;
rootfs: IBaseImageArtifactManifest;
}
/**
* Cached base image bundle metadata.
*/
export interface IBaseImageBundle {
preset: TBaseImagePreset;
arch: TFirecrackerArch;
ciVersion: string;
firecrackerVersion: string;
bundleId: string;
bundleDir: string;
kernelImagePath: string;
rootfsPath: string;
rootfsType: TBaseImageRootfsType;
rootfsIsReadOnly: boolean;
bootArgs: string;
source: {
type?: 'firecracker-ci' | 'hosted-manifest';
bucketUrl?: string;
kernelKey?: string;
rootfsKey?: string;
manifestUrl?: string;
manifestPath?: string;
kernelUrl?: string;
rootfsUrl?: string;
kernelSourcePath?: string;
rootfsSourcePath?: string;
};
checksums?: {
kernelSha256?: string;
rootfsSha256?: string;
};
sizes?: {
kernelBytes?: number;
rootfsBytes?: number;
};
createdAt: string;
lastAccessedAt: string;
}
/**
@@ -142,9 +263,9 @@ export interface ILoggerConfig {
logPath: string;
/** Log level. */
level?: TLogLevel;
/** Whether to show log origin (file, line). */
showLevel?: boolean;
/** Whether to show log level. */
showLevel?: boolean;
/** Whether to show log origin (file, line). */
showLogOrigin?: boolean;
}
+2 -1
View File
@@ -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';