feat(base-images): add managed base image bundles with cache retention, hosted manifests, and opt-in integration boot testing
This commit is contained in:
+51
-27
@@ -2,6 +2,12 @@ import * as plugins from './plugins.js';
|
||||
import type { TFirecrackerArch } from './interfaces/index.js';
|
||||
import { SmartVMError } from './interfaces/index.js';
|
||||
|
||||
type TShellExecResult = Awaited<ReturnType<InstanceType<typeof plugins.smartshell.Smartshell>['execSpawn']>>;
|
||||
|
||||
function getErrorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check if a file or directory exists.
|
||||
*/
|
||||
@@ -21,10 +27,21 @@ async function pathExists(filePath: string): Promise<boolean> {
|
||||
export class ImageManager {
|
||||
private dataDir: string;
|
||||
private arch: TFirecrackerArch;
|
||||
private shell: InstanceType<typeof plugins.smartshell.Smartshell>;
|
||||
|
||||
constructor(dataDir: string, arch: TFirecrackerArch = 'x86_64') {
|
||||
this.dataDir = dataDir;
|
||||
this.arch = arch;
|
||||
this.shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||
}
|
||||
|
||||
private async runChecked(command: string, args: string[]): Promise<TShellExecResult> {
|
||||
const result = await this.shell.execSpawn(command, args, { silent: true });
|
||||
if (result.exitCode !== 0) {
|
||||
const output = (result.stderr || result.stdout || '').trim();
|
||||
throw new Error(`${command} ${args.join(' ')} exited with code ${result.exitCode}${output ? `: ${output}` : ''}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,14 +106,22 @@ export class ImageManager {
|
||||
*/
|
||||
public async getLatestVersion(): Promise<string> {
|
||||
try {
|
||||
const response = await plugins.SmartRequest.create()
|
||||
.url('https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest')
|
||||
.get();
|
||||
const data = await response.json() as { tag_name: string };
|
||||
return data.tag_name;
|
||||
const result = await this.runChecked('curl', [
|
||||
'-fsSLI',
|
||||
'-o',
|
||||
'/dev/null',
|
||||
'-w',
|
||||
'%{url_effective}',
|
||||
'https://github.com/firecracker-microvm/firecracker/releases/latest',
|
||||
]);
|
||||
const match = result.stdout.trim().match(/\/releases\/tag\/(v\d+\.\d+\.\d+)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Could not parse latest Firecracker release URL: ${result.stdout.trim()}`);
|
||||
}
|
||||
return match[1];
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`Failed to fetch latest Firecracker version: ${(err as Error).message}`,
|
||||
`Failed to fetch latest Firecracker version: ${getErrorMessage(err)}`,
|
||||
'VERSION_FETCH_FAILED',
|
||||
);
|
||||
}
|
||||
@@ -119,11 +144,10 @@ export class ImageManager {
|
||||
|
||||
try {
|
||||
// Download the archive
|
||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||
await shell.exec(`curl -fSL -o "${archivePath}" "${downloadUrl}"`);
|
||||
await this.runChecked('curl', ['-fSL', '-o', archivePath, downloadUrl]);
|
||||
|
||||
// Extract the archive
|
||||
await shell.exec(`tar -xzf "${archivePath}" -C "${targetDir}"`);
|
||||
await this.runChecked('tar', ['-xzf', archivePath, '-C', targetDir]);
|
||||
|
||||
// Firecracker archives contain a directory like release-v1.5.0-x86_64/
|
||||
// with binaries named like firecracker-v1.5.0-x86_64
|
||||
@@ -134,21 +158,25 @@ export class ImageManager {
|
||||
const jailerDst = this.getJailerPath(version);
|
||||
|
||||
// Move binaries to expected paths
|
||||
await shell.exec(`mv "${firecrackerSrc}" "${firecrackerDst}"`);
|
||||
await plugins.fs.promises.rename(firecrackerSrc, firecrackerDst);
|
||||
if (await pathExists(jailerSrc)) {
|
||||
await shell.exec(`mv "${jailerSrc}" "${jailerDst}"`);
|
||||
await plugins.fs.promises.rename(jailerSrc, jailerDst);
|
||||
}
|
||||
|
||||
// Make executable
|
||||
await shell.exec(`chmod +x "${firecrackerDst}"`);
|
||||
await plugins.fs.promises.chmod(firecrackerDst, 0o755);
|
||||
if (await pathExists(jailerDst)) {
|
||||
await plugins.fs.promises.chmod(jailerDst, 0o755);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
await shell.exec(`rm -rf "${archivePath}" "${extractedDir}"`);
|
||||
await plugins.fs.promises.rm(archivePath, { force: true });
|
||||
await plugins.fs.promises.rm(extractedDir, { recursive: true, force: true });
|
||||
|
||||
return firecrackerDst;
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`Failed to download Firecracker ${version}: ${(err as Error).message}`,
|
||||
`Failed to download Firecracker ${version}: ${getErrorMessage(err)}`,
|
||||
'DOWNLOAD_FAILED',
|
||||
);
|
||||
}
|
||||
@@ -163,12 +191,11 @@ export class ImageManager {
|
||||
const kernelPath = plugins.path.join(kernelsDir, name);
|
||||
|
||||
try {
|
||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||
await shell.exec(`curl -fSL -o "${kernelPath}" "${url}"`);
|
||||
await this.runChecked('curl', ['-fSL', '-o', kernelPath, url]);
|
||||
return kernelPath;
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`Failed to download kernel from ${url}: ${(err as Error).message}`,
|
||||
`Failed to download kernel from ${url}: ${getErrorMessage(err)}`,
|
||||
'DOWNLOAD_FAILED',
|
||||
);
|
||||
}
|
||||
@@ -183,12 +210,11 @@ export class ImageManager {
|
||||
const rootfsPath = plugins.path.join(rootfsDir, name);
|
||||
|
||||
try {
|
||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||
await shell.exec(`curl -fSL -o "${rootfsPath}" "${url}"`);
|
||||
await this.runChecked('curl', ['-fSL', '-o', rootfsPath, url]);
|
||||
return rootfsPath;
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`Failed to download rootfs from ${url}: ${(err as Error).message}`,
|
||||
`Failed to download rootfs from ${url}: ${getErrorMessage(err)}`,
|
||||
'DOWNLOAD_FAILED',
|
||||
);
|
||||
}
|
||||
@@ -203,13 +229,12 @@ export class ImageManager {
|
||||
const rootfsPath = plugins.path.join(rootfsDir, name);
|
||||
|
||||
try {
|
||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||
await shell.exec(`dd if=/dev/zero of="${rootfsPath}" bs=1M count=${sizeMib}`);
|
||||
await shell.exec(`mkfs.ext4 "${rootfsPath}"`);
|
||||
await this.runChecked('dd', ['if=/dev/zero', `of=${rootfsPath}`, 'bs=1M', `count=${sizeMib}`]);
|
||||
await this.runChecked('mkfs.ext4', [rootfsPath]);
|
||||
return rootfsPath;
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`Failed to create blank rootfs: ${(err as Error).message}`,
|
||||
`Failed to create blank rootfs: ${getErrorMessage(err)}`,
|
||||
'ROOTFS_CREATE_FAILED',
|
||||
);
|
||||
}
|
||||
@@ -224,12 +249,11 @@ export class ImageManager {
|
||||
const targetPath = plugins.path.join(rootfsDir, targetName);
|
||||
|
||||
try {
|
||||
const shell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||
await shell.exec(`cp "${sourcePath}" "${targetPath}"`);
|
||||
await plugins.fs.promises.copyFile(sourcePath, targetPath);
|
||||
return targetPath;
|
||||
} catch (err) {
|
||||
throw new SmartVMError(
|
||||
`Failed to clone rootfs: ${(err as Error).message}`,
|
||||
`Failed to clone rootfs: ${getErrorMessage(err)}`,
|
||||
'ROOTFS_CLONE_FAILED',
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user