From 77923e501f0583e3df2e0f2defcd6fe039589945 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sat, 11 Jul 2026 21:43:28 +0200 Subject: [PATCH] build(docker): add core application image --- .dockerignore | 31 ++++++ .superpowers/sdd/container-task-3-report.md | 117 ++++++++++++++++++++ docker/core-entrypoint.sh | 24 ++++ docker/core.Dockerfile | 64 +++++++++++ docker/smoke/core-smoke.sh | 36 ++++++ 5 files changed, 272 insertions(+) create mode 100644 .dockerignore create mode 100644 .superpowers/sdd/container-task-3-report.md create mode 100755 docker/core-entrypoint.sh create mode 100644 docker/core.Dockerfile create mode 100755 docker/smoke/core-smoke.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..6d6c2571 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +.git +.worktrees +.superpowers +.codex +.agents +**/.DS_Store +**/.env +**/.env.* +!**/.env.example +**/.npmrc +**/.pypirc +**/.netrc +**/.ssh +**/*.key +**/*.pem +**/*.p12 +**/*.pfx +**/.venv +**/node_modules +**/__pycache__ +**/.pytest_cache +**/.ruff_cache +**/coverage +**/dist +backend/data +harness/workspaces +harness/sessions +harness/artifacts +harness/indexes +harness/corpus +deploy diff --git a/.superpowers/sdd/container-task-3-report.md b/.superpowers/sdd/container-task-3-report.md new file mode 100644 index 00000000..af1d2feb --- /dev/null +++ b/.superpowers/sdd/container-task-3-report.md @@ -0,0 +1,117 @@ +# Container Packaging Task 3 Report + +## Status + +Implemented the multi-stage core application image, non-root runtime, pinned Pi installation, +container entrypoint, context exclusions, and an in-image health smoke test. + +## TDD / Build Evidence + +Initial RED: + +```text +docker build -f docker/core.Dockerfile -t thothii-core:test . +ERROR: failed to build: resolve : lstat docker: no such file or directory +``` + +The first sandboxed attempt could not access the Docker socket; the authorized rerun reached the +builder and failed for the expected reason: the Dockerfile did not exist. + +GREEN build: + +```text +sh -n docker/core-entrypoint.sh docker/smoke/core-smoke.sh +docker build --progress=plain -f docker/core.Dockerfile -t thothii-core:test . +``` + +Result: shell syntax exited 0; Docker build exited 0. A final rebuild after tightening +`.dockerignore` also exited 0 and transferred only 17.60 kB of changed context (the initial clean +build transferred 1.02 MB). + +## Runtime and Entrypoints + +- Runtime user is `10001:10001` (`thoth`), never root. +- Runtime contains Node `v22.19.0` and Python `3.12.13`. Python 3.12 is intentional because the + harness declares `requires-python = ">=3.12"` and also satisfies the deployment floor of 3.11+. +- Pi is installed exactly as `@earendil-works/pi-coding-agent@0.80.3`; its build-time and runtime + version probes both reported `0.80.3`. +- `server` starts `/app/backend/dist/server.js`; `doctor` routes to `tht doctor`; `preprocess` + routes to the future-facing `tht preprocess` command; explicit `tht ...` and arbitrary CLI + arguments route to the installed `tht` binary. +- The gate extension's `typebox` runtime dependency is installed from the harness lockfile. + +## Smoke and Diagnostic Results + +```text +docker run --rm thothii-core:test doctor +config: error - configuration is invalid or unreadable +data_root: ok +``` + +Result: expected exit 1 for absent mounted workspace configuration, with no traceback and no +secret-bearing validation detail. + +```text +docker run --rm --entrypoint /app/docker/smoke/core-smoke.sh thothii-core:test +backend listening on http://127.0.0.1:8787 +v22.19.0 +Python 3.12.13 +core smoke: ok +``` + +Result: exit 0. The script asserted non-root execution, `tht --help`, `pi --version`, runtime +version floors, and `GET /health` through curl. Fastify's returned display address was loopback; +the inspected container environment is `HOST=0.0.0.0`, and the compiled server passes that value +to `app.listen`. + +```text +docker run --rm thothii-core:test tht --version +0.1.0 +``` + +Result: arbitrary `tht` entrypoint exited 0. + +An explicit runtime assertion checked UID 10001, exact Node and Pi versions, Python 3.11+, and the +absence of `/app/harness/.env` and `/app/harness/workspaces`; it exited 0. + +## Image Size and Containment Inspection + +```text +docker image inspect thothii-core:test --format '{{.Size}} {{json .Config.User}} {{json .Config.Env}}' +221419008 "10001:10001" [...runtime paths and version metadata only...] +``` + +Image size: **221,419,008 bytes** (about 211.2 MiB). + +`docker history --no-trunc thothii-core:test` was inspected. It contains only Dockerfile commands, +the pinned public package name/version, base-image metadata, and non-sensitive runtime variables; +no credentials or customer paths were found. An in-image filename scan found only +`/app/harness/.pi/settings.json` among `.env`, key/certificate, and settings-name candidates; that +tracked Pi file contains theme/startup preferences, not secrets. The build asserts `.env` and +workspace directories are absent. + +`.dockerignore` excludes VCS/agent state, all environment files except examples, package-manager +credential files, SSH/private-key and certificate formats, local virtualenvs/node_modules/caches, +backend runtime data, customer workspaces, sessions, artifacts, indexes, corpus, and deployment +mount content. + +## Self-review + +- `git diff --check` is clean. +- Entrypoint processes use `exec`, preserving container signal handling. +- Backend production dependencies are pruned; TypeScript build tools remain in the build stage. +- The writable `/data` root is owned by UID 10001; application payload remains root-owned and + read-only to the runtime user. +- CA certificates and curl are present for HTTPS integrations and health probing. +- No existing source, customer workspace, secret, or unrelated progress-ledger change is included + in the task commit. + +## Concerns + +- The `tht preprocess` command is deliberately a future-facing routing contract; its CLI group is + scheduled in the Evidence/preprocessing plan and is not implemented in the current harness. +- Python dependencies are range-resolved because the existing harness has no Python lockfile. The + Pi package, Node runtime, and package-lock-backed Node dependency sets are pinned/reproducible. +- The image was built and smoked on Docker Desktop arm64. The chosen official multi-arch base + images and Pi package are architecture-neutral at the package level, but amd64 still needs a CI + build/smoke before being advertised as verified. diff --git a/docker/core-entrypoint.sh b/docker/core-entrypoint.sh new file mode 100755 index 00000000..0f0d2b84 --- /dev/null +++ b/docker/core-entrypoint.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +case "${1:-server}" in + server) + shift || true + exec node /app/backend/dist/server.js "$@" + ;; + doctor) + shift + exec tht doctor "$@" + ;; + preprocess) + shift + exec tht preprocess "$@" + ;; + tht) + shift + exec tht "$@" + ;; + *) + exec tht "$@" + ;; +esac diff --git a/docker/core.Dockerfile b/docker/core.Dockerfile new file mode 100644 index 00000000..56933117 --- /dev/null +++ b/docker/core.Dockerfile @@ -0,0 +1,64 @@ +# syntax=docker/dockerfile:1 +FROM node:22.19.0-bookworm-slim AS node-runtime + +RUN npm install --global --ignore-scripts --no-audit --no-fund \ + @earendil-works/pi-coding-agent@0.80.3 \ + && pi --version + +FROM node:22.19.0-bookworm-slim AS backend-build +WORKDIR /src/backend +COPY backend/package.json backend/package-lock.json ./ +RUN npm ci --no-audit --no-fund +COPY backend/ ./ +RUN npm run build \ + && npm prune --omit=dev + +FROM node:22.19.0-bookworm-slim AS gate-deps +WORKDIR /src/harness +COPY harness/package.json harness/package-lock.json ./ +RUN npm ci --no-audit --no-fund + +FROM python:3.12-slim-bookworm AS runtime + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --uid 10001 thoth \ + && mkdir -p /app/backend /app/docker/smoke /data/settings \ + && chown -R thoth:thoth /data + +COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node +COPY --from=node-runtime /usr/local/lib/node_modules /usr/local/lib/node_modules +RUN ln -s /usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js /usr/local/bin/pi \ + && node --version \ + && pi --version + +WORKDIR /app +COPY harness/ /app/harness/ +COPY --from=gate-deps /src/harness/node_modules /app/harness/node_modules +RUN python -m venv /opt/venv \ + && /opt/venv/bin/pip install --no-cache-dir /app/harness + +COPY --from=backend-build /src/backend/dist /app/backend/dist +COPY --from=backend-build /src/backend/node_modules /app/backend/node_modules +COPY docker/core-entrypoint.sh /app/docker/core-entrypoint.sh +COPY docker/smoke/core-smoke.sh /app/docker/smoke/core-smoke.sh +RUN chmod 0555 /app/docker/core-entrypoint.sh /app/docker/smoke/core-smoke.sh \ + && test ! -e /app/harness/.env \ + && test ! -d /app/harness/workspaces + +ENV PATH="/opt/venv/bin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ + HOST=0.0.0.0 \ + PORT=8787 \ + THT_HARNESS_DIR=/app/harness \ + THT_BIN=/opt/venv/bin/tht \ + PI_BIN=/usr/local/bin/pi \ + THT_DATA_ROOT=/data \ + SETTINGS_FILE=/data/settings/settings.json \ + HOME=/home/thoth + +WORKDIR /app/harness +EXPOSE 8787 +USER 10001:10001 +ENTRYPOINT ["/app/docker/core-entrypoint.sh"] +CMD ["server"] diff --git a/docker/smoke/core-smoke.sh b/docker/smoke/core-smoke.sh new file mode 100755 index 00000000..77c968d7 --- /dev/null +++ b/docker/smoke/core-smoke.sh @@ -0,0 +1,36 @@ +#!/bin/sh +set -eu + +test "$(id -u)" != "0" + +node_version="$(node --version)" +python_version="$(python --version 2>&1)" +case "$node_version" in + v22.19.*|v22.2[0-9].*|v2[3-9].*|v[3-9][0-9].*) ;; + *) echo "Node 22.19+ required, found $node_version" >&2; exit 1 ;; +esac +case "$python_version" in + "Python 3.1"[1-9].*|"Python 3."[2-9][0-9].*) ;; + *) echo "Python 3.11+ required, found $python_version" >&2; exit 1 ;; +esac + +tht --help >/dev/null +pi --version >/dev/null + +/app/docker/core-entrypoint.sh server & +server_pid=$! +trap 'kill "$server_pid" 2>/dev/null || true; wait "$server_pid" 2>/dev/null || true' EXIT INT TERM + +attempt=0 +until curl --fail --silent --show-error http://127.0.0.1:8787/health >/dev/null; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 30 ]; then + echo "backend health check did not become ready" >&2 + exit 1 + fi + sleep 1 +done + +echo "$node_version" +echo "$python_version" +echo "core smoke: ok"