diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3161ded..5c11e21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,63 @@ jobs: - uses: actions/checkout@v7.0.1 - uses: ./.github/actions/setup - run: pnpm test:e2e + + docker-e2e: + name: Docker (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + env: + TEST_IMAGE: sensitive-detector:e2e + TEST_CONTAINER: sensitive-detector-e2e + SENSITIVE_DETECTOR_API_KEY: docker-e2e-test-key + steps: + - uses: actions/checkout@v7.0.1 + # The host only runs the HTTP checker. Build the server inside Docker. + - uses: pnpm/action-setup@v6.1.0 + - uses: actions/setup-node@v7.1.0 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - uses: docker/setup-buildx-action@v4 + - name: Build and load image + uses: docker/build-push-action@v7 + with: + context: . + platforms: ${{ matrix.platform }} + load: true + push: false + tags: ${{ env.TEST_IMAGE }} + cache-from: type=gha,scope=docker-e2e-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=docker-e2e-${{ matrix.arch }} + - name: Start built image + run: | + timeout --signal=KILL 60s docker create --name "$TEST_CONTAINER" \ + --publish 127.0.0.1::3009 \ + --mount "type=bind,source=$GITHUB_WORKSPACE/config.docker.mjs,target=/config/config.mjs,readonly" \ + --env SENSITIVE_DETECTOR_API_KEY \ + "$TEST_IMAGE" + timeout --signal=KILL 60s docker start "$TEST_CONTAINER" + - name: Check health, authentication and image predictions + run: pnpm test:e2e:docker "$TEST_CONTAINER" + - name: Container diagnostics + if: failure() + run: | + timeout --signal=KILL 60s docker logs "$TEST_CONTAINER" || true + timeout --signal=KILL 60s docker inspect "$TEST_CONTAINER" || true + - name: Remove test container + if: always() + run: | + timeout --signal=KILL 60s docker rm --force "$TEST_CONTAINER" diff --git a/README.md b/README.md index 2f38558..c74fb91 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,34 @@ CIではPR、mainへのpush、手動実行時に独立した `server-e2e` ジョ `apps/server/e2e/check-images.ts` の `checkImages({ baseUrl, apiKey })` は起動処理と独立しており、 Docker等で起動したserverにも同じ画像・検証を使用できる。 +CIの `Docker (amd64)` / `Docker (arm64)` ジョブは、それぞれ `ubuntu-24.04` / +`ubuntu-24.04-arm` でイメージをビルド・ロードして起動する。設定だけを読み取り専用でマウントし、 +モデルとserverはイメージ同梱のものを使う。組み込みHEALTHCHECKの `healthy`、`/health` の200、 +認証なしの推論リクエストの401、同じ固定画像の予測を検証する。起動待ちは最大120秒、 +各HTTP通信は本文を含め最大60秒。失敗時はログ・状態を出力し、終了時はコンテナを削除する。 +PR、mainへのpush、手動実行が対象で、レジストリへのログイン・pushは行わない。 + +Dockerの検証はローカルでも実行できる(Node.js 22以上、GNU `timeout` と `pnpm install` が必要)。 +検証コマンドは既存コンテナを確認するため、呼び出し側で削除を行う。 + +```sh +docker build --tag sensitive-detector:e2e . +export SENSITIVE_DETECTOR_API_KEY=docker-e2e-test-key +( + set -eu + timeout --signal=KILL 60s docker create --name sensitive-detector-e2e --publish 127.0.0.1::3009 \ + --mount "type=bind,source=$PWD/config.docker.mjs,target=/config/config.mjs,readonly" \ + --env SENSITIVE_DETECTOR_API_KEY sensitive-detector:e2e + trap 'timeout --signal=KILL 60s docker rm --force sensitive-detector-e2e' EXIT + timeout --signal=KILL 60s docker start sensitive-detector-e2e + pnpm test:e2e:docker sensitive-detector-e2e || { + timeout --signal=KILL 60s docker logs sensitive-detector-e2e || true + timeout --signal=KILL 60s docker inspect sensitive-detector-e2e || true + exit 1 + } +) +``` + ## 構成 - `packages/core` (`@misskey-sensitive-detector/core`): 推論エンジン。重いネイティブ実依存(onnxruntime-node)はここに集約。 diff --git a/apps/server/e2e/check-docker.ts b/apps/server/e2e/check-docker.ts new file mode 100644 index 0000000..06777b1 --- /dev/null +++ b/apps/server/e2e/check-docker.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { setTimeout as delay } from 'node:timers/promises'; +import { checkImages, HTTP_TIMEOUT_MS } from './check-images.js'; + +type ContainerInfo = { + State: { Running: boolean; Status: string; Health?: { Status: string } }; + NetworkSettings: { Ports: Record }; +}; + +/** Require Docker's own HEALTHCHECK, not just a reachable host port. */ +export async function waitForContainer( + inspect: (signal: AbortSignal) => Promise, + { + signal, + startupTimeoutMs = 120_000, + pollIntervalMs = 250, + }: { signal?: AbortSignal; startupTimeoutMs?: number; pollIntervalMs?: number } = {}, +): Promise { + const timeout = AbortSignal.timeout(startupTimeoutMs); + const starting = signal ? AbortSignal.any([signal, timeout]) : timeout; + try { + for (;;) { + starting.throwIfAborted(); + const info = await inspect(starting); + starting.throwIfAborted(); + assert(info.State.Running, `container stopped: ${info.State.Status}`); + const health = info.State.Health?.Status; + assert(health, 'container has no built-in HEALTHCHECK'); + assert(health === 'starting' || health === 'healthy', `container HEALTHCHECK is ${health}`); + if (health === 'healthy') { + const ports = info.NetworkSettings.Ports['3009/tcp']; + const port = ports?.[0]; + assert( + ports?.length === 1 && + port?.HostIp === '127.0.0.1' && + /^\d+$/.test(port.HostPort) && + Number(port.HostPort) >= 1 && + Number(port.HostPort) <= 65535, + 'container must publish port3009 on one ephemeral loopback port', + ); + return `http://127.0.0.1:${port.HostPort}`; + } + await delay(pollIntervalMs, undefined, { signal: starting }); + } + } catch (error) { + starting.throwIfAborted(); // Preserve the timeout/caller reason instead of a subprocess AbortError. + throw error; + } +} + +export async function checkDockerHttp({ + baseUrl, + apiKey, + signal, + requestTimeoutMs = HTTP_TIMEOUT_MS, +}: { + baseUrl: string; + apiKey: string; + signal?: AbortSignal; + requestTimeoutMs?: number; +}): Promise { + async function expectStatus(path: string, method: string, expected: number) { + const timeout = AbortSignal.timeout(requestTimeoutMs); + const response = await fetch(new URL(path, baseUrl), { + method, + redirect: 'error', + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }); + try { + assert.equal(response.status, expected, `${method} ${path}: expected HTTP ${expected}, got ${response.status}`); + await response.arrayBuffer(); // The deadline includes the response body. + } finally { + await response.body?.cancel().catch(() => {}); + } + } + + await expectStatus('/health', 'GET', 200); + await expectStatus('/v1/detect-images', 'POST', 401); + await checkImages({ baseUrl, apiKey, signal, requestTimeoutMs }); +} diff --git a/apps/server/e2e/run-docker.ts b/apps/server/e2e/run-docker.ts new file mode 100644 index 0000000..cfc0228 --- /dev/null +++ b/apps/server/e2e/run-docker.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { checkDockerHttp, waitForContainer } from './check-docker.js'; +import { HTTP_TIMEOUT_MS } from './check-images.js'; +import { withSignals } from './signals.js'; + +const exec = promisify(execFile); + +try { + const [, , containerName, ...extra] = process.argv; + const apiKey = process.env.SENSITIVE_DETECTOR_API_KEY; + assert(containerName && extra.length === 0, 'usage: pnpm test:e2e:docker '); + assert(apiKey, 'SENSITIVE_DETECTOR_API_KEY is required'); + await withSignals(async (signal) => { + const baseUrl = await waitForContainer( + async (starting) => { + const { stdout } = await exec('docker', ['inspect', '--format', '{{json .}}', containerName], { + signal: starting, + timeout: HTTP_TIMEOUT_MS, + killSignal: 'SIGKILL', + }); + return JSON.parse(stdout); + }, + { signal }, + ); + await checkDockerHttp({ baseUrl, apiKey, signal }); + }); + console.log('Docker E2E passed: healthy, HTTP200/401, Neutral and Sexy fixture predictions.'); +} catch (error) { + console.error(error); + process.exitCode = 1; +} diff --git a/apps/server/test/e2e-docker.test.ts b/apps/server/test/e2e-docker.test.ts new file mode 100644 index 0000000..53634fb --- /dev/null +++ b/apps/server/test/e2e-docker.test.ts @@ -0,0 +1,161 @@ +import { createServer, type RequestListener } from 'node:http'; +import { setTimeout as delay } from 'node:timers/promises'; +import { afterEach, describe, expect, it } from 'vitest'; +import { checkDockerHttp, waitForContainer } from '../e2e/check-docker.js'; + +const ready = { + State: { Running: true, Status: 'running', Health: { Status: 'healthy' } }, + NetworkSettings: { Ports: { '3009/tcp': [{ HostIp: '127.0.0.1', HostPort: '43210' }] } }, +}; + +describe('Docker E2E readiness', () => { + it('waits for the built-in HEALTHCHECK before returning the published loopback URL', async () => { + const states = [ + { ...ready, State: { ...ready.State, Health: { Status: 'starting' } }, NetworkSettings: { Ports: {} } }, + ready, + ]; + await expect(waitForContainer(async () => states.shift() ?? ready, { pollIntervalMs: 1 })).resolves.toBe( + 'http://127.0.0.1:43210', + ); + }); + + it('fails when the container exits before it becomes healthy', async () => { + await expect( + waitForContainer(async () => ({ ...ready, State: { Running: false, Status: 'exited' } })), + ).rejects.toThrow(/exited/); + }); + + it('fails when the built-in HEALTHCHECK reports unhealthy', async () => { + await expect( + waitForContainer(async () => ({ ...ready, State: { ...ready.State, Health: { Status: 'unhealthy' } } })), + ).rejects.toThrow(/unhealthy/); + }); + + it('fails when the image has no HEALTHCHECK', async () => { + await expect( + waitForContainer(async () => ({ ...ready, State: { Running: true, Status: 'running' } })), + ).rejects.toThrow(/HEALTHCHECK/); + }); + + it.each([{}, { '3009/tcp': null }, { '3009/tcp': [{ HostIp: '0.0.0.0', HostPort: '43210' }] }])( + 'fails when the expected loopback port is not published %#', + async (Ports) => { + await expect(waitForContainer(async () => ({ ...ready, NetworkSettings: { Ports } }))).rejects.toThrow(/port/i); + }, + ); + + it('times out when the container stays in starting state', async () => { + await expect( + waitForContainer(async () => ({ ...ready, State: { ...ready.State, Health: { Status: 'starting' } } }), { + startupTimeoutMs: 50, + pollIntervalMs: 1, + }), + ).rejects.toThrow(/timeout/i); + }); + + it('includes a stalled Docker inspect in the startup deadline', async () => { + await expect( + waitForContainer( + async (signal) => { + await delay(10_000, undefined, { signal }); + return ready; + }, + { startupTimeoutMs: 50 }, + ), + ).rejects.toThrow(/timeout/i); + }); + + it('cancels startup when the caller aborts', async () => { + const controller = new AbortController(); + const check = waitForContainer( + async (signal) => { + await delay(10_000, undefined, { signal }); + return ready; + }, + { signal: controller.signal }, + ); + controller.abort(new Error('caller cancelled Docker E2E')); + await expect(check).rejects.toThrow(/caller cancelled/); + }); +}); + +function predictions(winner: string) { + return ['Drawing', 'Hentai', 'Neutral', 'Porn', 'Sexy'].map((className) => ({ + className, + probability: className === winner ? 0.96 : 0.01, + })); +} + +describe('Docker E2E HTTP verification', () => { + let server: ReturnType | undefined; + afterEach(async () => { + server?.closeAllConnections(); + if (server) await new Promise((resolve) => server?.close(() => resolve())); + }); + + async function listen(handler: RequestListener) { + server = createServer(handler); + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('missing TCP address'); + return `http://127.0.0.1:${address.port}`; + } + + function handler({ health = 200, unauthenticated = 401, winner = 'Sexy' } = {}): RequestListener { + return async (req, res) => { + if (req.method === 'GET' && req.url === '/health') return void res.writeHead(health).end('ok'); + if (req.method !== 'POST' || req.url !== '/v1/detect-images') return void res.writeHead(404).end(); + if (!req.headers.authorization) return void res.writeHead(unauthenticated).end('unauthenticated'); + if (req.headers.authorization !== 'Bearer docker-e2e-test-key') return void res.writeHead(403).end(); + let received = ''; + for await (const chunk of req) received += chunk.toString('latin1'); + if (!received.includes('neutral-synthetic-cat.png') || !received.includes('sexy-synthetic-swimwear.png')) { + return void res.writeHead(400).end('both fixtures required'); + } + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + success: true, + result: { + results: [ + { success: true, predictions: predictions('Neutral') }, + { success: true, predictions: predictions(winner) }, + ], + }, + }), + ); + }; + } + + it('requires health200, unauthenticated401 and authenticated predictions for both real fixtures', async () => { + const baseUrl = await listen(handler()); + await expect(checkDockerHttp({ baseUrl, apiKey: 'docker-e2e-test-key' })).resolves.toBeUndefined(); + }); + + it('rejects a failing health endpoint', async () => { + const baseUrl = await listen(handler({ health: 503 })); + await expect(checkDockerHttp({ baseUrl, apiKey: 'docker-e2e-test-key' })).rejects.toThrow(/health.*503/); + }); + + it('rejects inference that accepts a request without authentication', async () => { + const baseUrl = await listen(handler({ unauthenticated: 200 })); + await expect(checkDockerHttp({ baseUrl, apiKey: 'docker-e2e-test-key' })).rejects.toThrow(/401.*200/); + }); + + it('rejects the wrong winning class using the shared fixture validator', async () => { + const baseUrl = await listen(handler({ winner: 'Neutral' })); + await expect(checkDockerHttp({ baseUrl, apiKey: 'docker-e2e-test-key' })).rejects.toThrow(/expected Sexy/); + }); + + it.each(['/health', '/v1/detect-images'])('times out on a stalled response body from %s', async (path) => { + const normal = handler(); + const baseUrl = await listen((req, res) => { + if (req.url !== path) return normal(req, res); + res.writeHead(path === '/health' ? 200 : 401); + res.write('body never finishes'); + }); + await expect(checkDockerHttp({ baseUrl, apiKey: 'docker-e2e-test-key', requestTimeoutMs: 50 })).rejects.toThrow( + /timeout/i, + ); + }); +}); diff --git a/docs/superpowers/plans/2026-11-06-docker-ci.md b/docs/superpowers/plans/2026-11-06-docker-ci.md new file mode 100644 index 0000000..7d4710b --- /dev/null +++ b/docs/superpowers/plans/2026-11-06-docker-ci.md @@ -0,0 +1,67 @@ +# Docker CI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** PR時にamd64・arm64のDockerビルドと、ビルドしたイメージ自身の起動・画像推論を検証する。 + +**Architecture:** CIの独立matrixジョブがイメージをローカルにロードし、既存configだけを読み取り専用でマウントする。TypeScriptの検証コマンドがDockerのHEALTHCHECKと公開ポートを確認し、#38の画像・検証処理を再利用する。診断と削除はworkflowの独立した終了ステップで行う。 + +**Tech Stack:** GitHub Actions, Docker Buildx, Node.js 22, TypeScript/tsx, Vitest. + +**Spec:** https://github.com/misskey-dev/sensitive-detector/issues/39 (会話で合意済みのIssue 2) + +## Global Constraints + +- amd64は`ubuntu-24.04`、arm64は`ubuntu-24.04-arm`。QEMUは使用しない。 +- PR、mainへのpush、workflow_dispatchで実行する。 +- 起動待ちは最大120秒、HTTPリクエストは応答本文を含め最大60秒。タイムアウトは失敗する。 +- モデルはイメージ同梱。既存`config.docker.mjs`のみを`/config/config.mjs`にreadonlyでマウントし、テスト用APIキーを環境変数で渡す。 +- `/health`の200、組み込みHEALTHCHECKのhealthy、実画像の期待予測、認証なし401を全て必須とする。 +- 失敗時はコンテナログと状態を出力し、成功・失敗・キャンセル時とも削除ステップを実行する。 +- レジストリへのログイン・push、公開API、本番の判定仕様、リリースworkflowは変更しない。 + +## Review Focus + +- healthyにならない・早期終了・HEALTHCHECK欠落を成功と誤認しない。 +- 応答ヘッダーのみ到着し本文が止まったHTTPも時間制限を守る。 +- 認証不要になった推論APIや異なる予測を成功にしない。 +- build/start/checkのどこで失敗しても診断・削除ステップが実行される。 +- 各runnerでビルドしたイメージ自身を起動し、ホストのモデルで同梱漏れを隠さない。 + +### Task 1: Docker readiness and HTTP verification + +**Files:** +- Create: `apps/server/e2e/check-docker.ts`, `apps/server/e2e/run-docker.ts` +- Test: `apps/server/test/e2e-docker.test.ts` +- Modify: `package.json` + +**Interfaces:** +- Consumes: `checkImages({baseUrl, apiKey, signal, requestTimeoutMs})` from #38. +- Produces: `waitForContainer(inspect, {signal?, startupTimeoutMs?, pollIntervalMs?}) -> Promise`; `checkDockerHttp({baseUrl, apiKey, signal?, requestTimeoutMs?}) -> Promise`; `pnpm test:e2e:docker `. +- `inspect(signal)` returns Docker inspect's `State` and `NetworkSettings`; the CLI uses bounded `docker inspect`, while tests supply external-daemon state without requiring Docker. + +- [x] Write behavior tests: starting→healthy returns loopback port; exited, unhealthy, missing HEALTHCHECK, invalid publication fail; startup stalls/cancellation fail. Real HTTP tests require health200, unauthenticated401 and both fixture predictions, reject incorrect status/class, and abort stalled bodies. +- [x] Run `pnpm exec vitest run apps/server/test/e2e-docker.test.ts`: expected FAIL because verifier does not exist. +- [x] Implement checks and command. Share a120-second startup deadline with Docker inspect; each Docker command and HTTP request has a60-second bound. Use #38's signal handling and real fixture validator. +- [x] Re-run targeted tests: expected PASS; run full suite: expected all existing and new tests pass. +- [x] Commit verifier, command, tests and plan. + +### Task 2: Native Docker matrix and end-to-end verification + +**Files:** +- Modify: `.github/workflows/ci.yml`, `README.md` + +**Interfaces:** +- Consumes: Task1's `pnpm test:e2e:docker ` and test-key environment variable. +- Produces: independent `docker-e2e` matrix checks without registry credentials or image push. + +- [x] Add native runner/platform matrix, dependency setup without host build, Buildx build with `load: true`, `push: false`, and architecture-scoped cache. +- [x] Create/start named container with ephemeral loopback publication, readonly config, test API key; run verifier. +- [x] Add failure-only logs/state and always-run forced removal. Document local invocation and lifecycle. +- [x] Run native amd64 Docker build/start/check locally; expected correct predictions and cleanup. +- [x] Inject missing model, disabled HEALTHCHECK and failed inference/auth scenarios; expected nonzero result, useful diagnostics, removal. Unit tests cover HTTP stalls/readiness deadline. +- [x] Run full tests, built-server E2E, typecheck, lint, diff check: expected PASS. +- [x] Independent adversarial review; verify findings, fix substantive issues, repeat until no substantive findings (user instruction). +- [x] Commit, push feature branch, create PR closing#39; verify hosted amd64 and arm64 Docker checks plus existing jobs. + +**Hosted verification:** [PR#41](https://github.com/misskey-dev/sensitive-detector/pull/41)の[CI run42183772760](https://github.com/misskey-dev/sensitive-detector/actions/runs/42183772760)で全5ジョブが成功。amd64・arm64ともビルド、起動、HEALTHCHECK・認証・画像推論、コンテナ削除が成功した。ローカル全165テスト、型チェック、lint、既存server E2E、actionlintも成功。独立レビューで指摘されたDockerコマンドの時間制限とローカル手順の既存コンテナ保護を修正し、再レビューは実質的な指摘なし。 diff --git a/package.json b/package.json index b7e6fd8..2d338bc 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "test:unit": "vitest run --project unit", "test:integration": "vitest run --project integration", "test:e2e": "node --import tsx apps/server/e2e/run.ts", + "test:e2e:docker": "node --import tsx apps/server/e2e/run-docker.ts", "lint": "pnpm exec -- biome check .", "format": "pnpm exec -- biome format --write ." },