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
60 changes: 60 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)はここに集約。
Expand Down
80 changes: 80 additions & 0 deletions apps/server/e2e/check-docker.ts
Original file line number Diff line number Diff line change
@@ -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<string, { HostIp: string; HostPort: string }[] | null | undefined> };
};

/** Require Docker's own HEALTHCHECK, not just a reachable host port. */
export async function waitForContainer(
inspect: (signal: AbortSignal) => Promise<ContainerInfo>,
{
signal,
startupTimeoutMs = 120_000,
pollIntervalMs = 250,
}: { signal?: AbortSignal; startupTimeoutMs?: number; pollIntervalMs?: number } = {},
): Promise<string> {
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<void> {
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 });
}
33 changes: 33 additions & 0 deletions apps/server/e2e/run-docker.ts
Original file line number Diff line number Diff line change
@@ -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 <container-name>');
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;
}
161 changes: 161 additions & 0 deletions apps/server/test/e2e-docker.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createServer> | undefined;
afterEach(async () => {
server?.closeAllConnections();
if (server) await new Promise<void>((resolve) => server?.close(() => resolve()));
});

async function listen(handler: RequestListener) {
server = createServer(handler);
await new Promise<void>((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,
);
});
});
Loading
Loading