Skip to content
This repository was archived by the owner on May 8, 2026. It is now read-only.
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
126 changes: 24 additions & 102 deletions __tests__/containers.docker.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,13 @@ import fs from 'node:fs';
import { randomUUID } from 'node:crypto';

import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { Server, ServerCredentials, credentials, Metadata, type ServiceError } from '@grpc/grpc-js';
import { create } from '@bufbuild/protobuf';
import { Server, ServerCredentials, credentials } from '@grpc/grpc-js';

import type { RunnerConfig } from '../src/service/config';
import { ContainerService, NonceCache } from '../src';
import { buildAuthHeaders } from '../src/contracts/auth';
import { createRunnerGrpcServer } from '../src/service/grpc/server';
import {
RUNNER_SERVICE_INSPECT_WORKLOAD_PATH,
RUNNER_SERVICE_READY_PATH,
RUNNER_SERVICE_REMOVE_WORKLOAD_PATH,
RUNNER_SERVICE_START_WORKLOAD_PATH,
RUNNER_SERVICE_STOP_WORKLOAD_PATH,
RunnerServiceGrpcClient,
} from '../src/proto/grpc.js';
import {
InspectWorkloadRequestSchema,
ReadyRequestSchema,
RemoveWorkloadRequestSchema,
StopWorkloadRequestSchema,
type InspectWorkloadResponse,
type StartWorkloadResponse,
} from '../src/proto/gen/agynio/api/runner/v1/runner_pb.js';
import { containerOptsToStartWorkloadRequest } from '../src/contracts/workload.grpc';
import { RunnerServiceGrpcClient } from '../src/proto/grpc.js';
import { createGrpcTestClient, type GrpcTestClient } from './helpers/grpc-test-client';

const DEFAULT_SOCKET = process.env.DOCKER_SOCKET ?? '/var/run/docker.sock';
const hasSocket = fs.existsSync(DEFAULT_SOCKET);
Expand All @@ -46,6 +29,7 @@ const RUNNER_SECRET = 'docker-runner-integration-secret';
describeOrSkip('docker-runner docker-backed container lifecycle', () => {
let grpcAddress: string;
let client: InstanceType<typeof RunnerServiceGrpcClient>;
let grpcTestClient: GrpcTestClient;
let shutdown: (() => Promise<void>) | null = null;
const startedContainers = new Set<string>();

Expand All @@ -68,7 +52,8 @@ describeOrSkip('docker-runner docker-backed container lifecycle', () => {
const address = await bindServer(server, config.grpcHost);
grpcAddress = address;
client = new RunnerServiceGrpcClient(address, credentials.createInsecure());
await waitForReady();
grpcTestClient = createGrpcTestClient({ client, secret: RUNNER_SECRET });
await grpcTestClient.ready();
shutdown = async () => {
await new Promise<void>((resolve) => {
server.tryShutdown((err) => {
Expand Down Expand Up @@ -97,12 +82,12 @@ describeOrSkip('docker-runner docker-backed container lifecycle', () => {
afterEach(async () => {
for (const containerId of startedContainers) {
try {
await stopContainer(containerId);
await grpcTestClient.stopContainer(containerId);
} catch (error) {
console.warn(`cleanup stop failed for ${containerId}`, error);
}
try {
await removeContainer(containerId, { force: true, removeVolumes: true });
await grpcTestClient.removeContainer(containerId, { force: true, removeVolumes: true });
} catch (error) {
console.warn(`cleanup remove failed for ${containerId}`, error);
}
Expand All @@ -113,109 +98,46 @@ describeOrSkip('docker-runner docker-backed container lifecycle', () => {
it('starts, inspects, stops, and removes a real container', async () => {
const containerId = await startAlpineContainer('delete-once');

const inspect = await inspectContainer(containerId);
const inspect = await grpcTestClient.inspectContainer(containerId);
expect(inspect.id).toBe(containerId);
expect(inspect.configImage).toContain('alpine');

await deleteContainer(containerId);

await expect(inspectContainer(containerId)).rejects.toMatchObject({ code: 5 });
await expect(grpcTestClient.inspectContainer(containerId)).rejects.toMatchObject({ code: 5 });
}, 120_000);

it('allows delete operations to be invoked twice without failing', async () => {
const containerId = await startAlpineContainer('delete-twice');

await deleteContainer(containerId);
await expect(stopContainer(containerId)).resolves.toBeUndefined();
await expect(removeContainer(containerId, { force: true, removeVolumes: true })).resolves.toBeUndefined();
await expect(grpcTestClient.stopContainer(containerId)).resolves.toBeUndefined();
await expect(
grpcTestClient.removeContainer(containerId, { force: true, removeVolumes: true }),
).resolves.toBeUndefined();
}, 120_000);

async function startAlpineContainer(prefix: string): Promise<string> {
const name = `${prefix}-${randomUUID().slice(0, 8)}`;
const response = await startWorkload({ image: 'alpine:3.19', cmd: ['sleep', '30'], name, autoRemove: false });
if (!response?.containers?.main && !response?.id) {
const response = await grpcTestClient.startWorkload({
image: 'alpine:3.19',
cmd: ['sleep', '30'],
name,
autoRemove: false,
});
if (!response?.id) {
throw new Error('runner start did not return containerId');
}
const containerId = response.containers?.main ?? response.id;
const containerId = response.id;
startedContainers.add(containerId);
return containerId;
}

async function deleteContainer(containerId: string): Promise<void> {
await stopContainer(containerId);
await removeContainer(containerId, { force: true, removeVolumes: true });
await grpcTestClient.stopContainer(containerId);
await grpcTestClient.removeContainer(containerId, { force: true, removeVolumes: true });
startedContainers.delete(containerId);
}

async function startWorkload(opts: { image: string; cmd: string[]; name: string; autoRemove: boolean }): Promise<StartWorkloadResponse> {
const request = containerOptsToStartWorkloadRequest({
image: opts.image,
cmd: opts.cmd,
name: opts.name,
autoRemove: opts.autoRemove,
});
return unary(RUNNER_SERVICE_START_WORKLOAD_PATH, request, (req, metadata, callback) => {
client.startWorkload(req, metadata, callback);
});
}

async function stopContainer(containerId: string) {
const request = create(StopWorkloadRequestSchema, { workloadId: containerId, timeoutSec: 1 });
await unary(RUNNER_SERVICE_STOP_WORKLOAD_PATH, request, (req, metadata, callback) => {
client.stopWorkload(req, metadata, callback);
});
}

async function removeContainer(containerId: string, options: { force?: boolean; removeVolumes?: boolean }) {
const request = create(RemoveWorkloadRequestSchema, {
workloadId: containerId,
force: options.force ?? false,
removeVolumes: options.removeVolumes ?? false,
});
await unary(RUNNER_SERVICE_REMOVE_WORKLOAD_PATH, request, (req, metadata, callback) => {
client.removeWorkload(req, metadata, callback);
});
}

async function inspectContainer(containerId: string): Promise<InspectWorkloadResponse> {
const request = create(InspectWorkloadRequestSchema, { workloadId: containerId });
return unary(RUNNER_SERVICE_INSPECT_WORKLOAD_PATH, request, (req, metadata, callback) => {
client.inspectWorkload(req, metadata, callback);
});
}

function metadataFor(path: string): Metadata {
const headers = buildAuthHeaders({ method: 'POST', path, body: '', secret: RUNNER_SECRET });
const metadata = new Metadata();
for (const [key, value] of Object.entries(headers)) {
metadata.set(key, value);
}
return metadata;
}

async function unary<Request, Response>(
path: string,
request: Request,
invoke: (req: Request, metadata: Metadata, callback: (err: ServiceError | null, response?: Response) => void) => void,
): Promise<Response> {
const metadata = metadataFor(path);
return new Promise<Response>((resolve, reject) => {
invoke(request, metadata, (err, response) => {
if (err) {
reject(err);
return;
}
resolve(response as Response);
});
});
}

async function waitForReady(): Promise<void> {
const request = create(ReadyRequestSchema, {});
await unary(RUNNER_SERVICE_READY_PATH, request, (req, metadata, callback) => {
client.ready(req, metadata, callback);
});
}
});

async function bindServer(server: Server, host: string): Promise<string> {
Expand Down
120 changes: 120 additions & 0 deletions __tests__/e2e/docker-runner.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { randomUUID } from 'node:crypto';

import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { credentials, status } from '@grpc/grpc-js';

import { RunnerServiceGrpcClient, type RunnerServiceGrpcClientInstance } from '../../src/proto/grpc.js';
import { createGrpcTestClient, type GrpcTestClient } from '../helpers/grpc-test-client';

const grpcAddress = process.env.DOCKER_RUNNER_GRPC_URL ?? 'localhost:50051';
const sharedSecret = process.env.DOCKER_RUNNER_SHARED_SECRET;

if (!sharedSecret) {
throw new Error('DOCKER_RUNNER_SHARED_SECRET is required for e2e tests');
}

describe('docker-runner e2e', () => {
let client: RunnerServiceGrpcClientInstance;
let grpcTestClient: GrpcTestClient;
const startedContainers = new Set<string>();

beforeAll(async () => {
client = new RunnerServiceGrpcClient(grpcAddress, credentials.createInsecure());
grpcTestClient = createGrpcTestClient({ client, secret: sharedSecret });
await grpcTestClient.ready();
}, 30_000);

afterAll(() => {
client.close();
});

afterEach(async () => {
for (const containerId of startedContainers) {
try {
await grpcTestClient.stopContainer(containerId);
} catch (error) {
console.warn(`cleanup stop failed for ${containerId}`, error);
}
try {
await grpcTestClient.removeContainer(containerId, { force: true, removeVolumes: true });
} catch (error) {
console.warn(`cleanup remove failed for ${containerId}`, error);
}
}
startedContainers.clear();
});

it('ready health check', async () => {
const response = await grpcTestClient.ready();
expect(response).toBeDefined();
});

it('starts a container', async () => {
const containerId = await startAlpineContainer('start-only');
expect(containerId).toBeTruthy();
});

it('inspects after start', async () => {
const containerId = await startAlpineContainer('inspect');
const inspect = await grpcTestClient.inspectContainer(containerId);
expect(inspect.id).toBe(containerId);
expect(inspect.configImage).toContain('alpine');
});

it('stops a running container', async () => {
const containerId = await startAlpineContainer('stop');
await grpcTestClient.stopContainer(containerId);
const inspect = await grpcTestClient.inspectContainer(containerId);
expect(inspect.id).toBe(containerId);
expect(inspect.stateRunning).toBe(false);
expect(inspect.stateStatus).not.toBe('running');
});

it('removes a stopped container', async () => {
const containerId = await startAlpineContainer('remove');
await grpcTestClient.stopContainer(containerId);
await grpcTestClient.removeContainer(containerId, { force: true, removeVolumes: true });
await expect(grpcTestClient.inspectContainer(containerId)).rejects.toMatchObject({ code: status.NOT_FOUND });
});

it('runs the full lifecycle', async () => {
const containerId = await startAlpineContainer('lifecycle');
const inspect = await grpcTestClient.inspectContainer(containerId);
expect(inspect.id).toBe(containerId);
expect(inspect.configImage).toContain('alpine');
await grpcTestClient.stopContainer(containerId);
await grpcTestClient.removeContainer(containerId, { force: true, removeVolumes: true });
await expect(grpcTestClient.inspectContainer(containerId)).rejects.toMatchObject({ code: status.NOT_FOUND });
});

it('allows idempotent stop', async () => {
const containerId = await startAlpineContainer('stop-twice');
await grpcTestClient.stopContainer(containerId);
await expect(grpcTestClient.stopContainer(containerId)).resolves.toBeUndefined();
});

it('allows idempotent remove', async () => {
const containerId = await startAlpineContainer('remove-twice');
await grpcTestClient.stopContainer(containerId);
await grpcTestClient.removeContainer(containerId, { force: true, removeVolumes: true });
await expect(
grpcTestClient.removeContainer(containerId, { force: true, removeVolumes: true }),
).resolves.toBeUndefined();
});

async function startAlpineContainer(prefix: string): Promise<string> {
const name = `${prefix}-${randomUUID().slice(0, 8)}`;
const response = await grpcTestClient.startWorkload({
image: 'alpine:3.19',
cmd: ['sleep', '30'],
name,
autoRemove: false,
});
if (!response?.id) {
throw new Error('runner start did not return containerId');
}
const containerId = response.id;
startedContainers.add(containerId);
Comment thread
noa-lucent marked this conversation as resolved.
return containerId;
}
});
Loading
Loading