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
+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;