feat(runtime): stage VM runtime artifacts and writable drives in per-VM ephemeral storage by default
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartvm',
|
||||
version: '1.2.0',
|
||||
version: '1.3.0',
|
||||
description: 'A TypeScript module wrapping Amazon Firecracker VMM for managing lightweight microVMs'
|
||||
}
|
||||
|
||||
@@ -485,14 +485,14 @@ export class BaseImageManager {
|
||||
}
|
||||
|
||||
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)!;
|
||||
}
|
||||
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)!;
|
||||
}
|
||||
throw new SmartVMError('No suitable Firecracker CI rootfs image found', 'BASE_IMAGE_RESOLVE_FAILED');
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ import * as plugins from './plugins.js';
|
||||
import type {
|
||||
TVMState,
|
||||
IMicroVMConfig,
|
||||
IMicroVMRuntimeOptions,
|
||||
ISnapshotCreateParams,
|
||||
ISnapshotLoadParams,
|
||||
IDriveConfig,
|
||||
ITapDevice,
|
||||
} from './interfaces/index.js';
|
||||
import { SmartVMError } from './interfaces/index.js';
|
||||
@@ -26,6 +28,9 @@ export class MicroVM {
|
||||
private networkManager: NetworkManager;
|
||||
private binaryPath: string;
|
||||
private socketPath: string;
|
||||
private runtimeDir: string;
|
||||
private ephemeralWritableDrives: boolean;
|
||||
private vmRuntimeDir: string | null = null;
|
||||
private tapDevices: ITapDevice[] = [];
|
||||
|
||||
constructor(
|
||||
@@ -34,12 +39,15 @@ export class MicroVM {
|
||||
binaryPath: string,
|
||||
socketPath: string,
|
||||
networkManager: NetworkManager,
|
||||
runtimeOptions: IMicroVMRuntimeOptions = {},
|
||||
) {
|
||||
this.id = id;
|
||||
this.vmConfig = new VMConfig(config);
|
||||
this.binaryPath = binaryPath;
|
||||
this.socketPath = socketPath;
|
||||
this.networkManager = networkManager;
|
||||
this.runtimeDir = runtimeOptions.runtimeDir || plugins.path.join(plugins.os.tmpdir(), '.smartvm', 'runtime');
|
||||
this.ephemeralWritableDrives = runtimeOptions.ephemeralWritableDrives ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,6 +91,9 @@ export class MicroVM {
|
||||
this.state = 'configuring';
|
||||
|
||||
try {
|
||||
await this.ensureVMRuntimeDir();
|
||||
await this.prepareEphemeralDrives();
|
||||
|
||||
// Start the Firecracker process
|
||||
this.process = new FirecrackerProcess({
|
||||
binaryPath: this.binaryPath,
|
||||
@@ -318,6 +329,13 @@ export class MicroVM {
|
||||
return this.vmConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the per-VM runtime directory if it has been created.
|
||||
*/
|
||||
public getRuntimeDir(): string | null {
|
||||
return this.vmRuntimeDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full cleanup: stop process, remove socket, remove TAP devices.
|
||||
*/
|
||||
@@ -334,12 +352,69 @@ export class MicroVM {
|
||||
}
|
||||
this.tapDevices = [];
|
||||
|
||||
if (this.vmRuntimeDir) {
|
||||
await plugins.fs.promises.rm(this.vmRuntimeDir, { recursive: true, force: true });
|
||||
this.vmRuntimeDir = null;
|
||||
}
|
||||
|
||||
this.socketClient = null;
|
||||
if (this.state !== 'error') {
|
||||
this.state = 'stopped';
|
||||
}
|
||||
}
|
||||
|
||||
private shouldStageDrive(drive: IDriveConfig): boolean {
|
||||
if (!this.ephemeralWritableDrives) {
|
||||
return false;
|
||||
}
|
||||
if (drive.ephemeral === false) {
|
||||
return false;
|
||||
}
|
||||
if (drive.isReadOnly === true && drive.ephemeral !== true) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async ensureVMRuntimeDir(): Promise<string> {
|
||||
if (!this.vmRuntimeDir) {
|
||||
this.vmRuntimeDir = plugins.path.join(this.runtimeDir, this.sanitizePathPart(this.id));
|
||||
}
|
||||
await plugins.fs.promises.mkdir(this.vmRuntimeDir, { recursive: true });
|
||||
return this.vmRuntimeDir;
|
||||
}
|
||||
|
||||
private async prepareEphemeralDrives(): Promise<void> {
|
||||
const drives = this.vmConfig.config.drives || [];
|
||||
for (const drive of drives) {
|
||||
if (!this.shouldStageDrive(drive)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const vmRuntimeDir = await this.ensureVMRuntimeDir();
|
||||
const drivesDir = plugins.path.join(vmRuntimeDir, 'drives');
|
||||
await plugins.fs.promises.mkdir(drivesDir, { recursive: true });
|
||||
|
||||
const sourcePath = drive.pathOnHost;
|
||||
const sourceFileName = plugins.path.basename(sourcePath) || 'drive.img';
|
||||
const stagedPath = plugins.path.join(
|
||||
drivesDir,
|
||||
`${this.sanitizePathPart(drive.driveId)}-${this.sanitizePathPart(sourceFileName)}`,
|
||||
);
|
||||
|
||||
await plugins.fs.promises.copyFile(sourcePath, stagedPath);
|
||||
drive.pathOnHost = stagedPath;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizePathPart(value: string): string {
|
||||
const sanitized = value.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
if (!sanitized || sanitized === '.' || sanitized === '..') {
|
||||
return 'item';
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: PUT request with error handling.
|
||||
*/
|
||||
|
||||
+33
-2
@@ -26,6 +26,8 @@ export class SmartVM {
|
||||
constructor(options: ISmartVMOptions = {}) {
|
||||
this.options = {
|
||||
dataDir: options.dataDir || '/tmp/.smartvm',
|
||||
runtimeDir: options.runtimeDir || this.getDefaultRuntimeDir(),
|
||||
ephemeralWritableDrives: options.ephemeralWritableDrives ?? true,
|
||||
arch: options.arch || 'x86_64',
|
||||
bridgeName: options.bridgeName || 'svbr0',
|
||||
subnet: options.subnet || '172.30.0.0/24',
|
||||
@@ -57,6 +59,30 @@ export class SmartVM {
|
||||
});
|
||||
}
|
||||
|
||||
private getDefaultRuntimeDir(): string {
|
||||
const tmpfsDir = '/dev/shm';
|
||||
try {
|
||||
if (plugins.fs.existsSync(tmpfsDir) && plugins.fs.statSync(tmpfsDir).isDirectory()) {
|
||||
return plugins.path.join(tmpfsDir, '.smartvm', 'runtime');
|
||||
}
|
||||
} catch {
|
||||
// Fall back to os.tmpdir() below.
|
||||
}
|
||||
return plugins.path.join(plugins.os.tmpdir(), '.smartvm', 'runtime');
|
||||
}
|
||||
|
||||
public getRuntimeDir(): string {
|
||||
return this.options.runtimeDir!;
|
||||
}
|
||||
|
||||
private sanitizePathPart(value: string): string {
|
||||
const sanitized = value.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
if (!sanitized || sanitized === '.' || sanitized === '..') {
|
||||
return 'item';
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the Firecracker binary is available.
|
||||
* Downloads it if not present.
|
||||
@@ -110,8 +136,9 @@ export class SmartVM {
|
||||
// Generate VM ID if not provided
|
||||
const vmId = config.id || plugins.smartunique.uuid4();
|
||||
|
||||
// Generate socket path
|
||||
const socketPath = this.imageManager.getSocketPath(vmId);
|
||||
// Keep per-VM runtime artifacts in tmpfs by default.
|
||||
const vmRuntimeDir = plugins.path.join(this.options.runtimeDir!, this.sanitizePathPart(vmId));
|
||||
const socketPath = plugins.path.join(vmRuntimeDir, 'firecracker.sock');
|
||||
|
||||
// Create MicroVM instance
|
||||
const vm = new MicroVM(
|
||||
@@ -120,6 +147,10 @@ export class SmartVM {
|
||||
this.firecrackerBinaryPath!,
|
||||
socketPath,
|
||||
this.networkManager,
|
||||
{
|
||||
runtimeDir: this.options.runtimeDir,
|
||||
ephemeralWritableDrives: this.options.ephemeralWritableDrives,
|
||||
},
|
||||
);
|
||||
|
||||
// Register in active VMs
|
||||
|
||||
@@ -26,6 +26,7 @@ export class VMConfig {
|
||||
drives: config.drives?.map((drive) => ({
|
||||
...drive,
|
||||
rateLimiter: drive.rateLimiter ? this.cloneRateLimiter(drive.rateLimiter) : undefined,
|
||||
ephemeral: drive.ephemeral,
|
||||
})),
|
||||
networkInterfaces: config.networkInterfaces?.map((iface) => ({
|
||||
...iface,
|
||||
|
||||
@@ -6,6 +6,10 @@ import type { TFirecrackerArch, TCacheType, TSnapshotType, TLogLevel } from './c
|
||||
export interface ISmartVMOptions {
|
||||
/** Directory for storing binaries, kernels, rootfs images, and sockets. Defaults to /tmp/.smartvm */
|
||||
dataDir?: string;
|
||||
/** Directory for VM sockets and ephemeral per-VM files. Defaults to /dev/shm/.smartvm/runtime on Linux when available. */
|
||||
runtimeDir?: string;
|
||||
/** Copy writable drives into the VM runtime directory before boot and delete them on cleanup. Defaults to true. */
|
||||
ephemeralWritableDrives?: boolean;
|
||||
/** Firecracker version to use. Defaults to latest. */
|
||||
firecrackerVersion?: string;
|
||||
/** Target architecture. Defaults to x86_64. */
|
||||
@@ -139,6 +143,16 @@ export interface IBaseImageBundle {
|
||||
lastAccessedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime behavior for a MicroVM instance.
|
||||
*/
|
||||
export interface IMicroVMRuntimeOptions {
|
||||
/** Directory for VM sockets and ephemeral per-VM files. */
|
||||
runtimeDir?: string;
|
||||
/** Copy writable drives into runtimeDir before boot and delete them on cleanup. Defaults to true. */
|
||||
ephemeralWritableDrives?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Firecracker boot source configuration.
|
||||
*/
|
||||
@@ -205,6 +219,8 @@ export interface IDriveConfig {
|
||||
rateLimiter?: IRateLimiter;
|
||||
/** Path to a file that backs the device for I/O. */
|
||||
ioEngine?: string;
|
||||
/** Whether this drive should be staged into per-VM ephemeral storage. Defaults to true for writable drives. */
|
||||
ephemeral?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user