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
66 changes: 66 additions & 0 deletions .github/workflows/container-e2e.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions getting-started/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
node_modules
gen
tests
*.md
.gitignore
.pnpmfile.cjs
pnpm-lock.yaml

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repo files of interest =="
git ls-files | rg '(^|/)getting-started/(Dockerfile|Dockerfile\.bun|\.dockerignore)|^package\.json$|pnpm-lock\.yaml$'

echo
echo "== getting-started/.dockerignore =="
if [ -f getting-started/.dockerignore ]; then cat -n getting-started/.dockerignore; fi

echo
echo "== getting-started/Dockerfile =="
if [ -f getting-started/Dockerfile ]; then cat -n getting-started/Dockerfile; fi

echo
echo "== getting-started/Dockerfile.bun =="
if [ -f getting-started/Dockerfile.bun ]; then cat -n getting-started/Dockerfile.bun; fi

echo
echo "== root package manager files =="
for f in package.json pnpm-lock.yaml getting-started/package.json; do
  [ -f "$f" ] && { echo "--- $f"; sed -n '1,220p' "$f"; }
done

echo
echo "== package scripts/packageManager fields =="
python3 - <<'PY'
import json, pathlib
for p in [pathlib.Path('package.json'), pathlib.Path('getting-started/package.json')]:
    if not p.exists():
        continue
    data=json.loads(p.read_text())
    print(p, "packageManager=", data.get("packageManager"), "scripts=", data.get("scripts"))
PY

Repository: Connectum-Framework/examples

Length of output: 7485


🌐 Web query:

Bun package manager install lockfile mode lockfile only package.json lockfile generation

💡 Result:

To generate a lockfile in the Bun package manager without installing dependencies to node_modules, you can use the --lockfile-only flag [1][2]. When you run bun install --lockfile-only, Bun will resolve dependencies, update the lockfile, and save it to disk (named bun.lock) [1][3]. Note that this process still populates your global install cache with registry metadata and relevant dependency files (git/tarballs) to ensure accuracy [1][3]. Regarding lockfile generation and management: 1. Default Format: As of Bun v1.2.0, the default lockfile is the human-readable, text-based bun.lock [1][4]. 2. Migration: If you have an older binary lockfile (bun.lockb), you can migrate to the new text format by running bun install --save-text-lockfile --frozen-lockfile --lockfile-only, and then deleting the old bun.lockb file [1][2]. 3. Frozen Lockfile: To ensure reproducible builds, use the --frozen-lockfile flag (often used in CI environments with the equivalent command bun ci). This instructs Bun to use the existing lockfile versions exclusively and fail if the package.json and lockfile are out of sync [2][5]. 4. Alternative Formats: If you require a yarn.lock file for compatibility, you can generate it alongside your bun.lock by using the --yarn flag or by configuring it in your bunfig.toml file [1][6]. If you want to prevent the creation of a lockfile entirely during installation, you can use the --no-save flag [1][3].

Citations:


Use a lockfile for the Docker dependency stages.

getting-started/.dockerignore excludes pnpm-lock.yaml, so both image stages resolve packages again from package.json. A rebuild can get a different dependency graph, and npm can generate a different lockfile than pnpm. Include a project lockfile in the build context, then install from it in frozen mode for both full and production dependency stages.

📍 Affects 3 files
  • getting-started/.dockerignore#L7-L7 (this comment)
  • getting-started/Dockerfile#L14-L15
  • getting-started/Dockerfile#L23-L24
  • getting-started/Dockerfile.bun#L15-L16
  • getting-started/Dockerfile.bun#L24-L25
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@getting-started/.dockerignore` at line 7, Stop excluding pnpm-lock.yaml in
getting-started/.dockerignore, and update both dependency-install stages in
getting-started/Dockerfile (lines 14-15 and 23-24) and
getting-started/Dockerfile.bun (lines 15-16 and 24-25) to use the project
lockfile with frozen pnpm installs for full and production dependencies.

pnpm-workspace.yaml
Dockerfile*
.dockerignore
50 changes: 50 additions & 0 deletions getting-started/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
51 changes: 51 additions & 0 deletions getting-started/Dockerfile.bun
Original file line number Diff line number Diff line change
@@ -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"]
21 changes: 21 additions & 0 deletions getting-started/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions scripts/container-e2e.sh
Original file line number Diff line number Diff line change
@@ -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 <image> <label>
set -uo pipefail

IMAGE="$1"; LABEL="$2"
NAME="e2e-$LABEL-$$"
PORT="${E2E_PORT:-15000}"
PASS=0; FAIL=0

ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; PASS=$((PASS+1)); }
bad() { printf ' \033[31mFAIL\033[0m %s -- %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }

# check <condition-result> <name> <detail-on-failure>
check() { if [ "$1" = "0" ]; then ok "$2"; else bad "$2" "${3-}"; fi; }

# Dump the logs before removing the container, or a CI failure leaves nothing to
# diagnose -- the container is gone by the time any later step runs.
cleanup() {
if [ "$FAIL" -gt 0 ] && docker inspect "$NAME" >/dev/null 2>&1; then
echo " --- last 50 log lines from $NAME"
docker logs --tail 50 "$NAME" 2>&1 | sed 's/^/ | /'
fi
docker rm -f "$NAME" >/dev/null 2>&1 || true
}
trap cleanup EXIT

echo "=== $LABEL ($IMAGE) ==="

docker run -d --name "$NAME" -p "$PORT:5000" "$IMAGE" >/dev/null

# 1. the container reaches its own HEALTHCHECK
for _ in $(seq 1 30); do
st=$(docker inspect -f '{{.State.Health.Status}}' "$NAME" 2>/dev/null)
[ "$st" = "healthy" ] || [ "$st" = "unhealthy" ] && break
sleep 2
done
[ "$st" = "healthy" ]; check $? "container healthcheck -> healthy" "status=$st"

# 2. HTTP /healthz over h2c reports SERVING
body=$(curl -fsS --http2-prior-knowledge "http://localhost:$PORT/healthz" 2>/dev/null)
echo "$body" | grep -q '"status":"SERVING"'; check $? "GET /healthz -> SERVING" "body=$body"

# 3. a non-existent path is rejected (proves the probe is not vacuous)
code=$(curl -s -o /dev/null -w '%{http_code}' --http2-prior-knowledge "http://localhost:$PORT/nope")
[ "$code" = "404" ]; check $? "unknown path -> 404" "code=$code"

# 4. gRPC reflection lists the three services
services=$(buf curl --protocol grpc --http2-prior-knowledge --list-methods \
"http://localhost:$PORT" 2>/dev/null)
# The reflection service does not advertise itself, which is normal; assert the
# services a client would actually look up.
for svc in greeter.v1.GreeterService/SayHello greeter.v1.GreeterService/SayGoodbye grpc.health.v1.Health/Check; do
echo "$services" | grep -q "$svc"; check $? "reflection lists $svc" "missing from the listing"
done

# 5. a real unary RPC returns the documented payload
reply=$(buf curl --protocol grpc --http2-prior-knowledge -d '{"name":"Ada"}' \
"http://localhost:$PORT/greeter.v1.GreeterService/SayHello" 2>/dev/null)
echo "$reply" | grep -q 'Hello, Ada!'; check $? 'SayHello -> "Hello, Ada!"' "reply=$reply"

# 6. gRPC health check reports SERVING
health=$(buf curl --protocol grpc --http2-prior-knowledge -d '{}' \
"http://localhost:$PORT/grpc.health.v1.Health/Check" 2>/dev/null)
echo "$health" | grep -q 'SERVING'; check $? "Health/Check -> SERVING" "reply=$health"

# 7. the second method answers too, so the whole service is wired, not just one route
# (this example declares no proto constraints, so there is no validation path to assert)
bye=$(buf curl --protocol grpc --http2-prior-knowledge -d '{"name":"Ada"}' \
"http://localhost:$PORT/greeter.v1.GreeterService/SayGoodbye" 2>/dev/null)
echo "$bye" | grep -q 'Goodbye, Ada!'; check $? 'SayGoodbye -> "Goodbye, Ada!"' "reply=$bye"

# 8. SIGTERM as PID 1: graceful shutdown, clean exit code, inside the grace window
start=$(date +%s)
docker stop -t 30 "$NAME" >/dev/null 2>&1
stop_rc=$?
elapsed=$(( $(date +%s) - start ))
status=$(docker inspect -f '{{.State.Status}}' "$NAME" 2>/dev/null)

# `docker inspect` reports ExitCode 0 for a *running* container, so trusting it
# without checking the status would pass even if the container never stopped.
if [ "$stop_rc" != "0" ] || [ "$status" != "exited" ]; then
bad "SIGTERM -> container exited" "docker stop rc=$stop_rc, status=$status after ${elapsed}s"
bad "shutdown within the grace window" "container never exited"
else
exit_code=$(docker inspect -f '{{.State.ExitCode}}' "$NAME" 2>/dev/null)
[ "$exit_code" = "0" ]
check $? "SIGTERM -> exit 0 (${elapsed}s)" "exit=$exit_code (137 = SIGKILL, shutdown hung)"
[ "$elapsed" -lt 15 ]
check $? "shutdown within the grace window (${elapsed}s)" "took ${elapsed}s"
fi

echo " --- $LABEL: $PASS passed, $FAIL failed"
exit $((FAIL > 0))