From fc674e1c005f321814238b6b6b5c20e39ce76ec0 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Fri, 13 Mar 2026 14:07:48 +0000 Subject: [PATCH 1/3] feat: add devspace and e2e setup --- __tests__/e2e/docker-runner.e2e.test.ts | 220 ++++++++++++++++++++++++ devspace.yaml | 101 +++++++++++ package.json | 1 + vitest.config.e2e.ts | 12 ++ vitest.config.ts | 1 + 5 files changed, 335 insertions(+) create mode 100644 __tests__/e2e/docker-runner.e2e.test.ts create mode 100644 devspace.yaml create mode 100644 vitest.config.e2e.ts diff --git a/__tests__/e2e/docker-runner.e2e.test.ts b/__tests__/e2e/docker-runner.e2e.test.ts new file mode 100644 index 0000000..4aa67df --- /dev/null +++ b/__tests__/e2e/docker-runner.e2e.test.ts @@ -0,0 +1,220 @@ +import { randomUUID } from 'node:crypto'; + +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { Metadata, credentials, status, type ServiceError } from '@grpc/grpc-js'; +import { create } from '@bufbuild/protobuf'; + +import { buildAuthHeaders } from '../../src/contracts/auth'; +import { containerOptsToStartWorkloadRequest } from '../../src/contracts/workload.grpc'; +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, + type RunnerServiceGrpcClientInstance, +} 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'; + +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; + const startedContainers = new Set(); + + beforeAll(async () => { + client = new RunnerServiceGrpcClient(grpcAddress, credentials.createInsecure()); + await waitForReady(); + }, 30_000); + + afterAll(() => { + client.close(); + }); + + afterEach(async () => { + for (const containerId of startedContainers) { + try { + await stopContainer(containerId); + } catch (error) { + console.warn(`cleanup stop failed for ${containerId}`, error); + } + try { + await 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 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 inspectContainer(containerId); + expect(inspect.id).toBe(containerId); + expect(inspect.configImage).toContain('alpine'); + }); + + it('stops a running container', async () => { + const containerId = await startAlpineContainer('stop'); + await stopContainer(containerId); + const inspect = await inspectContainer(containerId); + expect(inspect.id).toBe(containerId); + }); + + it('removes a stopped container', async () => { + const containerId = await startAlpineContainer('remove'); + await stopContainer(containerId); + await removeContainer(containerId, { force: true, removeVolumes: true }); + await expect(inspectContainer(containerId)).rejects.toMatchObject({ code: status.NOT_FOUND }); + }); + + it('runs the full lifecycle', async () => { + const containerId = await startAlpineContainer('lifecycle'); + const inspect = await inspectContainer(containerId); + expect(inspect.id).toBe(containerId); + expect(inspect.configImage).toContain('alpine'); + await stopContainer(containerId); + await removeContainer(containerId, { force: true, removeVolumes: true }); + await expect(inspectContainer(containerId)).rejects.toMatchObject({ code: status.NOT_FOUND }); + }); + + it('allows idempotent stop', async () => { + const containerId = await startAlpineContainer('stop-twice'); + await stopContainer(containerId); + await expect(stopContainer(containerId)).resolves.toBeUndefined(); + }); + + it('allows idempotent remove', async () => { + const containerId = await startAlpineContainer('remove-twice'); + await stopContainer(containerId); + await removeContainer(containerId, { force: true, removeVolumes: true }); + await expect( + removeContainer(containerId, { force: true, removeVolumes: true }), + ).resolves.toBeUndefined(); + }); + + async function startAlpineContainer(prefix: string): Promise { + 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) { + throw new Error('runner start did not return containerId'); + } + const containerId = response.containers?.main ?? response.id; + startedContainers.add(containerId); + return containerId; + } + + async function startWorkload(opts: { + image: string; + cmd: string[]; + name: string; + autoRemove: boolean; + }): Promise { + 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): Promise { + 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 }, + ): Promise { + 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 { + const request = create(InspectWorkloadRequestSchema, { workloadId: containerId }); + return unary(RUNNER_SERVICE_INSPECT_WORKLOAD_PATH, request, (req, metadata, callback) => { + client.inspectWorkload(req, metadata, callback); + }); + } + + async function ready() { + const request = create(ReadyRequestSchema, {}); + return unary(RUNNER_SERVICE_READY_PATH, request, (req, metadata, callback) => { + client.ready(req, metadata, callback); + }); + } + + function metadataFor(path: string): Metadata { + const headers = buildAuthHeaders({ method: 'POST', path, body: '', secret: sharedSecret }); + const metadata = new Metadata(); + for (const [key, value] of Object.entries(headers)) { + metadata.set(key, value); + } + return metadata; + } + + async function unary( + path: string, + request: Request, + invoke: ( + req: Request, + metadata: Metadata, + callback: (err: ServiceError | null, response?: Response) => void, + ) => void, + ): Promise { + const metadata = metadataFor(path); + return new Promise((resolve, reject) => { + invoke(request, metadata, (err, response) => { + if (err) { + reject(err); + return; + } + resolve(response as Response); + }); + }); + } + + async function waitForReady(): Promise { + await ready(); + } +}); diff --git a/devspace.yaml b/devspace.yaml new file mode 100644 index 0000000..91ccbf2 --- /dev/null +++ b/devspace.yaml @@ -0,0 +1,101 @@ +version: v2beta1 + +vars: + DOCKER_RUNNER_NAMESPACE: platform + +pipelines: + dev: |- + if kubectl get application docker-runner -n argocd >/dev/null 2>&1; then + echo "Disabling ArgoCD auto-sync for docker-runner..." + kubectl patch application docker-runner -n argocd \ + --type merge \ + -p '{"spec":{"syncPolicy":{"automated":null}}}' + echo "ArgoCD auto-sync disabled." + else + echo "WARNING: ArgoCD Application 'docker-runner' not found in argocd namespace." + fi + echo "Patching docker-runner deployment for DevSpace..." + kubectl patch deployment docker-runner -n ${DOCKER_RUNNER_NAMESPACE} --type json --patch "$(cat <<'EOF' + [ + { + "op": "add", + "path": "/spec/template/spec/containers/0/image", + "value": "ghcr.io/agynio/devcontainer-node:1" + }, + { + "op": "add", + "path": "/spec/template/spec/containers/0/workingDir", + "value": "/opt/app/data" + }, + { + "op": "add", + "path": "/spec/template/spec/containers/0/command", + "value": [ + "sh", + "-c", + "set -eu\nelapsed=0\nwhile [ ! -f /opt/app/data/package.json ]; do\n sleep 1; elapsed=$((elapsed + 1))\n [ \"$elapsed\" -ge 120 ] && { echo \"ERROR: sync timeout\" >&2; exit 1; }\ndone\npnpm install --frozen-lockfile\npnpm proto:generate\nexec tsx watch src/service/main.ts\n" + ] + }, + { + "op": "add", + "path": "/spec/template/spec/containers/0/args", + "value": [] + }, + { + "op": "add", + "path": "/spec/template/spec/containers/0/resources", + "value": { + "requests": { + "cpu": "500m", + "memory": "1Gi" + }, + "limits": { + "cpu": "2", + "memory": "4Gi" + } + } + }, + { + "op": "add", + "path": "/spec/template/spec/containers/0/securityContext/readOnlyRootFilesystem", + "value": false + } + ] + EOF + )" + start_dev --disable-pod-replace docker-runner + +hooks: + - name: restore-argocd-auto-sync + events: + - after:dev:docker-runner + command: sh + args: + - -c + - | + if kubectl get application docker-runner -n argocd >/dev/null 2>&1; then + echo "Re-enabling ArgoCD auto-sync for docker-runner..." + kubectl patch application docker-runner -n argocd \ + --type merge \ + -p '{"spec":{"syncPolicy":{"automated":{"prune":true,"selfHeal":true}}}}' + echo "ArgoCD auto-sync restored." + fi + +dev: + docker-runner: + namespace: ${DOCKER_RUNNER_NAMESPACE} + labelSelector: + app.kubernetes.io/name: docker-runner + app.kubernetes.io/instance: docker-runner + containers: + docker-runner: + container: docker-runner + sync: + - path: ./:/opt/app/data + excludePaths: + - .git/ + - node_modules/ + - dist/ + - .devspace/ + ports: + - port: "50051" diff --git a/package.json b/package.json index 662f308..aa96d10 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "start": "node dist/service/main.js", "lint": "eslint .", "test": "vitest run", + "test:e2e": "vitest run --config vitest.config.e2e.ts", "proto:generate": "buf generate buf.build/agynio/api" }, "dependencies": { diff --git a/vitest.config.e2e.ts b/vitest.config.e2e.ts new file mode 100644 index 0000000..ec02a6d --- /dev/null +++ b/vitest.config.e2e.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + include: ['__tests__/e2e/**/*.e2e.test.ts'], + testTimeout: 120_000, + hookTimeout: 30_000, + coverage: { enabled: false }, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index df327c6..0ffad7c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ environment: 'node', globals: true, include: ['__tests__/**/*.test.ts'], + exclude: ['__tests__/e2e/**'], hookTimeout: 60_000, coverage: { enabled: false }, }, From f19867bae37537f63d3e6bfa07196eed31ceed5b Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Fri, 13 Mar 2026 14:19:23 +0000 Subject: [PATCH 2/3] refactor: share grpc test helpers --- .../containers.docker.integration.test.ts | 126 +++----------- __tests__/e2e/docker-runner.e2e.test.ts | 158 ++++-------------- __tests__/helpers/grpc-test-client.ts | 144 ++++++++++++++++ 3 files changed, 197 insertions(+), 231 deletions(-) create mode 100644 __tests__/helpers/grpc-test-client.ts diff --git a/__tests__/containers.docker.integration.test.ts b/__tests__/containers.docker.integration.test.ts index ee183fe..8c33788 100644 --- a/__tests__/containers.docker.integration.test.ts +++ b/__tests__/containers.docker.integration.test.ts @@ -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); @@ -46,6 +29,7 @@ const RUNNER_SECRET = 'docker-runner-integration-secret'; describeOrSkip('docker-runner docker-backed container lifecycle', () => { let grpcAddress: string; let client: InstanceType; + let grpcTestClient: GrpcTestClient; let shutdown: (() => Promise) | null = null; const startedContainers = new Set(); @@ -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((resolve) => { server.tryShutdown((err) => { @@ -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); } @@ -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 { 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 { - 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 { - 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 { - 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( - path: string, - request: Request, - invoke: (req: Request, metadata: Metadata, callback: (err: ServiceError | null, response?: Response) => void) => void, - ): Promise { - const metadata = metadataFor(path); - return new Promise((resolve, reject) => { - invoke(request, metadata, (err, response) => { - if (err) { - reject(err); - return; - } - resolve(response as Response); - }); - }); - } - - async function waitForReady(): Promise { - 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 { diff --git a/__tests__/e2e/docker-runner.e2e.test.ts b/__tests__/e2e/docker-runner.e2e.test.ts index 4aa67df..6af5d07 100644 --- a/__tests__/e2e/docker-runner.e2e.test.ts +++ b/__tests__/e2e/docker-runner.e2e.test.ts @@ -1,28 +1,10 @@ import { randomUUID } from 'node:crypto'; import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; -import { Metadata, credentials, status, type ServiceError } from '@grpc/grpc-js'; -import { create } from '@bufbuild/protobuf'; - -import { buildAuthHeaders } from '../../src/contracts/auth'; -import { containerOptsToStartWorkloadRequest } from '../../src/contracts/workload.grpc'; -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, - type RunnerServiceGrpcClientInstance, -} 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 { 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; @@ -33,11 +15,13 @@ if (!sharedSecret) { describe('docker-runner e2e', () => { let client: RunnerServiceGrpcClientInstance; + let grpcTestClient: GrpcTestClient; const startedContainers = new Set(); beforeAll(async () => { client = new RunnerServiceGrpcClient(grpcAddress, credentials.createInsecure()); - await waitForReady(); + grpcTestClient = createGrpcTestClient({ client, secret: sharedSecret }); + await grpcTestClient.ready(); }, 30_000); afterAll(() => { @@ -47,12 +31,12 @@ describe('docker-runner e2e', () => { 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); } @@ -72,149 +56,65 @@ describe('docker-runner e2e', () => { it('inspects after start', async () => { const containerId = await startAlpineContainer('inspect'); - const inspect = await inspectContainer(containerId); + 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 stopContainer(containerId); - const inspect = await inspectContainer(containerId); + 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 stopContainer(containerId); - await removeContainer(containerId, { force: true, removeVolumes: true }); - await expect(inspectContainer(containerId)).rejects.toMatchObject({ code: status.NOT_FOUND }); + 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 inspectContainer(containerId); + const inspect = await grpcTestClient.inspectContainer(containerId); expect(inspect.id).toBe(containerId); expect(inspect.configImage).toContain('alpine'); - await stopContainer(containerId); - await removeContainer(containerId, { force: true, removeVolumes: true }); - await expect(inspectContainer(containerId)).rejects.toMatchObject({ code: status.NOT_FOUND }); + 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 stopContainer(containerId); - await expect(stopContainer(containerId)).resolves.toBeUndefined(); + await grpcTestClient.stopContainer(containerId); + await expect(grpcTestClient.stopContainer(containerId)).resolves.toBeUndefined(); }); it('allows idempotent remove', async () => { const containerId = await startAlpineContainer('remove-twice'); - await stopContainer(containerId); - await removeContainer(containerId, { force: true, removeVolumes: true }); + await grpcTestClient.stopContainer(containerId); + await grpcTestClient.removeContainer(containerId, { force: true, removeVolumes: true }); await expect( - removeContainer(containerId, { force: true, removeVolumes: true }), + grpcTestClient.removeContainer(containerId, { force: true, removeVolumes: true }), ).resolves.toBeUndefined(); }); async function startAlpineContainer(prefix: string): Promise { const name = `${prefix}-${randomUUID().slice(0, 8)}`; - const response = await startWorkload({ + const response = await grpcTestClient.startWorkload({ image: 'alpine:3.19', cmd: ['sleep', '30'], name, autoRemove: false, }); - if (!response?.containers?.main && !response?.id) { + 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 startWorkload(opts: { - image: string; - cmd: string[]; - name: string; - autoRemove: boolean; - }): Promise { - 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): Promise { - 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 }, - ): Promise { - 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 { - const request = create(InspectWorkloadRequestSchema, { workloadId: containerId }); - return unary(RUNNER_SERVICE_INSPECT_WORKLOAD_PATH, request, (req, metadata, callback) => { - client.inspectWorkload(req, metadata, callback); - }); - } - - async function ready() { - const request = create(ReadyRequestSchema, {}); - return unary(RUNNER_SERVICE_READY_PATH, request, (req, metadata, callback) => { - client.ready(req, metadata, callback); - }); - } - - function metadataFor(path: string): Metadata { - const headers = buildAuthHeaders({ method: 'POST', path, body: '', secret: sharedSecret }); - const metadata = new Metadata(); - for (const [key, value] of Object.entries(headers)) { - metadata.set(key, value); - } - return metadata; - } - - async function unary( - path: string, - request: Request, - invoke: ( - req: Request, - metadata: Metadata, - callback: (err: ServiceError | null, response?: Response) => void, - ) => void, - ): Promise { - const metadata = metadataFor(path); - return new Promise((resolve, reject) => { - invoke(request, metadata, (err, response) => { - if (err) { - reject(err); - return; - } - resolve(response as Response); - }); - }); - } - - async function waitForReady(): Promise { - await ready(); - } }); diff --git a/__tests__/helpers/grpc-test-client.ts b/__tests__/helpers/grpc-test-client.ts new file mode 100644 index 0000000..358703a --- /dev/null +++ b/__tests__/helpers/grpc-test-client.ts @@ -0,0 +1,144 @@ +import { Metadata, type ServiceError } from '@grpc/grpc-js'; +import { create } from '@bufbuild/protobuf'; + +import { buildAuthHeaders } from '../../src/contracts/auth'; +import { containerOptsToStartWorkloadRequest } from '../../src/contracts/workload.grpc'; +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, + type RunnerServiceGrpcClientInstance, +} from '../../src/proto/grpc.js'; +import { + InspectWorkloadRequestSchema, + ReadyRequestSchema, + RemoveWorkloadRequestSchema, + StopWorkloadRequestSchema, + type InspectWorkloadResponse, + type ReadyResponse, + type StartWorkloadResponse, +} from '../../src/proto/gen/agynio/api/runner/v1/runner_pb.js'; + +export type StartWorkloadInput = { + image: string; + cmd: string[]; + name: string; + autoRemove: boolean; +}; + +export type GrpcTestClient = { + metadataFor: (path: string) => Metadata; + unary: ( + path: string, + request: Request, + invoke: ( + req: Request, + metadata: Metadata, + callback: (err: ServiceError | null, response?: Response) => void, + ) => void, + ) => Promise; + startWorkload: (opts: StartWorkloadInput) => Promise; + stopContainer: (containerId: string, timeoutSec?: number) => Promise; + removeContainer: ( + containerId: string, + options?: { force?: boolean; removeVolumes?: boolean }, + ) => Promise; + inspectContainer: (containerId: string) => Promise; + ready: () => Promise; +}; + +export function createGrpcTestClient(options: { + client: RunnerServiceGrpcClientInstance; + secret: string; +}): GrpcTestClient { + const { client, secret } = options; + + const metadataFor = (path: string): Metadata => { + const headers = buildAuthHeaders({ method: 'POST', path, body: '', secret }); + const metadata = new Metadata(); + for (const [key, value] of Object.entries(headers)) { + metadata.set(key, value); + } + return metadata; + }; + + const unary = async ( + path: string, + request: Request, + invoke: ( + req: Request, + metadata: Metadata, + callback: (err: ServiceError | null, response?: Response) => void, + ) => void, + ): Promise => { + const metadata = metadataFor(path); + return new Promise((resolve, reject) => { + invoke(request, metadata, (err, response) => { + if (err) { + reject(err); + return; + } + resolve(response as Response); + }); + }); + }; + + const startWorkload = async (opts: StartWorkloadInput): Promise => { + 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); + }); + }; + + const stopContainer = async (containerId: string, timeoutSec = 1): Promise => { + const request = create(StopWorkloadRequestSchema, { workloadId: containerId, timeoutSec }); + await unary(RUNNER_SERVICE_STOP_WORKLOAD_PATH, request, (req, metadata, callback) => { + client.stopWorkload(req, metadata, callback); + }); + }; + + const removeContainer = async ( + containerId: string, + options: { force?: boolean; removeVolumes?: boolean } = {}, + ): Promise => { + 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); + }); + }; + + const inspectContainer = async (containerId: string): Promise => { + const request = create(InspectWorkloadRequestSchema, { workloadId: containerId }); + return unary(RUNNER_SERVICE_INSPECT_WORKLOAD_PATH, request, (req, metadata, callback) => { + client.inspectWorkload(req, metadata, callback); + }); + }; + + const ready = async (): Promise => { + const request = create(ReadyRequestSchema, {}); + return unary(RUNNER_SERVICE_READY_PATH, request, (req, metadata, callback) => { + client.ready(req, metadata, callback); + }); + }; + + return { + metadataFor, + unary, + startWorkload, + stopContainer, + removeContainer, + inspectContainer, + ready, + }; +} From ca296e00474c9dddc17828fe8caf7044c29cd4a7 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Fri, 13 Mar 2026 14:34:01 +0000 Subject: [PATCH 3/3] fix: use shared ready helper --- __tests__/e2e/docker-runner.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/e2e/docker-runner.e2e.test.ts b/__tests__/e2e/docker-runner.e2e.test.ts index 6af5d07..cccdd62 100644 --- a/__tests__/e2e/docker-runner.e2e.test.ts +++ b/__tests__/e2e/docker-runner.e2e.test.ts @@ -45,7 +45,7 @@ describe('docker-runner e2e', () => { }); it('ready health check', async () => { - const response = await ready(); + const response = await grpcTestClient.ready(); expect(response).toBeDefined(); });