diff --git a/.github/workflows/container-e2e.yml b/.github/workflows/container-e2e.yml new file mode 100644 index 0000000..5c0bed8 --- /dev/null +++ b/.github/workflows/container-e2e.yml @@ -0,0 +1,66 @@ +name: Container E2E + +# Builds the quickstart image and runs the full scenario against it over the wire: +# HEALTHCHECK, /healthz, gRPC reflection, a real unary RPC, gRPC health, and SIGTERM as +# PID 1. Nothing else in either repository covers the container -- the existing e2e tests +# keep client and server in one process, so the production dependency closure (with +# devDependencies stripped), the healthcheck and signal handling are all untested there. +# +# `connectum init` clones getting-started as its base and passes every file through +# except `pnpm-workspace.yaml` and `.pnpmfile.cjs`, so these Dockerfiles are also what a +# scaffolded project gets -- testing them here tests what the CLI hands to users. +# +# NOT covered, named rather than silently dropped: the tsx execution model (tsx is a +# devDependency and there is no tsx Dockerfile), the other examples' images, and any +# broker-backed flow. + +on: + pull_request: + paths: + - "getting-started/**" + - "scripts/container-e2e.sh" + - ".github/workflows/container-e2e.yml" + push: + branches: [main] + paths: + - "getting-started/**" + - "scripts/container-e2e.sh" + schedule: + # The images install @connectum/* from npm at build time, so a published regression + # can break this without anything in the repository changing. + - cron: "0 3 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + container-e2e: + name: "e2e ${{ matrix.runtime.name }}" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + runtime: + - { name: node, dockerfile: Dockerfile } + - { name: bun, dockerfile: Dockerfile.bun } + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up buf (for `buf curl`) + uses: bufbuild/buf-action@fd21066df7214747548607aaa45548ba2b9bc1ff # v1 + with: + setup_only: true + + - name: Build the image + working-directory: getting-started + run: docker build -f ${{ matrix.runtime.dockerfile }} -t quickstart:${{ matrix.runtime.name }} . + + - name: Run the wire-level scenario + run: ./scripts/container-e2e.sh quickstart:${{ matrix.runtime.name }} ${{ matrix.runtime.name }} + + - name: Container logs on failure + if: failure() + run: docker ps -a --filter "name=e2e-" --format '{{.Names}}' | xargs -r -n1 docker logs --tail 100 diff --git a/getting-started/.dockerignore b/getting-started/.dockerignore new file mode 100644 index 0000000..13a68c5 --- /dev/null +++ b/getting-started/.dockerignore @@ -0,0 +1,10 @@ +node_modules +gen +tests +*.md +.gitignore +.pnpmfile.cjs +pnpm-lock.yaml +pnpm-workspace.yaml +Dockerfile* +.dockerignore diff --git a/getting-started/Dockerfile b/getting-started/Dockerfile new file mode 100644 index 0000000..47f8822 --- /dev/null +++ b/getting-started/Dockerfile @@ -0,0 +1,50 @@ +# The quickstart service, containerised. `engines.node` is >=25.2.0, so TypeScript +# runs directly via native type stripping -- no build step and no flags. +# +# `gen/` is not committed, and `buf` is a devDependency, so code generation happens in +# its own stage with the full dependency tree; the runtime image gets production +# dependencies plus the generated code, and never needs buf. +# +# The Bun variant of this file is Dockerfile.bun. Both are exercised by the +# `example-e2e` workflow in the connectum repository. + +# ── build: full dependencies, then generate the proto code ────────────────── +FROM node:25-slim AS build +WORKDIR /app +COPY package.json ./ +# No lockfile is committed for this example (matching car-sharing and hris): deps +# resolve from the caret (^) ranges in package.json, so the image tracks the latest +# compatible @connectum/* rather than being bit-reproducible. That is deliberate for an +# example -- a production service should commit a lockfile and install with +# `--frozen-lockfile` (the secondary examples model this). +RUN npm install --no-audit --no-fund +COPY buf.yaml buf.gen.yaml ./ +COPY proto/ ./proto/ +RUN npx buf generate + +# ── deps: production dependencies only ────────────────────────────────────── +FROM node:25-slim AS deps +WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev --no-audit --no-fund + +# ── runtime ───────────────────────────────────────────────────────────────── +FROM node:25-slim AS runtime +# curl, not wget: this service serves plaintext h2c (`allowHTTP1: false`), and wget +# speaks HTTP/1.1 only. Against an h2c listener `wget --spider` gets an empty status +# line yet still exits 0, so it would report a dead or NOT_SERVING service as healthy. +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY --from=build /app/gen ./gen +COPY package.json ./ +COPY src/ ./src/ +ENV NODE_ENV=production PORT=5000 +EXPOSE 5000 +# `-f` fails on a non-2xx status, which is what makes this a health check rather than a +# port check: /healthz answers 200 for SERVING and 503 for NOT_SERVING and UNKNOWN. +HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=3 \ + CMD curl -fsS --http2-prior-knowledge http://localhost:${PORT:-5000}/healthz || exit 1 +# Run as the built-in non-root `node` user. +USER node +CMD ["node", "src/index.ts"] diff --git a/getting-started/Dockerfile.bun b/getting-started/Dockerfile.bun new file mode 100644 index 0000000..4216aca --- /dev/null +++ b/getting-started/Dockerfile.bun @@ -0,0 +1,51 @@ +# The quickstart service on Bun. Bun transpiles TypeScript itself, so as with the Node +# variant there is no build step. +# +# Bun is used here as both the runtime and the package manager, which is the common +# pairing but not a requirement: `bun install` lays out an ordinary `node_modules`, and a +# project installed with npm runs under Bun unchanged. +# +# `gen/` is not committed, and `buf` is a devDependency, so code generation happens in +# its own stage with the full dependency tree; the runtime image gets production +# dependencies plus the generated code, and never needs buf. + +# ── build: full dependencies, then generate the proto code ────────────────── +FROM oven/bun:1-slim AS build +WORKDIR /app +COPY package.json ./ +# No lockfile is committed for this example (matching car-sharing and hris): deps +# resolve from the caret (^) ranges in package.json, so the image tracks the latest +# compatible @connectum/* rather than being bit-reproducible. That is deliberate for an +# example -- a production service should commit a lockfile and install with +# `--frozen-lockfile` (the secondary examples model this). +RUN bun install +COPY buf.yaml buf.gen.yaml ./ +COPY proto/ ./proto/ +RUN bunx buf generate + +# ── deps: production dependencies only ────────────────────────────────────── +FROM oven/bun:1-slim AS deps +WORKDIR /app +COPY package.json ./ +RUN bun install --production + +# ── runtime ───────────────────────────────────────────────────────────────── +FROM oven/bun:1-slim AS runtime +# curl, not wget: this service serves plaintext h2c (`allowHTTP1: false`), and wget +# speaks HTTP/1.1 only. Against an h2c listener `wget --spider` gets an empty status +# line yet still exits 0, so it would report a dead or NOT_SERVING service as healthy. +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY --from=build /app/gen ./gen +COPY package.json ./ +COPY src/ ./src/ +ENV NODE_ENV=production PORT=5000 +EXPOSE 5000 +# `-f` fails on a non-2xx status, which is what makes this a health check rather than a +# port check: /healthz answers 200 for SERVING and 503 for NOT_SERVING and UNKNOWN. +HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=3 \ + CMD curl -fsS --http2-prior-knowledge http://localhost:${PORT:-5000}/healthz || exit 1 +# Run as the built-in non-root `bun` user. +USER bun +CMD ["bun", "run", "src/index.ts"] diff --git a/getting-started/README.md b/getting-started/README.md index 30feaf5..b10a48c 100644 --- a/getting-started/README.md +++ b/getting-started/README.md @@ -47,6 +47,27 @@ pnpm start:bun # Bun pnpm start:tsx # tsx ``` +## In a container + +Two Dockerfiles, one per runtime. Both generate the proto code during the build (`gen/` +is not committed and `buf` is a devDependency), then ship production dependencies only: + +```bash +docker build -t quickstart . # Node.js +docker build -f Dockerfile.bun -t quickstart . # Bun + +docker run --rm -p 5000:5000 quickstart +curl -fsS --http2-prior-knowledge http://localhost:5000/healthz +``` + +The probe needs `--http2-prior-knowledge` because the service is plaintext h2c +(`allowHTTP1: false`); `wget` cannot see it at all and would report a dead service as +healthy. + +`scripts/container-e2e.sh` runs the full scenario against a built image — healthcheck, +`/healthz`, reflection, a real RPC, gRPC health and SIGTERM as PID 1 — and CI runs it for +both runtimes. + ## Next - [hris](../hris/) — the same codebase running as a monolith **or** as diff --git a/scripts/container-e2e.sh b/scripts/container-e2e.sh new file mode 100755 index 0000000..fac2344 --- /dev/null +++ b/scripts/container-e2e.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# +# Full wire-level scenario against a containerised quickstart service. +# +# Everything else in this repository exercises the services in-process: the e2e tests +# open a real socket, but client and server share one process, so nothing covers the +# container itself -- the HEALTHCHECK, the production dependency closure with +# devDependencies stripped, or SIGTERM handling as PID 1. +# +# The probes run from the host against the published port and assert response *bodies* +# against the documented contract, not merely that a call did not fail. +# +# Requires: docker, curl, and `buf` on PATH (for `buf curl`). +# Usage: scripts/container-e2e.sh