-
Notifications
You must be signed in to change notification settings - Fork 0
feat(getting-started): Dockerfiles and a container e2e workflow #42
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| pnpm-workspace.yaml | ||
| Dockerfile* | ||
| .dockerignore | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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:
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/.dockerignoreexcludespnpm-lock.yaml, so both image stages resolve packages again frompackage.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-L15getting-started/Dockerfile#L23-L24getting-started/Dockerfile.bun#L15-L16getting-started/Dockerfile.bun#L24-L25🤖 Prompt for AI Agents