mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
638bd808f8
Phase 2b of the open-core hybrid extraction. After Phase 2a (PR #880) wired the public-side loader to dynamic-import the private package, this PR makes production Docker builds actually install the package so the runtime path uses it. Single image; saelix/sencho remains the only published image (the original ADR's dual-image plan was rejected because customers buying paid tiers would otherwise need GitHub auth to pull a second image, breaking the purchase flow). Dockerfile (prod-deps stage): a new RUN block after npm ci installs @studio-saelix/sencho-pro using a BuildKit secret-mounted github_token for npm.pkg.github.com auth. The .npmrc carrying the token is written and removed inside the same RUN, plus /root/.npm is wiped to scrub any verbose-log artifacts that npm might otherwise stash. The token never enters an image layer (BuildKit excludes secret content from both layer filesystems and cache keys; docker history shows the literal $(cat /run/secrets/...) command, not the substituted value). PRO_PACKAGE_VERSION is a build arg pinned by CI to a literal SemVer (0.1.0 today) so the scan build and the publish build resolve to the same package version. Default of `latest` keeps local builds convenient. When the pro package ships a new version, bump the value in docker-publish.yml in the same PR that ships the matching public Sencho release; release-please does not coordinate the two cadences. Empty-secret branch (no github_token provided, e.g. local dev or fork PRs) skips the install and prints a notice. The resulting image runs through the loader's in-tree LicenseService fallback, so PR validation builds and contributor builds work without any GitHub auth setup. docker-publish.yml: both build-push-action invocations (the pre-publish scan and the multi-arch publish) pass the github_token secret and PRO_PACKAGE_VERSION build arg. The auto-provisioned GITHUB_TOKEN's packages:read scope is sufficient because the public Sencho repo and the private package live in the same Studio-Saelix GitHub org. Moving the package to a different org would silently break this contract; the Dockerfile comment block records the invariant. ci.yml is intentionally not changed. The PR-time Docker validation job builds without the secret and exercises the loader's in-tree fallback path, which is correct for fork PRs (no token access) and useful for catching fallback-path regressions. Test plan: tsc clean (no TS changes). The dockerfile install path is exercised by the next release's pre-publish scan + smoke test, both of which boot the actual image and call /api/health. Failed dynamic import or constructor throw would block bootstrap before the listener binds, so the existing smoke test covers the runtime contract.
326 lines
15 KiB
Docker
326 lines
15 KiB
Docker
# Cross-compilation helper - provides xx-clang, xx-apk, etc.
|
|
# Runs on the BUILD platform; its binaries are copied into build stages below.
|
|
# Digest pinned to prevent silent base-image changes between scan and publish.
|
|
FROM --platform=$BUILDPLATFORM tonistiigi/xx@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx
|
|
|
|
# Stage 1: Build Frontend
|
|
# Runs on the BUILD platform (amd64) - frontend has no native modules so the
|
|
# compiled output (JS/CSS/HTML) is entirely platform-agnostic.
|
|
FROM --platform=$BUILDPLATFORM node:25-alpine@sha256:bdf2cca6fe3dabd014ea60163eca3f0f7015fbd5c7ee1b0e9ccb4ced6eb02ef4 AS frontend-builder
|
|
|
|
WORKDIR /app/frontend
|
|
|
|
COPY frontend/package*.json frontend/.npmrc ./
|
|
RUN npm config set fetch-retry-maxtimeout 120000 && \
|
|
npm config set fetch-retries 5 && \
|
|
npm ci
|
|
|
|
COPY frontend/ ./
|
|
# vite.config.ts reads the root package.json for the app version
|
|
COPY package.json /app/package.json
|
|
RUN npm run build
|
|
|
|
# Stage 2: Compile TypeScript
|
|
# Runs on the BUILD platform (amd64) - tsc output is platform-agnostic JS.
|
|
FROM --platform=$BUILDPLATFORM node:25-alpine@sha256:bdf2cca6fe3dabd014ea60163eca3f0f7015fbd5c7ee1b0e9ccb4ced6eb02ef4 AS backend-builder
|
|
|
|
WORKDIR /app/backend
|
|
|
|
RUN apk add --no-cache python3 make g++
|
|
|
|
COPY backend/package*.json backend/.npmrc ./
|
|
RUN npm config set fetch-retry-maxtimeout 120000 && \
|
|
npm config set fetch-retries 5 && \
|
|
npm ci
|
|
|
|
COPY backend/ ./
|
|
# prebuild hook (generate-version.js) reads the root package.json for the app version
|
|
COPY package.json /app/package.json
|
|
RUN npm run build
|
|
|
|
# Stage 3: Production dependencies (cross-compiled - NO QEMU execution)
|
|
# Runs on the BUILD platform (amd64) but compiles native modules
|
|
# (bcrypt, better-sqlite3, node-pty) for the TARGET platform using
|
|
# tonistiigi/xx + clang as the cross-compiler.
|
|
# This avoids the Node.js v20 SIGILL crash that occurs when npm runs
|
|
# under QEMU because QEMU lacks ARMv8.1 LSE atomic instruction support.
|
|
FROM --platform=$BUILDPLATFORM node:25-alpine@sha256:bdf2cca6fe3dabd014ea60163eca3f0f7015fbd5c7ee1b0e9ccb4ced6eb02ef4 AS prod-deps
|
|
|
|
# Copy xx cross-compilation tools into this stage
|
|
COPY --from=xx / /
|
|
|
|
ARG TARGETARCH
|
|
ARG BUILDARCH
|
|
|
|
WORKDIR /app
|
|
|
|
# Two paths depending on whether we are cross-compiling:
|
|
#
|
|
# Native (TARGETARCH == BUILDARCH, e.g. amd64 → amd64):
|
|
# Standard g++ is used. xx-clang introduces sysroot flags that conflict with
|
|
# node-gyp's header resolution on Alpine for same-platform builds, so we
|
|
# bypass it entirely and let npm ci use the host compiler directly.
|
|
#
|
|
# Cross (TARGETARCH != BUILDARCH, e.g. amd64 → arm64):
|
|
# xx-clang targets the foreign architecture without QEMU. The target sysroot
|
|
# is populated via xx-apk:
|
|
# g++ - libstdc++ headers/libs (all three native modules use C++)
|
|
# musl-dev - musl libc headers for the target arch
|
|
# linux-headers - <pty.h> / <termios.h> required by node-pty
|
|
RUN if [ "$TARGETARCH" = "$BUILDARCH" ]; then \
|
|
apk add --no-cache python3 make g++; \
|
|
else \
|
|
apk add --no-cache clang lld python3 make g++ && \
|
|
xx-apk add --no-cache g++ musl-dev linux-headers; \
|
|
fi
|
|
|
|
COPY backend/package*.json backend/.npmrc ./
|
|
|
|
# Native: plain npm ci - g++ compiles native modules for the host arch.
|
|
# Cross: npm_config_arch tells prebuild-install/node-pre-gyp which pre-built
|
|
# binary to attempt; CC/CXX/AR route compilation through xx-clang so
|
|
# the output targets the foreign arch without any QEMU emulation.
|
|
RUN if [ "$TARGETARCH" = "$BUILDARCH" ]; then \
|
|
npm ci --omit=dev; \
|
|
else \
|
|
npm_config_arch=$TARGETARCH \
|
|
CC=xx-clang \
|
|
CXX=xx-clang++ \
|
|
AR=xx-ar \
|
|
npm ci --omit=dev; \
|
|
fi
|
|
|
|
# Add the private @studio-saelix/sencho-pro package on top of the
|
|
# production dependencies installed above. The package contains the
|
|
# Lemon Squeezy validation client; the public Sencho core's
|
|
# loadEntitlementProvider() dynamic-imports it at runtime when present
|
|
# and falls back to the in-tree LicenseService when it is not.
|
|
#
|
|
# Authentication uses a BuildKit secret rather than a build arg so the
|
|
# token never lands in an image layer. The github_token secret is
|
|
# expected to carry packages:read scope against the Studio-Saelix org;
|
|
# CI passes the auto-provisioned GITHUB_TOKEN, which has that scope
|
|
# automatically because the public Sencho repo and the private package
|
|
# are in the same GitHub org. Moving the package to a different org
|
|
# would silently break this contract; the auto-token would lose scope
|
|
# and every build would fall through to the empty-secret branch.
|
|
# Local builds without the secret skip the install entirely and the
|
|
# resulting image runs through the loader's in-tree fallback.
|
|
#
|
|
# The install is pure-JS (axios is the only runtime dep, no native
|
|
# modules) so cross-compilation env vars are not needed here. We use
|
|
# `npm install --no-save` so package.json and package-lock.json stay
|
|
# unchanged in the source tree; the package is added to node_modules
|
|
# only inside this build layer.
|
|
#
|
|
# PRO_PACKAGE_VERSION is pinned by CI at build time (a literal SemVer
|
|
# like `0.1.0`) so the scan build and the publish build resolve to the
|
|
# same package version. Defaulting to `latest` keeps local builds
|
|
# convenient; CI overrides this so production never tracks a moving
|
|
# tag.
|
|
ARG PRO_PACKAGE_VERSION=latest
|
|
RUN --mount=type=secret,id=github_token \
|
|
if [ -s /run/secrets/github_token ]; then \
|
|
printf '@studio-saelix:registry=https://npm.pkg.github.com\n//npm.pkg.github.com/:_authToken=%s\n' "$(cat /run/secrets/github_token)" > /root/.npmrc && \
|
|
npm install --no-save --omit=dev "@studio-saelix/sencho-pro@${PRO_PACKAGE_VERSION}" && \
|
|
rm -f /root/.npmrc && \
|
|
rm -rf /root/.npm; \
|
|
else \
|
|
echo "[Sencho] No github_token secret provided; @studio-saelix/sencho-pro will not be installed. Loader will use the in-tree LicenseService fallback at runtime."; \
|
|
fi
|
|
|
|
# Stage 4a: Build Docker CLI from source against Go 1.26.2
|
|
#
|
|
# CLI v29.4.1 ships otel/sdk v1.43.0, resolving CVE-2026-39883 (BSD kenv) and
|
|
# CVE-2026-39882 (OTLP response OOM). It also carries the CVE-2025-15558 fix
|
|
# (Windows plugin search path LPE, fixed since v29.2.0). Building from source
|
|
# with Go 1.26.2 additionally eliminates Go stdlib CVEs present in the upstream
|
|
# static binary.
|
|
#
|
|
# Runs on the BUILD platform; GOARCH cross-compiles the static binary for TARGET.
|
|
# The --depth 1 clone fetches only the v29.4.1 tag commit, minimising transfer size.
|
|
# docker/cli uses CalVer and ships vendor.mod instead of go.mod to avoid SemVer
|
|
# compliance requirements. We copy vendor.mod -> go.mod and build with -mod=vendor
|
|
# so all deps come from the vendored tree (no network access needed).
|
|
FROM --platform=$BUILDPLATFORM golang:1.26-alpine@sha256:f85330846cde1e57ca9ec309382da3b8e6ae3ab943d2739500e08c86393a21b1 AS cli-builder
|
|
|
|
ARG TARGETARCH
|
|
|
|
RUN apk add --no-cache git
|
|
|
|
RUN git clone --depth 1 --branch v29.4.1 https://github.com/docker/cli.git /src/docker-cli
|
|
|
|
WORKDIR /src/docker-cli
|
|
|
|
RUN mkdir -p /build
|
|
|
|
RUN cp vendor.mod go.mod && cp vendor.sum go.sum && \
|
|
CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
|
-mod=vendor \
|
|
-ldflags "-extldflags=-static \
|
|
-X github.com/docker/cli/cli/version.Version=29.4.1 \
|
|
-X github.com/docker/cli/cli/version.GitCommit=source-go1.26.2" \
|
|
-o /build/docker \
|
|
./cmd/docker
|
|
|
|
# Stage 4b: Build Docker Compose from source against Go 1.26.2
|
|
#
|
|
# Compose v5.1.3 removed the direct dependency on github.com/docker/docker
|
|
# (replaced by moby/moby/api + moby/moby/client), eliminating CVE-2026-34040
|
|
# and CVE-2026-33997 at the dependency level. Rebuilding with the patched Go
|
|
# toolchain eliminates Go stdlib CVEs from the binary's SBOM.
|
|
#
|
|
# Compose v5.1.3 still bundles otel/sdk v1.42.0 transitively via buildkit
|
|
# v0.29.0. The go get step below bumps otel to v1.43.0 to resolve
|
|
# CVE-2026-39883 (BSD kenv) and CVE-2026-39882 (OTLP response OOM) so that
|
|
# the compose binary scans completely clean.
|
|
FROM --platform=$BUILDPLATFORM golang:1.26-alpine@sha256:f85330846cde1e57ca9ec309382da3b8e6ae3ab943d2739500e08c86393a21b1 AS compose-builder
|
|
|
|
ARG TARGETARCH
|
|
|
|
RUN apk add --no-cache git
|
|
|
|
RUN git clone --depth 1 --branch v5.1.3 https://github.com/docker/compose.git /src/docker-compose
|
|
|
|
WORKDIR /src/docker-compose
|
|
|
|
RUN mkdir -p /build
|
|
|
|
# Patch otel/sdk and exporters from v1.42.0 → v1.43.0 to clear CVE-2026-39883
|
|
# and CVE-2026-39882. This is a targeted security bump; otel 1.42→1.43 is a
|
|
# patch-level fix with no breaking API changes.
|
|
RUN --mount=type=cache,id=go-mod,sharing=locked,target=/go/pkg/mod \
|
|
go get go.opentelemetry.io/otel@v1.43.0 \
|
|
go.opentelemetry.io/otel/sdk@v1.43.0 \
|
|
go.opentelemetry.io/otel/sdk/metric@v1.43.0 \
|
|
go.opentelemetry.io/otel/metric@v1.43.0 \
|
|
go.opentelemetry.io/otel/trace@v1.43.0 \
|
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace@v1.43.0 \
|
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc@v1.43.0 \
|
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0 \
|
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc@v1.43.0 \
|
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp@v1.43.0 && \
|
|
go mod tidy
|
|
|
|
# Build target is ./cmd (the package main with plugin.Run), per docker/compose's
|
|
# Makefile. The directory ./cmd/compose is package compose (cobra command
|
|
# definitions only, not main). The module path moved from /v2 to /v5 in the
|
|
# v5 release, so the Version ldflag must reference /v5/internal.
|
|
RUN --mount=type=cache,id=go-mod,sharing=locked,target=/go/pkg/mod \
|
|
CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
|
|
-trimpath \
|
|
-ldflags "-s -w -extldflags=-static \
|
|
-X github.com/docker/compose/v5/internal.Version=v5.1.3" \
|
|
-o /build/docker-compose \
|
|
./cmd
|
|
|
|
# Sanity check: fail the stage immediately if go build did not produce an ELF
|
|
# executable. Catches the failure mode where -o file points to a non-main
|
|
# package and Go writes an ar-format archive that passes COPY + chmod but is
|
|
# not exec-able by the kernel, surfacing only as an opaque plugin-not-found
|
|
# error from the Docker CLI plugin manager hundreds of build steps later.
|
|
# `od -tx1` is used instead of `-c` because busybox and GNU coreutils render
|
|
# `-c` with different field padding; hex output is stable across both.
|
|
RUN test -f /build/docker-compose \
|
|
&& magic=$(dd if=/build/docker-compose bs=1 count=4 status=none | od -An -tx1 | tr -d ' \n') \
|
|
&& [ "$magic" = "7f454c46" ]
|
|
|
|
# Stage 5: Production runtime
|
|
# Runs on the TARGET platform - no compilation happens here.
|
|
#
|
|
# Vulnerability scanning uses the external `trivy` CLI. It is not installed
|
|
# in this image; operators who want the feature install Trivy on the host
|
|
# and mount the binary into the container, or run a sidecar. See
|
|
# docs/operations/trivy-setup.mdx for the supported integration paths.
|
|
FROM node:25-alpine@sha256:bdf2cca6fe3dabd014ea60163eca3f0f7015fbd5c7ee1b0e9ccb4ced6eb02ef4
|
|
|
|
# Daily cache-bust for the apk upgrade layer. CI passes the current date
|
|
# (YYYY-MM-DD) as a build-arg, so this RUN layer's hash changes at most
|
|
# once per calendar day. Without this, buildx reuses the cached layer
|
|
# indefinitely and a new Alpine package fix (e.g. an openssl CVE patched
|
|
# upstream in alpine 3.23) sits behind the stale cache until an unrelated
|
|
# change invalidates this line by coincidence. Default value lets local
|
|
# developers build without the arg; production CI always sets it.
|
|
ARG APK_CACHE_BUST=unset
|
|
|
|
# Upgrade all Alpine system packages and install runtime deps.
|
|
# Docker CLI and Compose are copied from source-built stages below,
|
|
# eliminating the curl dependency and all Go stdlib CVEs from the upstream
|
|
# static binaries. npm is removed because it is not needed at runtime;
|
|
# removing it also eliminates CVE-2026-33671 (picomatch ReDoS in npm).
|
|
RUN echo "apk cache bust: ${APK_CACHE_BUST}" && \
|
|
apk upgrade --no-cache && \
|
|
apk add --no-cache bash su-exec && \
|
|
mkdir -p /usr/local/lib/docker/cli-plugins
|
|
|
|
# Copy the source-built Docker CLI and Compose plugin from their builder stages.
|
|
# These binaries were compiled with Go 1.26.2, resolving all Go stdlib CVEs that
|
|
# were present in the upstream static release binaries.
|
|
COPY --from=cli-builder /build/docker /usr/local/bin/docker
|
|
COPY --from=compose-builder /build/docker-compose /usr/local/lib/docker/cli-plugins/docker-compose
|
|
RUN chmod +x /usr/local/bin/docker /usr/local/lib/docker/cli-plugins/docker-compose
|
|
|
|
# Remove npm and npx from the runtime image. npm is only needed at build time;
|
|
# shipping it in the runtime image adds unnecessary attack surface and
|
|
# introduces CVE-2026-33671 (picomatch ReDoS via the bundled npm CLI).
|
|
RUN rm -rf /usr/local/lib/node_modules/npm \
|
|
/usr/local/bin/npm \
|
|
/usr/local/bin/npx
|
|
|
|
WORKDIR /app
|
|
|
|
# Copy cross-compiled production node_modules from the prod-deps stage
|
|
COPY --from=prod-deps /app/node_modules ./node_modules
|
|
COPY --from=prod-deps /app/package.json ./
|
|
|
|
# Copy compiled TypeScript output (platform-agnostic JS)
|
|
COPY --from=backend-builder /app/backend/dist ./dist
|
|
|
|
# Copy built frontend
|
|
COPY --from=frontend-builder /app/frontend/dist ./public
|
|
|
|
# Set environment to production
|
|
ENV NODE_ENV=production
|
|
|
|
# Pre-create the sencho user and group so the SENCHO_USER=sencho opt-out path
|
|
# in docker-entrypoint.sh works out of the box. The default runtime is root;
|
|
# this user only becomes relevant when an operator explicitly sets
|
|
# SENCHO_USER at runtime to drop privileges.
|
|
RUN addgroup -S sencho && adduser -S -G sencho sencho \
|
|
&& mkdir -p /app/data \
|
|
&& chown -R sencho:sencho /app
|
|
|
|
# Sencho runs as root by default. Docker management tools like Portainer,
|
|
# Dockge, Komodo, and Yacht all ship this way because mounting
|
|
# /var/run/docker.sock is already equivalent to root-on-host; a non-root
|
|
# container user buys essentially no extra isolation while breaking
|
|
# filesystem operations against bind mounts that user stacks have chowned.
|
|
#
|
|
# Operators who need a non-root container (compliance scanners, rootless
|
|
# Docker with UID mapping, organisational policy) can set SENCHO_USER=sencho
|
|
# at runtime. The entrypoint handles the privilege drop, data-volume
|
|
# ownership, and Docker socket GID matching in that path.
|
|
#
|
|
# USER directive intentionally absent so the entrypoint controls the runtime
|
|
# user. Static security scanners (Trivy, Docker Scout) may flag this as
|
|
# "running as root" which is the documented and intended default.
|
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
|
# Strip Windows CRLF line endings that can sneak in on Windows dev machines
|
|
# even with .gitattributes eol=lf, then make executable. A shell script with
|
|
# \r in tokens like "fi\r" will fail with "unexpected end of file" in Alpine.
|
|
RUN sed -i 's/\r//' /usr/local/bin/docker-entrypoint.sh \
|
|
&& chmod +x /usr/local/bin/docker-entrypoint.sh
|
|
|
|
# Expose port
|
|
EXPOSE 1852
|
|
|
|
# Health check - polls the public /api/health endpoint every 30s
|
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
|
CMD node -e "const h=require('http');h.get('http://localhost:1852/api/health',r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"
|
|
|
|
# Entrypoint ensures /app/data is writable and execs the CMD as root by default,
|
|
# or drops to $SENCHO_USER via su-exec when that env var is set (see comment above).
|
|
# CMD provides the default arguments passed through to the entrypoint.
|
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
|
CMD ["node", "dist/index.js"]
|