2026-02-08 21:47:33 +00:00
|
|
|
import * as plugins from './plugins.js';
|
|
|
|
|
import type { TFirecrackerArch } from './interfaces/index.js';
|
|
|
|
|
import { SmartVMError } from './interfaces/index.js';
|
|
|
|
|
|
2026-05-01 13:30:51 +00:00
|
|
|
type TShellExecResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawn']>>;
|
|
|
|
|
|
|
|
|
|
function getErrorMessage(err: unknown): string {
|
|
|
|
|
return err instanceof Error ? err.message : String(err);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 21:47:33 +00:00
|
|
|
/**
|
|
|
|
|
* Helper to check if a file or directory exists.
|
|
|
|
|
*/
|
|
|
|
|
async function pathExists(filePath: string): Promise<boolean> {
|
|
|
|
|
try {
|
|
|
|
|
await plugins.fs.promises.access(filePath);
|
|
|
|
|
return true;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Manages Firecracker binaries, kernel images, and rootfs images.
|
|
|
|
|
* Downloads and caches them in a local data directory.
|
|
|
|
|
*/
|
|
|
|
|
export class ImageManager {
|
|
|
|
|
private dataDir: string;
|
|
|
|
|
private arch: TFirecrackerArch;
|
2026-05-01 13:30:51 +00:00
|
|
|
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
2026-02-08 21:47:33 +00:00
|
|
|
|
|
|
|
|
constructor(dataDir: string, arch: TFirecrackerArch = 'x86_64') {
|
|
|
|
|
this.dataDir = dataDir;
|
|
|
|
|
this.arch = arch;
|
2026-05-01 13:30:51 +00:00
|
|
|
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;
|
2026-02-08 21:47:33 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Ensure all required directories exist.
|
|
|
|
|
*/
|
|
|
|
|
public async ensureDirectories(): Promise<void> {
|
|
|
|
|
const dirs = [
|
|
|
|
|
this.getBinDir(),
|
|
|
|
|
this.getKernelsDir(),
|
|
|
|
|
this.getRootfsDir(),
|
|
|
|
|
this.getSocketsDir(),
|
|
|
|
|
];
|
|
|
|
|
for (const dir of dirs) {
|
|
|
|
|
await plugins.fs.promises.mkdir(dir, { recursive: true });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Base directory for firecracker binaries. */
|
|
|
|
|
public getBinDir(): string {
|
|
|
|
|
return plugins.path.join(this.dataDir, 'bin');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Directory for kernel images. */
|
|
|
|
|
public getKernelsDir(): string {
|
|
|
|
|
return plugins.path.join(this.dataDir, 'kernels');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Directory for rootfs images. */
|
|
|
|
|
public getRootfsDir(): string {
|
|
|
|
|
return plugins.path.join(this.dataDir, 'rootfs');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Directory for Unix sockets. */
|
|
|
|
|
public getSocketsDir(): string {
|
|
|
|
|
return plugins.path.join(this.dataDir, 'sockets');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the path to the firecracker binary for a given version.
|
|
|
|
|
*/
|
|
|
|
|
public getFirecrackerPath(version: string): string {
|
|
|
|
|
return plugins.path.join(this.getBinDir(), version, 'firecracker');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the path to the jailer binary for a given version.
|
|
|
|
|
*/
|
|
|
|
|
public getJailerPath(version: string): string {
|
|
|
|
|
return plugins.path.join(this.getBinDir(), version, 'jailer');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if a Firecracker binary for the given version exists.
|
|
|
|
|
*/
|
|
|
|
|
public async hasBinary(version: string): Promise<boolean> {
|
|
|
|
|
const binPath = this.getFirecrackerPath(version);
|
|
|
|
|
return pathExists(binPath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Query the GitHub API for the latest Firecracker release version tag.
|
|
|
|
|
*/
|
|
|
|
|
public async getLatestVersion(): Promise<string> {
|
|
|
|
|
try {
|
2026-05-01 13:30:51 +00:00
|
|
|
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];
|
2026-02-08 21:47:33 +00:00
|
|
|
} catch (err) {
|
|
|
|
|
throw new SmartVMError(
|
2026-05-01 13:30:51 +00:00
|
|
|
`Failed to fetch latest Firecracker version: ${getErrorMessage(err)}`,
|
2026-02-08 21:47:33 +00:00
|
|
|
'VERSION_FETCH_FAILED',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Download and extract the Firecracker binary for a given version.
|
|
|
|
|
*/
|
|
|
|
|
public async downloadFirecracker(version: string): Promise<string> {
|
|
|
|
|
const targetDir = plugins.path.join(this.getBinDir(), version);
|
|
|
|
|
await plugins.fs.promises.mkdir(targetDir, { recursive: true });
|
|
|
|
|
|
|
|
|
|
// Firecracker release tarball naming:
|
|
|
|
|
// firecracker-v1.5.0-x86_64.tgz containing release-v1.5.0-x86_64/firecracker-v1.5.0-x86_64
|
|
|
|
|
const tag = version.startsWith('v') ? version : `v${version}`;
|
|
|
|
|
const archiveName = `firecracker-${tag}-${this.arch}.tgz`;
|
|
|
|
|
const downloadUrl = `https://github.com/firecracker-microvm/firecracker/releases/download/${tag}/${archiveName}`;
|
|
|
|
|
|
|
|
|
|
const archivePath = plugins.path.join(targetDir, archiveName);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Download the archive
|
2026-05-01 13:30:51 +00:00
|
|
|
await this.runChecked('curl', ['-fSL', '-o', archivePath, downloadUrl]);
|
2026-02-08 21:47:33 +00:00
|
|
|
|
|
|
|
|
// Extract the archive
|
2026-05-01 13:30:51 +00:00
|
|
|
await this.runChecked('tar', ['-xzf', archivePath, '-C', targetDir]);
|
2026-02-08 21:47:33 +00:00
|
|
|
|
|
|
|
|
// Firecracker archives contain a directory like release-v1.5.0-x86_64/
|
|
|
|
|
// with binaries named like firecracker-v1.5.0-x86_64
|
|
|
|
|
const extractedDir = plugins.path.join(targetDir, `release-${tag}-${this.arch}`);
|
|
|
|
|
const firecrackerSrc = plugins.path.join(extractedDir, `firecracker-${tag}-${this.arch}`);
|
|
|
|
|
const jailerSrc = plugins.path.join(extractedDir, `jailer-${tag}-${this.arch}`);
|
|
|
|
|
const firecrackerDst = this.getFirecrackerPath(version);
|
|
|
|
|
const jailerDst = this.getJailerPath(version);
|
|
|
|
|
|
|
|
|
|
// Move binaries to expected paths
|
2026-05-01 13:30:51 +00:00
|
|
|
await plugins.fs.promises.rename(firecrackerSrc, firecrackerDst);
|
2026-02-08 21:47:33 +00:00
|
|
|
if (await pathExists(jailerSrc)) {
|
2026-05-01 13:30:51 +00:00
|
|
|
await plugins.fs.promises.rename(jailerSrc, jailerDst);
|
2026-02-08 21:47:33 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Make executable
|
2026-05-01 13:30:51 +00:00
|
|
|
await plugins.fs.promises.chmod(firecrackerDst, 0o755);
|
|
|
|
|
if (await pathExists(jailerDst)) {
|
|
|
|
|
await plugins.fs.promises.chmod(jailerDst, 0o755);
|
|
|
|
|
}
|
2026-02-08 21:47:33 +00:00
|
|
|
|
|
|
|
|
// Clean up
|
2026-05-01 13:30:51 +00:00
|
|
|
await plugins.fs.promises.rm(archivePath, { force: true });
|
|
|
|
|
await plugins.fs.promises.rm(extractedDir, { recursive: true, force: true });
|
2026-02-08 21:47:33 +00:00
|
|
|
|
|
|
|
|
return firecrackerDst;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
throw new SmartVMError(
|
2026-05-01 13:30:51 +00:00
|
|
|
`Failed to download Firecracker ${version}: ${getErrorMessage(err)}`,
|
2026-02-08 21:47:33 +00:00
|
|
|
'DOWNLOAD_FAILED',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Download a kernel image from a URL.
|
|
|
|
|
*/
|
|
|
|
|
public async downloadKernel(url: string, name: string): Promise<string> {
|
|
|
|
|
const kernelsDir = this.getKernelsDir();
|
|
|
|
|
await plugins.fs.promises.mkdir(kernelsDir, { recursive: true });
|
|
|
|
|
const kernelPath = plugins.path.join(kernelsDir, name);
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-01 13:30:51 +00:00
|
|
|
await this.runChecked('curl', ['-fSL', '-o', kernelPath, url]);
|
2026-02-08 21:47:33 +00:00
|
|
|
return kernelPath;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
throw new SmartVMError(
|
2026-05-01 13:30:51 +00:00
|
|
|
`Failed to download kernel from ${url}: ${getErrorMessage(err)}`,
|
2026-02-08 21:47:33 +00:00
|
|
|
'DOWNLOAD_FAILED',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Download a rootfs image from a URL.
|
|
|
|
|
*/
|
|
|
|
|
public async downloadRootfs(url: string, name: string): Promise<string> {
|
|
|
|
|
const rootfsDir = this.getRootfsDir();
|
|
|
|
|
await plugins.fs.promises.mkdir(rootfsDir, { recursive: true });
|
|
|
|
|
const rootfsPath = plugins.path.join(rootfsDir, name);
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-01 13:30:51 +00:00
|
|
|
await this.runChecked('curl', ['-fSL', '-o', rootfsPath, url]);
|
2026-02-08 21:47:33 +00:00
|
|
|
return rootfsPath;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
throw new SmartVMError(
|
2026-05-01 13:30:51 +00:00
|
|
|
`Failed to download rootfs from ${url}: ${getErrorMessage(err)}`,
|
2026-02-08 21:47:33 +00:00
|
|
|
'DOWNLOAD_FAILED',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a blank ext4 rootfs image.
|
|
|
|
|
*/
|
|
|
|
|
public async createBlankRootfs(name: string, sizeMib: number): Promise<string> {
|
|
|
|
|
const rootfsDir = this.getRootfsDir();
|
|
|
|
|
await plugins.fs.promises.mkdir(rootfsDir, { recursive: true });
|
|
|
|
|
const rootfsPath = plugins.path.join(rootfsDir, name);
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-01 13:30:51 +00:00
|
|
|
await this.runChecked('dd', ['if=/dev/zero', `of=${rootfsPath}`, 'bs=1M', `count=${sizeMib}`]);
|
|
|
|
|
await this.runChecked('mkfs.ext4', [rootfsPath]);
|
2026-02-08 21:47:33 +00:00
|
|
|
return rootfsPath;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
throw new SmartVMError(
|
2026-05-01 13:30:51 +00:00
|
|
|
`Failed to create blank rootfs: ${getErrorMessage(err)}`,
|
2026-02-08 21:47:33 +00:00
|
|
|
'ROOTFS_CREATE_FAILED',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Clone a rootfs image for a specific VM.
|
|
|
|
|
*/
|
|
|
|
|
public async cloneRootfs(sourcePath: string, targetName: string): Promise<string> {
|
|
|
|
|
const rootfsDir = this.getRootfsDir();
|
|
|
|
|
await plugins.fs.promises.mkdir(rootfsDir, { recursive: true });
|
|
|
|
|
const targetPath = plugins.path.join(rootfsDir, targetName);
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-01 13:30:51 +00:00
|
|
|
await plugins.fs.promises.copyFile(sourcePath, targetPath);
|
2026-02-08 21:47:33 +00:00
|
|
|
return targetPath;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
throw new SmartVMError(
|
2026-05-01 13:30:51 +00:00
|
|
|
`Failed to clone rootfs: ${getErrorMessage(err)}`,
|
2026-02-08 21:47:33 +00:00
|
|
|
'ROOTFS_CLONE_FAILED',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Generate a unique socket path for a VM.
|
|
|
|
|
*/
|
|
|
|
|
public getSocketPath(vmId: string): string {
|
|
|
|
|
return plugins.path.join(this.getSocketsDir(), `${vmId}.sock`);
|
|
|
|
|
}
|
|
|
|
|
}
|