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
28 changes: 15 additions & 13 deletions docs/sbx-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,13 +383,15 @@ myvm: {

That entry makes `runtimeUsesComposeAgent('myvm')` return `false`, which omits
its agent from `docker-compose.yml` and skips the Docker network-isolation
override in strict mode. It does **not** select the new manager: the current
main workflow treats every microVM entry as sbx until runtime-specific dispatch is added.
override in strict mode. It does **not** select the new manager: register an
`ExternalRuntimeBackendFactory` in `src/external-runtime-backend-resolver.ts`.

### 2. Implement a manager (mirror `sbx-manager.ts`)
### 2. Implement an external runtime backend

Provide `createSandbox` / `execInSandbox` / `removeSandbox` / `isAvailable`
equivalents for your VMM. Concretely, a KVM backend must:
Implement `ExternalAgentRuntimeBackend` from
`src/external-runtime-backend.ts`, following `SbxRuntimeBackend` in
`src/sbx-runtime-backend.ts`. The backend owns preflight, startup, execution,
diagnostics, and idempotent stop state. Concretely, a KVM backend must:

- **Boot a microVM on `/dev/kvm`** with a kernel + rootfs. Confirm KVM is
available (`/dev/kvm` present, user in the `kvm` group). On stock
Expand Down Expand Up @@ -417,16 +419,16 @@ sandbox egress through AWF's host-side Squid:
- Reproduce the boundary-crossing addressing that the sbx path uses: Squid at the
**bridge gateway IP + published port** (not the internal `172.30.0.x`), and the
api-proxy via a host-reachable name (`host.docker.internal`). See the
`SBX_GATEWAY_IP` / `SBX_HOST_DOCKER_INTERNAL` handling in `main-action.ts`.
`SBX_GATEWAY_IP` / `SBX_HOST_DOCKER_INTERNAL` handling in
`src/sbx-runtime-backend.ts`.

### 4. Wire it into `main-action.ts`
### 4. Register the backend

Introduce runtime-specific manager dispatch keyed by `config.containerRuntime`;
do not gate all microVM backends through the current sbx-specific branch. The
selected manager must provide start/run/cleanup wrappers that (a) start
infra-only compose, (b) build the agent environment with its network targets,
(c) create the VM, (d) check api-proxy and Squid across the boundary, and
(e) execute and tear down the agent with that backend's lifecycle commands.
Add the factory to `EXTERNAL_RUNTIME_BACKENDS` in
`src/external-runtime-backend-resolver.ts`. `main-action.ts` resolves exactly one
backend instance, adapts it to `WorkflowDependencies`, and uses that same
instance for cleanup and signal handling. Compose-managed Docker and gVisor
runtimes bypass this adapter.

### 5. Things to get right (lessons from the sbx path)

Expand Down
13 changes: 4 additions & 9 deletions src/cli-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,11 @@ import { validateEnclavesConfig } from './enclave/preflight';
/**
* Dependencies injected into the main workflow.
*
* These are implemented by `docker-manager.ts` for the Docker Compose backend.
* A future microVM backend (e.g. Docker sbx) would provide alternative
* implementations that:
* - `writeConfigs` — generate compose for infrastructure only (no agent service)
* - `startContainers` — start Squid + api-proxy via compose, then launch agent
* in a microVM with the sbx proxy chaining through host-side Squid/api-proxy
* - `runAgentCommand` — `sbx run` instead of `docker logs -f` + `docker wait`
* - Cleanup — `sbx rm` + `docker compose down` for infrastructure
* These are implemented by `docker-manager.ts` for Docker Compose agents.
* External agent backends adapt their lifecycle to `startContainers` and
* `runAgentCommand` while continuing to use compose for infrastructure.
*/
interface WorkflowDependencies {
export interface WorkflowDependencies {
ensureFirewallNetwork: () => Promise<{ squidIp: string; agentIp: string; proxyIp: string; subnet: string }>;
setupHostIptables: (squidIp: string, port: number, dnsServers: string[], apiProxyIp?: string, dohProxyIp?: string, hostAccess?: HostAccessConfig, cliProxyConfig?: CliProxyHostConfig) => Promise<void>;
writeConfigs: (config: WrapperConfig) => Promise<void>;
Expand Down
22 changes: 22 additions & 0 deletions src/commands/main-action-coverage-gaps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,28 @@ describe('createMainAction coverage gaps', () => {
});
});

describe('sbx signal handling', () => {
it('stops the selected external backend instead of the compose agent', async () => {
const sbxConfig = {
...MAIN_ACTION_STUB_CONFIG,
containerRuntime: 'sbx',
keepContainers: false,
} as unknown as import('../types').WrapperConfig;
mockedValidateOptions.validateOptions.mockReturnValue(sbxConfig);
let signalOptions: Parameters<typeof mockedSignalHandler.registerSignalHandlers>[0] | undefined;
mockedSignalHandler.registerSignalHandlers.mockImplementation((options) => {
signalOptions = options;
});

const action = createMainAction(getOptionValueSource);
await action(['echo hi'], {});
await signalOptions!.fastKillAgentContainer();

expect(mockedSbxManager.removeSandbox).toHaveBeenCalled();
expect(mockedDockerManager.fastKillAgentContainer).not.toHaveBeenCalled();
});
});

describe('sbx cleanup: keepContainers=true skips removeSandbox', () => {
it('does not call removeSandbox when keepContainers is true', async () => {
const sbxConfig = {
Expand Down
28 changes: 28 additions & 0 deletions src/commands/main-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ jest.mock('./signal-handler');
jest.mock('./validate-options');
jest.mock('../sbx-manager');
jest.mock('../enclave/gateway');
jest.mock('../external-runtime-backend-resolver', () => {
const actual = jest.requireActual('../external-runtime-backend-resolver');
return {
...actual,
resolveExternalRuntimeBackend: jest.fn(actual.resolveExternalRuntimeBackend),
};
});

import { logger } from '../logger';
import * as dockerManager from '../docker-manager';
Expand All @@ -33,6 +40,7 @@ import * as signalHandler from './signal-handler';
import * as validateOptions from './validate-options';
import * as sbxManager from '../sbx-manager';
import * as enclaveGateway from '../enclave/gateway';
import * as externalRuntimeResolver from '../external-runtime-backend-resolver';
import { MAIN_ACTION_STUB_CONFIG, setupMainActionTestHarness } from './main-action.test-utils';

const {
Expand All @@ -56,6 +64,7 @@ const mockedSignalHandler = signalHandler as jest.Mocked<typeof signalHandler>;
const mockedValidateOptions = validateOptions as jest.Mocked<typeof validateOptions>;
const mockedSbxManager = sbxManager as jest.Mocked<typeof sbxManager>;
const mockedEnclaveGateway = enclaveGateway as jest.Mocked<typeof enclaveGateway>;
const mockedExternalRuntimeResolver = externalRuntimeResolver as jest.Mocked<typeof externalRuntimeResolver>;

describe('createMainAction', () => {
let processExitSpy: jest.SpyInstance;
Expand Down Expand Up @@ -387,6 +396,25 @@ describe('createMainAction', () => {
expect(mockedHostIptables.cleanupHostIptables).not.toHaveBeenCalled();
expect(processExitSpy).toHaveBeenCalledWith(1);
});

describe('when external runtime resolution fails', () => {
it('uses fatal-error cleanup and exits with code 1', async () => {
mockedExternalRuntimeResolver.resolveExternalRuntimeBackend.mockImplementationOnce(() => {
throw new Error('backend is not registered');
});

const action = createMainAction(getOptionValueSource);
await expect(action(['echo hi'], {})).rejects.toThrow('process.exit: 1');

expect(mockedLogger.error).toHaveBeenCalledWith(
'Fatal error:',
expect.objectContaining({ message: 'backend is not registered' }),
);
expect(mockedDockerManager.cleanup).toHaveBeenCalled();
expect(mockedCliWorkflow.runMainWorkflow).not.toHaveBeenCalled();
expect(processExitSpy).toHaveBeenCalledWith(1);
});
});
});

describe('performCleanup with keepContainers=true', () => {
Expand Down
Loading
Loading