Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/firecracker-runtime-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { WrapperConfig } from './types';

export const FIRECRACKER_INCOMPLETE_CAPABILITY_ERROR =
'Firecracker runtime workload execution is unavailable in this preview: ' +
'networking and guest agent/vsock execution are not implemented';
'workspace image and guest command execution are not implemented';

export interface FirecrackerRuntimeBackendDependencies {
startInfrastructure: WorkflowDependencies['startContainers'];
Expand All @@ -16,7 +16,9 @@ export interface FirecrackerRuntimeBackendDependencies {
* Fail-closed backend boundary for the Firecracker control-plane preview.
*
* The manager primitives are intentionally not dispatched by the main workflow
* until networking and guest command execution land in later stack layers.
* until workspace preparation and guest command execution land in later stack
* layers. FirecrackerManager separately refuses to launch without host-side
* network enforcement.
*/
export class FirecrackerRuntimeBackend implements ExternalAgentRuntimeBackend {
readonly runtime = 'firecracker';
Expand Down
14 changes: 14 additions & 0 deletions src/firecracker/api-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ describe('FirecrackerApiClient', () => {
is_root_device: true,
is_read_only: false,
});
await client.putNetworkInterface({
iface_id: 'primary interface',
host_dev_name: 'fct123456789012',
guest_mac: '02:00:00:00:00:01',
});
await client.instanceStart();

expect(received).toEqual([
Expand All @@ -77,6 +82,15 @@ describe('FirecrackerApiClient', () => {
is_read_only: false,
}),
},
{
method: 'PUT',
url: '/network-interfaces/primary%20interface',
body: JSON.stringify({
iface_id: 'primary interface',
host_dev_name: 'fct123456789012',
guest_mac: '02:00:00:00:00:01',
}),
},
{
method: 'PUT',
url: '/actions',
Expand Down
159 changes: 156 additions & 3 deletions src/firecracker/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import {
FirecrackerManager,
createFirecrackerRunPaths,
type FirecrackerManagerDependencies,
type FirecrackerManagerNetworkConfig,
} from './manager';
import type {
FirecrackerNetworkLifecycle,
FirecrackerNetworkPlan,
} from './network';

function config(overrides: Partial<FirecrackerOptions> = {}): FirecrackerOptions {
return {
Expand Down Expand Up @@ -34,13 +39,32 @@ function processMock(): ExecaChildProcess<string> {
return child;
}

function networkConfig(
overrides: Partial<FirecrackerManagerNetworkConfig> = {},
): FirecrackerManagerNetworkConfig {
return {
infrastructureBridge: 'awfbr0',
enableApiProxy: true,
...overrides,
};
}

function networkLifecycle(plan: FirecrackerNetworkPlan): FirecrackerNetworkLifecycle {
return {
plan,
setup: jest.fn().mockResolvedValue(plan),
cleanup: jest.fn().mockResolvedValue(undefined),
};
}

function dependencies(
overrides: Partial<FirecrackerManagerDependencies> = {},
): FirecrackerManagerDependencies {
const client = {
putMachineConfig: jest.fn().mockResolvedValue(undefined),
putBootSource: jest.fn().mockResolvedValue(undefined),
putDrive: jest.fn().mockResolvedValue(undefined),
putNetworkInterface: jest.fn().mockResolvedValue(undefined),
instanceStart: jest.fn().mockResolvedValue(undefined),
} as unknown as FirecrackerApiClient;
return {
Expand All @@ -60,6 +84,7 @@ function dependencies(
rm: jest.fn().mockResolvedValue(undefined),
sleep: jest.fn().mockResolvedValue(undefined),
createClient: jest.fn().mockReturnValue(client),
createNetwork: jest.fn((plan) => networkLifecycle(plan)),
resolveIdentity: jest.fn().mockReturnValue({ uid: 1000, gid: 1000 }),
...overrides,
};
Expand Down Expand Up @@ -90,14 +115,21 @@ describe('FirecrackerManager', () => {

it('launches jailer and configures machine, kernel, and root drive', async () => {
const deps = dependencies();
const manager = new FirecrackerManager(config(), '/tmp/awf', deps, 'run-1');
const manager = new FirecrackerManager(
config(),
'/tmp/awf',
deps,
'run-1',
networkConfig(),
);
const client = await manager.start();

expect(deps.launch).toHaveBeenCalledWith(
'/opt/jailer',
expect.arrayContaining([
'--id', 'run-1',
'--exec-file', '/opt/firecracker',
'--netns', expect.stringMatching(/^\/var\/run\/netns\/awffc-/),
'--api-sock', '/run/firecracker.socket',
]),
expect.objectContaining({ reject: false }),
Expand All @@ -114,6 +146,23 @@ describe('FirecrackerManager', () => {
path_on_host: '/rootfs',
is_root_device: true,
}));
expect(client.putNetworkInterface).toHaveBeenCalledWith({
iface_id: 'eth0',
host_dev_name: expect.stringMatching(/^fct[0-9a-f]{12}$/),
guest_mac: expect.any(String),
});
const configuredNetwork = (client.putNetworkInterface as jest.Mock)
.mock.calls[0][0] as { guest_mac: string };
expect(configuredNetwork.guest_mac.split(':')).toHaveLength(6);
expect(configuredNetwork.guest_mac.startsWith('02:')).toBe(true);
expect(deps.createNetwork).toHaveBeenCalledWith(expect.objectContaining({
infrastructureBridge: 'awfbr0',
jailerUid: 1000,
jailerGid: 1000,
}));
const lifecycle = (deps.createNetwork as jest.Mock).mock.results[0]
.value as FirecrackerNetworkLifecycle;
expect(lifecycle.setup).toHaveBeenCalledTimes(1);
});

it('terminates the partial process and removes its jail on readiness failure', async () => {
Expand All @@ -124,7 +173,13 @@ describe('FirecrackerManager', () => {
access: jest.fn().mockRejectedValue(missing),
sleep: jest.fn(async () => new Promise((resolve) => setTimeout(resolve, 2))),
});
const manager = new FirecrackerManager(config(), '/tmp/awf', deps, 'partial');
const manager = new FirecrackerManager(
config(),
'/tmp/awf',
deps,
'partial',
networkConfig(),
);

await expect(manager.start()).rejects.toThrow(/API socket was not ready/);
expect(child.kill).toHaveBeenCalledWith(
Expand All @@ -135,6 +190,98 @@ describe('FirecrackerManager', () => {
'/tmp/awf/firecracker-jailer/firecracker/partial',
{ recursive: true, force: true },
);
const lifecycle = (deps.createNetwork as jest.Mock).mock.results[0]
.value as FirecrackerNetworkLifecycle;
expect(lifecycle.cleanup).toHaveBeenCalledTimes(1);
});

it('refuses to launch without host-side network enforcement', async () => {
const deps = dependencies();
const manager = new FirecrackerManager(config(), '/tmp/awf', deps, 'unsafe');

await expect(manager.start()).rejects.toThrow(/unfiltered microVM/);
expect(deps.preflight).not.toHaveBeenCalled();
expect(deps.launch).not.toHaveBeenCalled();
});

it('cleans up the network before removing the jail', async () => {
const order: string[] = [];
const deps = dependencies({
createNetwork: jest.fn((plan) => ({
plan,
setup: jest.fn().mockResolvedValue(plan),
cleanup: jest.fn(async () => {
order.push('network');
}),
})),
rm: jest.fn(async () => {
order.push('jail');
}),
});
const manager = new FirecrackerManager(
config(),
'/tmp/awf',
deps,
'cleanup',
networkConfig(),
);

await manager.start();
await manager.stop();

expect(order).toEqual(['network', 'jail']);
});

it('retains failed network cleanup for a later stop retry', async () => {
const cleanup = jest.fn()
.mockRejectedValueOnce(new Error('network cleanup failed'))
.mockResolvedValue(undefined);
const deps = dependencies({
createNetwork: jest.fn((plan) => ({
plan,
setup: jest.fn().mockResolvedValue(plan),
cleanup,
})),
});
const manager = new FirecrackerManager(
config(),
'/tmp/awf',
deps,
'cleanup-retry',
networkConfig(),
);

await manager.start();
await expect(manager.stop()).rejects.toThrow('network cleanup failed');
await expect(manager.stop()).resolves.toBeUndefined();

expect(cleanup).toHaveBeenCalledTimes(2);
});

it('rolls back the network when typed NIC configuration fails', async () => {
const client = {
putMachineConfig: jest.fn().mockResolvedValue(undefined),
putBootSource: jest.fn().mockResolvedValue(undefined),
putDrive: jest.fn().mockResolvedValue(undefined),
putNetworkInterface: jest.fn().mockRejectedValue(new Error('invalid NIC')),
} as unknown as FirecrackerApiClient;
const deps = dependencies({
createClient: jest.fn().mockReturnValue(client),
});
const manager = new FirecrackerManager(
config(),
'/tmp/awf',
deps,
'nic-failure',
networkConfig(),
);

await expect(manager.start()).rejects.toThrow('invalid NIC');

const lifecycle = (deps.createNetwork as jest.Mock).mock.results[0]
.value as FirecrackerNetworkLifecycle;
expect(lifecycle.cleanup).toHaveBeenCalledTimes(1);
expect(deps.rm).toHaveBeenCalled();
});

it('fails fast when jailer exits by signal before API readiness', async () => {
Expand All @@ -146,7 +293,13 @@ describe('FirecrackerManager', () => {
access: jest.fn().mockRejectedValue(missing),
sleep: jest.fn().mockResolvedValue(undefined),
});
const manager = new FirecrackerManager(config({ apiTimeoutMs: 2000 }), '/tmp/awf', deps, 'signal');
const manager = new FirecrackerManager(
config({ apiTimeoutMs: 2000 }),
'/tmp/awf',
deps,
'signal',
networkConfig(),
);

await expect(manager.start()).rejects.toThrow(
/exited before API readiness with code null and signal SIGKILL/,
Expand Down
Loading
Loading