CI / verify (pull_request) Failing after 47s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped
The production image now contains zero feature modules and is the same for every installation. Installed modules live in module storage that one Core resolver selects from EKKLESIA_MODULES_DIR: the checkout's modules/ when unset, an absolute path that must exist when set (/var/lib/ekklesia/modules in the image). A relative, blank, missing, or unreadable configured directory fails closed with a sanitized error that never contains the value. - Host configuration reads EKKLESIA_MODULES_DIR. The API, migration plan, apply and validate, module validation, module enablement, and Doctor all resolve it the same way; the test-only API override still takes precedence. - Doctor receives Core's migrations directory explicitly instead of deriving it from the modules directory's parent. - The migration runner retains and reports the applied history of a module that is not installed, and plans and applies Core's and present modules' migrations around it. Core's history and every installed module's history stay strict: a missing or changed applied migration, or a downgrade, still fails. No down migrations, no data deletion. - deploy/Dockerfile copies no module and installs nothing for the module template; .dockerignore excludes modules/ and the build refuses a context that contains it; the runtime has an empty root-owned /var/lib/ekklesia/modules and sets EKKLESIA_MODULES_DIR. - deploy/compose.yaml bind-mounts EKKLESIA_MODULES_HOST_DIR read-only into api and migrate, with create_host_path disabled. - scripts/check-image.sh proves the image holds no modules and that a populated modules/ directory leaves its runtime file system identical; CI runs it. - The split deploy smoke now uses the same generic image: it boots with no modules, installs a self-contained test module packaged from the template (test-only), migrates and restarts, removes it (Core runs, planning reports the retained history, data survives), and restores it with its data. Docs: deploy README (module storage, install/update/remove/restore, the runtime modules target), .env.example, API configuration, module development guide, testing, workflow, security, and the architecture overview. Co-Authored-By: Claude Opus 5.5 <[email protected]>
140 lines
8.0 KiB
Docker
140 lines
8.0 KiB
Docker
# Ekklesia's one generic platform image (ADR 0044, ADR 0048): the `api` service and every operator-run
|
|
# CLI, from one build, for every installation. See deploy/README.md for how to build and run it.
|
|
#
|
|
# The build context is this repository. The image contains **zero feature modules**: installed
|
|
# modules live in installation-owned storage, mounted read-only at /var/lib/ekklesia/modules
|
|
# (EKKLESIA_MODULES_DIR) and discovered when a process starts. `.dockerignore` keeps a checkout's
|
|
# `modules/` out of the context, so a build is the same whether or not one is present.
|
|
#
|
|
# No `# syntax=` directive, deliberately: it would make BuildKit pull an unpinned frontend image.
|
|
# The built-in frontend supports everything below, including cache mounts.
|
|
|
|
# Node.js 24.18.0 (matching `.node-version`) on Debian 13 slim, pinned by its multi-architecture
|
|
# index digest. glibc rather than musl, because module dependencies such as sharp ship glibc
|
|
# prebuilt binaries. Every stage starts from this exact image.
|
|
FROM node:24.18.0-trixie-slim@sha256:ae91dcc111a68c9d2d81ff2a17bda61be126426176fde6fe7d08ab13b7f50573 AS base
|
|
|
|
# ---------------------------------------------------------------------------------------------------
|
|
# toolchain: pnpm through Corepack, at exactly the root `packageManager` version.
|
|
# ---------------------------------------------------------------------------------------------------
|
|
FROM base AS toolchain
|
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
|
PNPM_HOME=/pnpm \
|
|
TURBO_TELEMETRY_DISABLED=1 \
|
|
DO_NOT_TRACK=1
|
|
WORKDIR /opt/ekklesia
|
|
COPY package.json ./
|
|
# Corepack reads `packageManager` (pnpm@<exact version>) from the root manifest, so the version is
|
|
# declared once, in the repository, and never restated here.
|
|
RUN corepack enable && corepack install
|
|
|
|
# ---------------------------------------------------------------------------------------------------
|
|
# manifests: the lockfile, workspace configuration, and the package.json of every platform package
|
|
# the runtime needs, at their repository paths. Dependency installation keys on these alone, so a
|
|
# source-only change reuses the installed layers. The module template is a workspace package but not
|
|
# part of the platform, so neither its manifest nor its dependencies reach the runtime.
|
|
# ---------------------------------------------------------------------------------------------------
|
|
FROM base AS manifests
|
|
WORKDIR /context
|
|
COPY . .
|
|
RUN set -eu; \
|
|
mkdir /manifests; \
|
|
cp package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc /manifests/; \
|
|
for manifest in apps/*/package.json core/package.json packages/*/package.json \
|
|
surfaces/*/package.json; do \
|
|
if [ -f "$manifest" ]; then install -D -m 0644 "$manifest" "/manifests/$manifest"; fi; \
|
|
done
|
|
|
|
# ---------------------------------------------------------------------------------------------------
|
|
# build: full install, validation, build, then the exact runtime file set.
|
|
# ---------------------------------------------------------------------------------------------------
|
|
FROM toolchain AS build
|
|
# Sources first, then one install: workspace packages publish `bin` commands from source files (the
|
|
# module UI builder's, which every module build invokes), and pnpm links a bin only when its target
|
|
# already exists. The store cache mount keeps a rebuild off the network all the same.
|
|
COPY . .
|
|
# A second line of defence behind `.dockerignore`: a context that still carries installed modules is
|
|
# refused rather than built into a platform image.
|
|
RUN if [ -e modules ]; then \
|
|
echo "The build context contains modules/. Installed modules never enter the platform image (ADR 0048)." >&2; \
|
|
exit 1; \
|
|
fi
|
|
RUN --mount=type=cache,id=ekklesia-pnpm-store,target=/pnpm/store \
|
|
pnpm install --frozen-lockfile --store-dir /pnpm/store
|
|
# The same gates `pnpm verify` runs for boundaries, modules, and migrations. With no modules in the
|
|
# context, module validation covers the empty set and migration validation covers Core's own. Each
|
|
# validate script builds first; the final build is a Turbo cache hit that keeps the intent explicit.
|
|
RUN pnpm boundaries && pnpm modules:validate && pnpm migrations:validate && pnpm build
|
|
# What the runtime resolves at startup, at the paths it resolves them from:
|
|
# - every platform package's `dist/` (every package exports only from `dist/`), which includes
|
|
# the Dashboard and display host assets under `surfaces/shell/dist/browser/`;
|
|
# - Core's migrations, `core/drizzle/` with its `meta/` journal.
|
|
# Never a module and never the module template. `node_modules` comes from the production-only
|
|
# install below, never from this stage.
|
|
RUN set -eu; \
|
|
mkdir -p /runtime/core; \
|
|
for package in apps/* core packages/* surfaces/*; do \
|
|
if [ -d "$package/dist" ]; then \
|
|
mkdir -p "/runtime/$package"; \
|
|
cp -a "$package/dist" "/runtime/$package/dist"; \
|
|
fi; \
|
|
done; \
|
|
cp -a core/drizzle /runtime/core/drizzle
|
|
|
|
# ---------------------------------------------------------------------------------------------------
|
|
# prod-deps: the same lockfile, production dependencies only, for the platform packages alone. No
|
|
# compiler, bundler, or test runner reaches the runtime image, and neither does anything only the
|
|
# module template depends on.
|
|
# ---------------------------------------------------------------------------------------------------
|
|
FROM toolchain AS prod-deps
|
|
COPY --from=manifests /manifests/ ./
|
|
RUN --mount=type=cache,id=ekklesia-pnpm-store,target=/pnpm/store \
|
|
pnpm install --frozen-lockfile --prod --store-dir /pnpm/store
|
|
|
|
# ---------------------------------------------------------------------------------------------------
|
|
# runtime
|
|
# ---------------------------------------------------------------------------------------------------
|
|
FROM base AS runtime
|
|
|
|
ARG VERSION=""
|
|
ARG REVISION=""
|
|
ARG SOURCE=""
|
|
LABEL org.opencontainers.image.title="Ekklesia" \
|
|
org.opencontainers.image.description="Ekklesia API, Dashboard, display host, and operator CLIs" \
|
|
org.opencontainers.image.version="${VERSION}" \
|
|
org.opencontainers.image.revision="${REVISION}" \
|
|
org.opencontainers.image.source="${SOURCE}" \
|
|
org.opencontainers.image.base.name="docker.io/library/node:24.18.0-trixie-slim" \
|
|
org.opencontainers.image.base.digest="sha256:ae91dcc111a68c9d2d81ff2a17bda61be126426176fde6fe7d08ab13b7f50573"
|
|
|
|
# Only the container's own listen address differs from the platform default: outside a container
|
|
# the API still binds 127.0.0.1. `production` is stated rather than relied on as the default.
|
|
ENV NODE_ENV=production \
|
|
EKKLESIA_API_HOST=0.0.0.0 \
|
|
EKKLESIA_API_PORT=3000 \
|
|
EKKLESIA_MODULES_DIR=/var/lib/ekklesia/modules
|
|
|
|
# The repository layout, preserved: the API finds the repository root three directories above
|
|
# `apps/api/dist/`, and every CLI uses its working directory as the root. Installed modules are not
|
|
# under it: they are read from EKKLESIA_MODULES_DIR.
|
|
WORKDIR /opt/ekklesia
|
|
COPY --from=prod-deps /opt/ekklesia/ ./
|
|
COPY --from=build /runtime/ ./
|
|
# The mount point for installation-owned module storage (ADR 0048), empty in the image. An image run
|
|
# with nothing mounted there has zero modules; the reference deployment mounts host storage
|
|
# read-only over it.
|
|
RUN install -d -o root -g root -m 0755 /var/lib/ekklesia/modules
|
|
|
|
# Files stay root-owned: the unprivileged runtime user can read the installation, never modify it.
|
|
USER node
|
|
EXPOSE 3000
|
|
|
|
# Readiness, not liveness: an instance whose database is unreachable or that is shutting down is
|
|
# unhealthy. Node's own fetch, so the image needs no curl.
|
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
|
CMD ["node", "-e", "fetch('http://127.0.0.1:' + (process.env.EKKLESIA_API_PORT || '3000') + '/api/v1/health/ready', { signal: AbortSignal.timeout(4000) }).then((response) => process.exit(response.status === 200 ? 0 : 1), () => process.exit(1))"]
|
|
|
|
# `pnpm api:start` without its build step and without `--env-file`: configuration comes only from
|
|
# the container environment. The same image runs every CLI: `node core/dist/cli/<command>.js`.
|
|
CMD ["node", "apps/api/dist/server.js"]
|