feat(base-images): add managed base image bundles with cache retention, hosted manifests, and opt-in integration boot testing
This commit is contained in:
+190
-46
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user