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
77 changes: 45 additions & 32 deletions src/cloud-hypervisor-runtime-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { PassThrough } from 'stream';
import type { WrapperConfig } from './types';
import * as hostEligibility from './cloud-hypervisor/host-eligibility';
import {
CloudHypervisorRuntimeBackend,
assertCloudHypervisorPreSecurityCompatibility,
buildCloudHypervisorGuestEnvironment,
cloudHypervisorRuntimeTestHelpers,
Expand Down Expand Up @@ -59,6 +58,20 @@ function config(overrides: Partial<WrapperConfig> = {}): WrapperConfig {
} as WrapperConfig;
}

type TestCloudHypervisorRuntimeBackend = ReturnType<typeof createCloudHypervisorRuntimeBackend> & {
preserve(): Promise<void>;
};

function createBackend(
backendConfig: WrapperConfig,
deps: CloudHypervisorRuntimeBackendDependencies,
): TestCloudHypervisorRuntimeBackend {
return cloudHypervisorRuntimeTestHelpers.createBackendWithDependencies(
backendConfig,
deps,
) as TestCloudHypervisorRuntimeBackend;
}

function infrastructure(): MicrovmInfrastructureSnapshot {
return {
networkId: 'a'.repeat(64),
Expand Down Expand Up @@ -222,7 +235,7 @@ describe('Cloud Hypervisor runtime backend', () => {
{ uid: 1000, gid: 1000 },
)).toBeDefined();
expect(createCloudHypervisorRuntimeBackend(config(), startInfrastructure))
.toBeInstanceOf(CloudHypervisorRuntimeBackend);
.toEqual(expect.objectContaining({ runtime: 'cloud-hypervisor' }));
} finally {
if (previousWorkspace === undefined) delete process.env.GITHUB_WORKSPACE;
else process.env.GITHUB_WORKSPACE = previousWorkspace;
Expand All @@ -243,7 +256,7 @@ describe('Cloud Hypervisor runtime backend', () => {
{ tag: 'tmp-gh-aw', source: ghAwSource, target: '/tmp/gh-aw', mode: 'rw' as const },
];
const { deps } = harness({ resolveExports: jest.fn().mockResolvedValue(resolved) });
const backend = new CloudHypervisorRuntimeBackend(
const backend = createBackend(
config({ filesystemAllowWrite: ['/tmp/gh-aw/agent'] }),
deps,
);
Expand Down Expand Up @@ -280,7 +293,7 @@ describe('Cloud Hypervisor runtime backend', () => {

it('passes no enforcement argument when filesystem.allowWrite is undefined', async () => {
const { deps } = harness();
const backend = new CloudHypervisorRuntimeBackend(config(), deps);
const backend = createBackend(config(), deps);

await backend.start('/tmp/awf', ['github.com']);

Expand All @@ -294,7 +307,7 @@ describe('Cloud Hypervisor runtime backend', () => {

it('fails closed on an unmatched allowlist path before creating a manager', async () => {
const { deps } = harness();
const backend = new CloudHypervisorRuntimeBackend(
const backend = createBackend(
config({ filesystemAllowWrite: ['/workspace/does-not-exist'] }),
deps,
);
Expand All @@ -306,7 +319,7 @@ describe('Cloud Hypervisor runtime backend', () => {

it('starts infrastructure, revalidates it, boots and probes before execution', async () => {
const { order, manager, deps, stdin } = harness();
const backend = new CloudHypervisorRuntimeBackend(config(), deps);
const backend = createBackend(config(), deps);

await backend.start('/tmp/awf', ['github.com']);
const execution = backend.exec('/tmp/awf', ['github.com'], undefined, 1);
Expand Down Expand Up @@ -345,7 +358,7 @@ describe('Cloud Hypervisor runtime backend', () => {
topologyPeerIps: { 'awmg-mcpg': '172.30.0.60' },
};
(deps.resolveInfrastructure as jest.Mock).mockResolvedValue(peerInfrastructure);
const backend = new CloudHypervisorRuntimeBackend(
const backend = createBackend(
config({ topologyAttach: ['awmg-mcpg'] }),
deps,
);
Expand Down Expand Up @@ -378,7 +391,7 @@ describe('Cloud Hypervisor runtime backend', () => {
throw new Error('Cloud Hypervisor is supported only inside GitHub Actions runs');
});
const { deps } = harness();
const backend = new CloudHypervisorRuntimeBackend(config(), deps);
const backend = createBackend(config(), deps);

await expect(backend.start('/tmp/awf', ['github.com']))
.rejects.toThrow(/supported only inside GitHub Actions runs/);
Expand All @@ -387,7 +400,7 @@ describe('Cloud Hypervisor runtime backend', () => {

it('rejects timeouts beyond the guest supervisor limit before infrastructure startup', async () => {
const { deps } = harness();
const backend = new CloudHypervisorRuntimeBackend(config({ agentTimeout: 1441 }), deps);
const backend = createBackend(config({ agentTimeout: 1441 }), deps);

await expect(backend.start('/tmp/awf', ['github.com']))
.rejects.toThrow(/up to 1440 minutes/);
Expand All @@ -401,7 +414,7 @@ describe('Cloud Hypervisor runtime backend', () => {
manager.writeStdin.mockImplementationOnce(() => new Promise<void>((resolve) => {
releaseFirstWrite = resolve;
}));
const backend = new CloudHypervisorRuntimeBackend(config(), deps);
const backend = createBackend(config(), deps);
await backend.start('/tmp/awf', ['github.com']);
const execution = backend.exec('/tmp/awf', ['github.com']);
stdin.write(Buffer.alloc(70_000, 1));
Expand Down Expand Up @@ -432,7 +445,7 @@ describe('Cloud Hypervisor runtime backend', () => {
signal: null,
timedOut: false,
}));
const backend = new CloudHypervisorRuntimeBackend(config(), deps);
const backend = createBackend(config(), deps);

await expect(backend.start('/tmp/awf', ['github.com']))
.rejects.toThrow(/connectivity probe failed/);
Expand All @@ -444,7 +457,7 @@ describe('Cloud Hypervisor runtime backend', () => {

it('waits with bounded backoff for the complete guest data plane before probing connectivity', async () => {
const { manager, deps } = harness();
const backend = new CloudHypervisorRuntimeBackend(config(), deps);
const backend = createBackend(config(), deps);

await backend.start('/tmp/awf', ['github.com']);

Expand Down Expand Up @@ -474,7 +487,7 @@ describe('Cloud Hypervisor runtime backend', () => {
signal: null,
timedOut: false,
});
const backend = new CloudHypervisorRuntimeBackend(config(), deps);
const backend = createBackend(config(), deps);

await expect(backend.start('/tmp/awf', ['github.com'])).rejects.toMatchObject({
code: 'CLOUD_HYPERVISOR_RETRYABLE_READINESS',
Expand Down Expand Up @@ -507,7 +520,7 @@ describe('Cloud Hypervisor runtime backend', () => {
signal: null,
timedOut: false,
}));
const backend = new CloudHypervisorRuntimeBackend(config(), deps);
const backend = createBackend(config(), deps);

await expect(backend.start('/tmp/awf', ['github.com']))
.rejects.toThrow(/connectivity probe failed/);
Expand Down Expand Up @@ -537,7 +550,7 @@ describe('Cloud Hypervisor runtime backend', () => {
manager.stop.mockImplementation(async (options?: { beforeCleanup?: () => Promise<void> }) => {
await options?.beforeCleanup?.();
});
const backend = new CloudHypervisorRuntimeBackend(
const backend = createBackend(
config({ diagnosticLogs: true } as Partial<WrapperConfig>),
deps,
);
Expand Down Expand Up @@ -570,7 +583,7 @@ describe('Cloud Hypervisor runtime backend', () => {
}
throw new Error(`unexpected request ${request.requestId}`);
});
const backend = new CloudHypervisorRuntimeBackend(config(), deps);
const backend = createBackend(config(), deps);

await expect(backend.start('/tmp/awf', ['github.com'])).rejects.toThrow(
/connectivity probe failed with exit code 1 \(stderr: wget: can't connect to remote host: Connection refused\)/,
Expand All @@ -594,7 +607,7 @@ describe('Cloud Hypervisor runtime backend', () => {
}
return { requestId: request.requestId, exitCode: 1, signal: null, timedOut: false };
});
const backend = new CloudHypervisorRuntimeBackend(config(), deps);
const backend = createBackend(config(), deps);

await expect(backend.start('/tmp/awf', ['github.com'])).rejects.toThrow(
/guest network state: 1: lo: <LOOPBACK,UP>/,
Expand All @@ -616,7 +629,7 @@ describe('Cloud Hypervisor runtime backend', () => {
}).mockResolvedValueOnce({
requestId: 'agent', exitCode: 0, signal: null, timedOut: false,
});
const backend = new CloudHypervisorRuntimeBackend(
const backend = createBackend(
config({ enableApiProxy: true } as Partial<WrapperConfig>),
deps,
);
Expand Down Expand Up @@ -663,7 +676,7 @@ describe('Cloud Hypervisor runtime backend', () => {
}).mockResolvedValueOnce({
requestId: 'agent', exitCode: 0, signal: null, timedOut: false,
});
const backend = new CloudHypervisorRuntimeBackend(
const backend = createBackend(
config({ enableApiProxy: true } as Partial<WrapperConfig>),
deps,
);
Expand Down Expand Up @@ -701,7 +714,7 @@ describe('Cloud Hypervisor runtime backend', () => {
order.push('stop-directory-removed');
});
manager.startInstance.mockRejectedValue(new Error('guest disconnected before readiness'));
const backend = new CloudHypervisorRuntimeBackend(
const backend = createBackend(
config({ diagnosticLogs: true } as Partial<WrapperConfig>),
deps,
);
Expand All @@ -719,7 +732,7 @@ describe('Cloud Hypervisor runtime backend', () => {
it('preserves boot-attempt diagnostics even when --diagnostic-logs is unset', async () => {
const { manager, deps } = harness();
manager.startInstance.mockRejectedValue(new Error('guest disconnected before readiness'));
const backend = new CloudHypervisorRuntimeBackend(
const backend = createBackend(
config({ diagnosticLogs: false } as Partial<WrapperConfig>),
deps,
);
Expand All @@ -742,7 +755,7 @@ describe('Cloud Hypervisor runtime backend', () => {
await options?.beforeCleanup?.();
});
manager.startInstance.mockRejectedValue(new Error('guest disconnected before readiness'));
const backend = new CloudHypervisorRuntimeBackend(
const backend = createBackend(
config({ diagnosticLogs: true } as Partial<WrapperConfig>),
deps,
);
Expand All @@ -755,15 +768,15 @@ describe('Cloud Hypervisor runtime backend', () => {
it('fails closed when manager readiness or startup cleanup is unavailable', async () => {
const missingIp = harness();
Reflect.set(missingIp.manager, 'guestIp', undefined);
const backend = new CloudHypervisorRuntimeBackend(config(), missingIp.deps);
const backend = createBackend(config(), missingIp.deps);
await expect(backend.start('/tmp/awf', ['github.com']))
.rejects.toThrow(/did not expose the configured guest network plan/);
expect(missingIp.manager.stop).toHaveBeenCalledTimes(1);

const dualFailure = harness();
dualFailure.manager.start.mockRejectedValue('VMM configuration failed');
dualFailure.manager.stop.mockRejectedValue('cleanup failed');
const failing = new CloudHypervisorRuntimeBackend(config(), dualFailure.deps);
const failing = createBackend(config(), dualFailure.deps);
await expect(failing.start('/tmp/awf', ['github.com'])).rejects.toMatchObject({
message: expect.stringContaining('VMM configuration failed'),
cause: 'VMM configuration failed',
Expand All @@ -786,7 +799,7 @@ describe('Cloud Hypervisor runtime backend', () => {
}
return { requestId: request.requestId, exitCode: 0, signal: null, timedOut: false };
});
const backend = new CloudHypervisorRuntimeBackend(config(), deps);
const backend = createBackend(config(), deps);

await expect(backend.start('/tmp/awf', ['github.com'])).resolves.toBeUndefined();

Expand All @@ -806,7 +819,7 @@ describe('Cloud Hypervisor runtime backend', () => {
signal: null,
timedOut: false,
}));
const backend = new CloudHypervisorRuntimeBackend(config(), deps);
const backend = createBackend(config(), deps);

await expect(backend.start('/tmp/awf', ['github.com']))
.rejects.toThrow(/connectivity configuration is invalid/);
Expand All @@ -817,7 +830,7 @@ describe('Cloud Hypervisor runtime backend', () => {

it('does not retry a failed wrapped command after execution starts', async () => {
const { deps, stdin } = harness();
const backend = new CloudHypervisorRuntimeBackend(config(), deps);
const backend = createBackend(config(), deps);
await backend.start('/tmp/awf', ['github.com']);

const execution = backend.exec('/tmp/awf', ['github.com']);
Expand All @@ -830,14 +843,14 @@ describe('Cloud Hypervisor runtime backend', () => {

it('rejects execution before readiness and unsupported TTY execution', async () => {
const cold = harness();
await expect(new CloudHypervisorRuntimeBackend(config(), cold.deps).exec(
await expect(createBackend(config(), cold.deps).exec(
'/tmp/awf',
['github.com'],
)).rejects.toThrow(/microVM is not ready/);

const ttyHarness = harness();
const ttyConfig = config();
const ttyBackend = new CloudHypervisorRuntimeBackend(ttyConfig, ttyHarness.deps);
const ttyBackend = createBackend(ttyConfig, ttyHarness.deps);
await ttyBackend.start('/tmp/awf', ['github.com']);
ttyConfig.tty = true;
await expect(ttyBackend.exec('/tmp/awf', ['github.com']))
Expand All @@ -847,7 +860,7 @@ describe('Cloud Hypervisor runtime backend', () => {

it('preserves a stopped VM once and logs retained artifacts', async () => {
const { manager, deps } = harness();
const backend = new CloudHypervisorRuntimeBackend(config(), deps);
const backend = createBackend(config(), deps);
await backend.start('/tmp/awf', ['github.com']);

await backend.preserve();
Expand All @@ -872,7 +885,7 @@ describe('Cloud Hypervisor runtime backend', () => {
timedOut: false,
});
});
const backend = new CloudHypervisorRuntimeBackend(config(), deps);
const backend = createBackend(config(), deps);
await backend.start('/tmp/awf', ['github.com']);
const execution = backend.exec('/tmp/awf', ['github.com']);

Expand All @@ -889,7 +902,7 @@ describe('Cloud Hypervisor runtime backend', () => {
it('cancels after stdin forwarding failure without changing command output', async () => {
const { manager, deps, stdin } = harness();
manager.writeStdin.mockRejectedValueOnce(new Error('closed stdin'));
const backend = new CloudHypervisorRuntimeBackend(config(), deps);
const backend = createBackend(config(), deps);
await backend.start('/tmp/awf', ['github.com']);
const execution = backend.exec('/tmp/awf', ['github.com']);
stdin.write('input');
Expand Down
19 changes: 14 additions & 5 deletions src/cloud-hypervisor-runtime-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,19 @@ function defaultDependencies(
};
}

function createBackendWithDependencies(
config: WrapperConfig,
dependencies: CloudHypervisorRuntimeBackendDependencies,
): ExternalAgentRuntimeBackend {
return new CloudHypervisorRuntimeBackend(config, dependencies);
}

/** @internal Exposed only for focused default-policy tests. */
// ts-prune-ignore-next
export const cloudHypervisorRuntimeTestHelpers = { defaultDependencies };
export const cloudHypervisorRuntimeTestHelpers = {
defaultDependencies,
createBackendWithDependencies,
};

type CloudHypervisorReadinessStage =
| 'guest-network-readiness'
Expand Down Expand Up @@ -203,11 +213,10 @@ export class CloudHypervisorRetryableReadinessError extends Error {
/**
* Stateful adapter for an explicitly enabled, fail-closed Cloud Hypervisor microVM.
*
* @internal Exported only so unit tests can construct instances directly with
* injected dependencies. Production code must go through
* Private implementation detail. Production code must go through
* {@link createCloudHypervisorRuntimeBackend} instead.
*/
export class CloudHypervisorRuntimeBackend implements ExternalAgentRuntimeBackend {
class CloudHypervisorRuntimeBackend implements ExternalAgentRuntimeBackend {
readonly runtime = 'cloud-hypervisor';

private manager: CloudHypervisorManagerAdapter | undefined;
Expand Down Expand Up @@ -931,6 +940,6 @@ function createBoundedOutputCollector(maxBytes = 4096): {
export function createCloudHypervisorRuntimeBackend(
config: WrapperConfig,
startInfrastructure: WorkflowDependencies['startContainers'],
): CloudHypervisorRuntimeBackend {
): ExternalAgentRuntimeBackend {
return new CloudHypervisorRuntimeBackend(config, defaultDependencies(startInfrastructure));
}
Loading