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 new file mode 100644 index 0000000..cccdd62 --- /dev/null +++ b/__tests__/e2e/docker-runner.e2e.test.ts @@ -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(); + + 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 { + 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); + return containerId; + } +}); 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, + }; +} 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 }, },