diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3dfc9dc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +.git +.github +.env* +coverage +tests +docs +scripts +.superpowers +*.log +*.md +docker-compose.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..34c8a78 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# Stage 1: Dependency resolution and production prune +FROM oven/bun:1-slim AS builder +WORKDIR /app + +# Copy dependency manifests +COPY package.json bun.lock ./ + +# Install production dependencies only with strict lockfile +RUN bun install --frozen-lockfile --production + +# Stage 2: Minimal runtime image +FROM oven/bun:1-slim AS runner +WORKDIR /app + +ENV NODE_ENV=production + +# Copy installed production node_modules from builder +COPY --from=builder /app/node_modules ./node_modules + +# Copy application configuration and source code +COPY package.json tsconfig.json ./ +COPY src ./src + +# Set ownership and drop root privileges +RUN chown -R bun:bun /app +USER bun + +# Standard MCP stdio communication +ENTRYPOINT ["bun", "run", "src/index.ts"] diff --git a/tests/docker.test.ts b/tests/docker.test.ts new file mode 100644 index 0000000..32e7628 --- /dev/null +++ b/tests/docker.test.ts @@ -0,0 +1,81 @@ +import { describe, test, expect } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const ROOT_DIR = resolve(import.meta.dir, ".."); + +describe("Docker Container Packaging", () => { + test("Dockerfile exists and defines multi-stage build with non-root user", () => { + const dockerfilePath = resolve(ROOT_DIR, "Dockerfile"); + expect(existsSync(dockerfilePath)).toBe(true); + + const content = readFileSync(dockerfilePath, "utf-8"); + expect(content).toContain("FROM oven/bun:1-slim AS builder"); + expect(content).toContain("FROM oven/bun:1-slim AS runner"); + expect(content).toContain("bun install --frozen-lockfile --production"); + expect(content).toContain("USER bun"); + expect(content).toContain('ENTRYPOINT ["bun", "run", "src/index.ts"]'); + }); + + test(".dockerignore exists and excludes sensitive and unnecessary files", () => { + const ignorePath = resolve(ROOT_DIR, ".dockerignore"); + expect(existsSync(ignorePath)).toBe(true); + + const content = readFileSync(ignorePath, "utf-8"); + expect(content).toContain("node_modules"); + expect(content).toContain(".env*"); + expect(content).toContain(".git"); + expect(content).toContain("tests"); + expect(content).toContain("docs"); + expect(content).toContain("scripts"); + }); + + test("Docker image builds and starts up cleanly in container", async () => { + // Check if docker daemon is available in test environment + const proc = Bun.spawn(["which", "docker"], { stdout: "pipe", stderr: "pipe" }); + const exitCode = await proc.exited; + if (exitCode !== 0) { + console.warn("Docker CLI not available, skipping container build execution test"); + return; + } + + // Build test image + const buildProc = Bun.spawn(["docker", "build", "-t", "openproject-mcp:test", "."], { + cwd: ROOT_DIR, + stdout: "pipe", + stderr: "pipe", + }); + const buildExit = await buildProc.exited; + expect(buildExit).toBe(0); + + // Run container with test env and check stderr banner + const runProc = Bun.spawn( + [ + "docker", + "run", + "-i", + "--rm", + "-e", + "OPENPROJECT_BASE_URL=http://localhost:8080", + "-e", + "OPENPROJECT_API_KEY=test-token", + "openproject-mcp:test", + "--read-only", + ], + { + cwd: ROOT_DIR, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + } + ); + + // Give it 1.5s to start and write banner to stderr + await Bun.sleep(1500); + runProc.kill(); + await runProc.exited; + + const stderrText = await new Response(runProc.stderr).text(); + expect(stderrText).toContain("[openproject-mcp] Server started (readOnly=true)"); + }, 60000); +});