mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix: harden blueprint deployment guardrails (#1027)
* fix: harden blueprint deployment guardrails * fix: update Docker toolchain to Go 1.26.3 * fix: repair Dockerfile tr argument split across lines * fix: bump protobufjs to clear npm audit high-severity advisories
This commit is contained in:
+286
-286
@@ -1,286 +1,286 @@
|
||||
# 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
|
||||
|
||||
# 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"]
|
||||
# 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
|
||||
|
||||
# Stage 4a: Build Docker CLI from source against Go 1.26.3
|
||||
#
|
||||
# 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.3 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.3-alpine 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.3" \
|
||||
-o /build/docker \
|
||||
./cmd/docker
|
||||
|
||||
# Stage 4b: Build Docker Compose from source against Go 1.26.3
|
||||
#
|
||||
# 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.3-alpine 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.3, 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"]
|
||||
|
||||
Generated
+8337
-8337
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
import { MAX_BLUEPRINT_COMPOSE_BYTES } from '../routes/blueprints';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let LicenseService: typeof import('../services/LicenseService').LicenseService;
|
||||
let adminCookie: string;
|
||||
let counter = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ LicenseService } = await import('../services/LicenseService'));
|
||||
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
const db = DatabaseService.getInstance().getDb();
|
||||
db.prepare('DELETE FROM blueprint_deployments').run();
|
||||
db.prepare('DELETE FROM blueprints').run();
|
||||
});
|
||||
|
||||
function validCreateBody(composeContent: string) {
|
||||
counter += 1;
|
||||
return {
|
||||
name: `route-validate-${counter}`,
|
||||
description: null,
|
||||
compose_content: composeContent,
|
||||
selector: { type: 'nodes', ids: [1] },
|
||||
drift_mode: 'suggest',
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Blueprint route compose validation', () => {
|
||||
it('rejects invalid compose YAML on create', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/blueprints')
|
||||
.set('Cookie', adminCookie)
|
||||
.send(validCreateBody('services:\n bad: : nope:'));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('compose_content must be valid YAML');
|
||||
expect(DatabaseService.getInstance().listBlueprints()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects oversized compose content on create', async () => {
|
||||
const oversized = `services:\n app:\n image: nginx\n labels:\n filler: "${'x'.repeat(MAX_BLUEPRINT_COMPOSE_BYTES)}"\n`;
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/blueprints')
|
||||
.set('Cookie', adminCookie)
|
||||
.send(validCreateBody(oversized));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain(`${MAX_BLUEPRINT_COMPOSE_BYTES} bytes or fewer`);
|
||||
expect(DatabaseService.getInstance().listBlueprints()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects oversized compose content on analyze', async () => {
|
||||
const oversized = `services:\n app:\n image: nginx\n labels:\n filler: "${'x'.repeat(MAX_BLUEPRINT_COMPOSE_BYTES)}"\n`;
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/blueprints/analyze')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ compose_content: oversized });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain(`${MAX_BLUEPRINT_COMPOSE_BYTES} bytes or fewer`);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* BlueprintReconciler decision-logic tests.
|
||||
*
|
||||
* The reconciler's `computeDecision` is the load-bearing pure logic — given
|
||||
* The reconciler's `computeDecision` is the load-bearing pure logic. Given
|
||||
* a blueprint, an actual deployment table, and a desired node set, it must
|
||||
* decide for each node whether to deploy, withdraw, drift-check, state-review,
|
||||
* or evict-block. We test that decision in isolation by accessing the
|
||||
@@ -9,10 +9,10 @@
|
||||
* pattern.
|
||||
*
|
||||
* Local deploy / remote HTTP / actual `docker compose` invocation are not
|
||||
* exercised here — they're integration concerns covered by the manual
|
||||
* exercised here; they're integration concerns covered by the manual
|
||||
* lifecycle in the plan.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
import type { Blueprint, Node } from '../services/DatabaseService';
|
||||
import type { ReconcileDecision } from '../services/BlueprintReconciler';
|
||||
@@ -42,6 +42,8 @@ beforeEach(() => {
|
||||
db.prepare('DELETE FROM blueprints').run();
|
||||
db.prepare('DELETE FROM node_labels').run();
|
||||
db.prepare("DELETE FROM nodes WHERE is_default = 0").run();
|
||||
db.prepare("UPDATE global_settings SET value = '0' WHERE key = 'developer_mode'").run();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function seedNode(): number {
|
||||
@@ -128,6 +130,22 @@ describe('BlueprintReconciler.computeDecision', () => {
|
||||
expect(decision.deploy.map((n: { id: number }) => n.id)).toContain(nodeId);
|
||||
});
|
||||
|
||||
it('queues state-review when a stateful deployment revision moves', () => {
|
||||
const nodeId = seedNode();
|
||||
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [nodeId] });
|
||||
DatabaseService.getInstance().upsertDeployment({
|
||||
blueprint_id: bp.id,
|
||||
node_id: nodeId,
|
||||
status: 'active',
|
||||
applied_revision: bp.revision - 1,
|
||||
});
|
||||
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute;
|
||||
const allNodes = DatabaseService.getInstance().getNodes();
|
||||
const decision = reconciler.computeDecision(bp, allNodes);
|
||||
expect(decision.stateReview.map((n: { id: number }) => n.id)).toContain(nodeId);
|
||||
expect(decision.deploy).toEqual([]);
|
||||
});
|
||||
|
||||
it('queues stateless eviction when a node leaves the selector', () => {
|
||||
const nodeId = seedNode();
|
||||
const bp = seedBlueprint({ classification: 'stateless', nodeIds: [] });
|
||||
@@ -352,6 +370,29 @@ describe('BlueprintReconciler.computeDecision', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('BlueprintReconciler developer-mode diagnostics', () => {
|
||||
it('does not emit diagnostic logs when developer mode is off', async () => {
|
||||
const nodeId = seedNode();
|
||||
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [nodeId] });
|
||||
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined);
|
||||
|
||||
await BlueprintReconciler.getInstance().reconcileOne(bp.id);
|
||||
|
||||
expect(infoSpy.mock.calls.some(([message]) => String(message).includes('[BlueprintReconciler:diag]'))).toBe(false);
|
||||
});
|
||||
|
||||
it('emits diagnostic decision logs when developer mode is on', async () => {
|
||||
DatabaseService.getInstance().updateGlobalSetting('developer_mode', '1');
|
||||
const nodeId = seedNode();
|
||||
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [nodeId] });
|
||||
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined);
|
||||
|
||||
await BlueprintReconciler.getInstance().reconcileOne(bp.id);
|
||||
|
||||
expect(infoSpy.mock.calls.some(([message]) => String(message).includes('[BlueprintReconciler:diag]'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BlueprintService marker parsing + name-conflict guard', () => {
|
||||
it('parseMarker accepts a well-formed marker', () => {
|
||||
const marker = BlueprintService.parseMarker(JSON.stringify({ blueprintId: 7, revision: 3, lastApplied: 12345 }));
|
||||
|
||||
@@ -57,7 +57,7 @@ vi.mock('../services/FleetSyncService', () => ({
|
||||
FleetSyncService: { getSelfIdentity: () => 'self-node' },
|
||||
}));
|
||||
|
||||
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
|
||||
import { enforcePolicyForImageRefs, enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
|
||||
|
||||
function mkPolicy(overrides: Partial<ScanPolicy> = {}): ScanPolicy {
|
||||
return {
|
||||
@@ -275,4 +275,43 @@ describe('enforcePolicyPreDeploy', () => {
|
||||
expect(result.violations).toEqual([]);
|
||||
expect(trivyStub.scanImagePreflight).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('enforces a supplied image list without reading compose from disk', async () => {
|
||||
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
|
||||
trivyStub.isTrivyAvailable.mockReturnValue(true);
|
||||
trivyStub.scanImagePreflight.mockResolvedValue(mkScan({
|
||||
id: 42,
|
||||
highest_severity: 'HIGH',
|
||||
high_count: 1,
|
||||
}));
|
||||
|
||||
const result = await enforcePolicyForImageRefs('blueprint-web', 1, ['nginx:1.27-alpine'], { bypass: false, actor: 'u' });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.violations).toHaveLength(1);
|
||||
expect(result.violations[0]).toMatchObject({
|
||||
imageRef: 'nginx:1.27-alpine',
|
||||
severity: 'HIGH',
|
||||
highCount: 1,
|
||||
scanId: 42,
|
||||
});
|
||||
expect(composeStub.listStackImages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed on invalid supplied image refs when requested', async () => {
|
||||
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
|
||||
trivyStub.isTrivyAvailable.mockReturnValue(true);
|
||||
|
||||
const result = await enforcePolicyForImageRefs('blueprint-web', 1, ['${IMAGE}'], { bypass: false, actor: 'u' }, undefined, true);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.violations).toHaveLength(1);
|
||||
expect(result.violations[0]).toMatchObject({
|
||||
imageRef: '${IMAGE}',
|
||||
severity: 'UNKNOWN',
|
||||
scanId: 0,
|
||||
});
|
||||
expect(trivyStub.scanImagePreflight).not.toHaveBeenCalled();
|
||||
expect(composeStub.listStackImages).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ blueprintsRouter.use(authMiddleware);
|
||||
const VALID_DRIFT_MODES: readonly DriftMode[] = ['observe', 'suggest', 'enforce'];
|
||||
const MAX_SELECTOR_ENTRIES = 200;
|
||||
const MAX_DESCRIPTION_LENGTH = 2048;
|
||||
export const MAX_BLUEPRINT_COMPOSE_BYTES = 96 * 1024;
|
||||
const BLUEPRINT_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
|
||||
|
||||
interface BlueprintBody {
|
||||
@@ -85,6 +86,18 @@ function validateDriftMode(mode: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateComposeContent(composeContent: unknown): string | null {
|
||||
if (typeof composeContent !== 'string' || composeContent.trim().length === 0) {
|
||||
return 'compose_content must be a non-empty string';
|
||||
}
|
||||
if (Buffer.byteLength(composeContent, 'utf8') > MAX_BLUEPRINT_COMPOSE_BYTES) {
|
||||
return `compose_content must be ${MAX_BLUEPRINT_COMPOSE_BYTES} bytes or fewer`;
|
||||
}
|
||||
const analysis = BlueprintAnalyzer.analyze(composeContent);
|
||||
if (analysis.parseError) return `compose_content must be valid YAML: ${analysis.parseError}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function summarizeBlueprint(blueprintId: number) {
|
||||
const db = DatabaseService.getInstance();
|
||||
const blueprint = db.getBlueprint(blueprintId);
|
||||
@@ -121,10 +134,8 @@ blueprintsRouter.post('/', (req: Request, res: Response): void => {
|
||||
const body = req.body as BlueprintBody;
|
||||
const nameError = validateName(body.name);
|
||||
if (nameError) { res.status(400).json({ error: nameError }); return; }
|
||||
if (typeof body.compose_content !== 'string' || body.compose_content.trim().length === 0) {
|
||||
res.status(400).json({ error: 'compose_content must be a non-empty string' });
|
||||
return;
|
||||
}
|
||||
const composeError = validateComposeContent(body.compose_content);
|
||||
if (composeError) { res.status(400).json({ error: composeError }); return; }
|
||||
const descError = validateDescription(body.description);
|
||||
if (descError) { res.status(400).json({ error: descError }); return; }
|
||||
const selectorResult = parseSelector(body.selector);
|
||||
@@ -132,11 +143,12 @@ blueprintsRouter.post('/', (req: Request, res: Response): void => {
|
||||
const driftModeError = validateDriftMode(body.drift_mode ?? 'suggest');
|
||||
if (driftModeError) { res.status(400).json({ error: driftModeError }); return; }
|
||||
try {
|
||||
const analysis = BlueprintAnalyzer.analyze(body.compose_content);
|
||||
const composeContent = body.compose_content as string;
|
||||
const analysis = BlueprintAnalyzer.analyze(composeContent);
|
||||
const blueprint = DatabaseService.getInstance().createBlueprint({
|
||||
name: (body.name as string).trim(),
|
||||
description: typeof body.description === 'string' ? body.description : null,
|
||||
compose_content: body.compose_content,
|
||||
compose_content: composeContent,
|
||||
selector: selectorResult.selector,
|
||||
drift_mode: (body.drift_mode as DriftMode | undefined) ?? 'suggest',
|
||||
classification: analysis.classification,
|
||||
@@ -188,12 +200,11 @@ blueprintsRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
updates.description = body.description as string | null;
|
||||
}
|
||||
if (body.compose_content !== undefined) {
|
||||
if (typeof body.compose_content !== 'string' || body.compose_content.trim().length === 0) {
|
||||
res.status(400).json({ error: 'compose_content must be a non-empty string' });
|
||||
return;
|
||||
}
|
||||
const analysis = BlueprintAnalyzer.analyze(body.compose_content);
|
||||
updates.compose_content = body.compose_content;
|
||||
const composeError = validateComposeContent(body.compose_content);
|
||||
if (composeError) { res.status(400).json({ error: composeError }); return; }
|
||||
const composeContent = body.compose_content as string;
|
||||
const analysis = BlueprintAnalyzer.analyze(composeContent);
|
||||
updates.compose_content = composeContent;
|
||||
updates.classification = analysis.classification;
|
||||
updates.classification_reasons = analysis.reasons;
|
||||
updates.bumpRevision = true;
|
||||
@@ -211,7 +222,7 @@ blueprintsRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
if (body.enabled !== undefined) {
|
||||
const next = Boolean(body.enabled);
|
||||
if (!next) {
|
||||
// Refuse to disable a blueprint with active deployments — operator must withdraw explicitly.
|
||||
// Refuse to disable a blueprint with active deployments. Operator must withdraw explicitly.
|
||||
const existing = DatabaseService.getInstance().getBlueprint(id);
|
||||
if (existing?.enabled) {
|
||||
const deployments = DatabaseService.getInstance().listDeployments(id);
|
||||
@@ -251,7 +262,7 @@ blueprintsRouter.delete('/:id', async (req: Request, res: Response): Promise<voi
|
||||
try {
|
||||
const blueprint = DatabaseService.getInstance().getBlueprint(id);
|
||||
if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; }
|
||||
// Refuse delete on stateful blueprints with active deployments — operator must withdraw explicitly first
|
||||
// Refuse delete on stateful blueprints with active deployments. Operator must withdraw explicitly first
|
||||
if (blueprint.classification === 'stateful' || blueprint.classification === 'unknown') {
|
||||
const deployments = DatabaseService.getInstance().listDeployments(id);
|
||||
const blocking = deployments.filter(d => d.status === 'active' || d.status === 'evict_blocked' || d.status === 'pending_state_review');
|
||||
@@ -489,6 +500,10 @@ blueprintsRouter.post('/analyze', (req: Request, res: Response): void => {
|
||||
res.status(400).json({ error: 'compose_content is required' });
|
||||
return;
|
||||
}
|
||||
if (Buffer.byteLength(composeContent, 'utf8') > MAX_BLUEPRINT_COMPOSE_BYTES) {
|
||||
res.status(400).json({ error: `compose_content must be ${MAX_BLUEPRINT_COMPOSE_BYTES} bytes or fewer` });
|
||||
return;
|
||||
}
|
||||
const result = BlueprintAnalyzer.analyze(composeContent);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ interface ComposeShape {
|
||||
}
|
||||
|
||||
interface ComposeService {
|
||||
image?: string | null;
|
||||
volumes?: Array<string | ComposeServiceVolume> | null;
|
||||
tmpfs?: string | string[] | null;
|
||||
}
|
||||
@@ -208,6 +209,20 @@ export class BlueprintAnalyzer {
|
||||
return false;
|
||||
}
|
||||
|
||||
static extractImageRefs(composeContent: string): string[] {
|
||||
const doc = (parseYaml(composeContent) ?? {}) as ComposeShape;
|
||||
const services = doc.services ?? {};
|
||||
const seen = new Set<string>();
|
||||
const images: string[] = [];
|
||||
for (const serviceDef of Object.values(services)) {
|
||||
const image = typeof serviceDef?.image === 'string' ? serviceDef.image.trim() : '';
|
||||
if (!image || image.startsWith('sha256:') || seen.has(image)) continue;
|
||||
seen.add(image);
|
||||
images.push(image);
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
private static extractNamedVolumes(composeContent: string): Set<string> {
|
||||
try {
|
||||
const doc = (parseYaml(composeContent) ?? {}) as ComposeShape;
|
||||
|
||||
@@ -8,10 +8,27 @@ import { BlueprintService } from './BlueprintService';
|
||||
import { BlueprintAnalyzer } from './BlueprintAnalyzer';
|
||||
import { NodeLabelService } from './NodeLabelService';
|
||||
import { NotificationService } from './NotificationService';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
const RECONCILER_INTERVAL_MS = 60_000;
|
||||
const RECONCILER_INITIAL_DELAY_MS = 5_000;
|
||||
|
||||
function isDeveloperModeEnabled(): boolean {
|
||||
try {
|
||||
return DatabaseService.getInstance().getGlobalSettings().developer_mode === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function diagnosticLog(message: string, fields: Record<string, string | number | boolean | null | undefined>): void {
|
||||
if (!isDeveloperModeEnabled()) return;
|
||||
const safeFields = Object.fromEntries(
|
||||
Object.entries(fields).map(([key, value]) => [key, typeof value === 'string' ? sanitizeForLog(value) : value]),
|
||||
);
|
||||
console.info(`[BlueprintReconciler:diag] ${message}`, safeFields);
|
||||
}
|
||||
|
||||
export interface ReconcileDecision {
|
||||
deploy: Node[];
|
||||
withdraw: Node[];
|
||||
@@ -77,17 +94,21 @@ export class BlueprintReconciler {
|
||||
const blueprint = DatabaseService.getInstance().getBlueprint(blueprintId);
|
||||
if (!blueprint || !blueprint.enabled) return;
|
||||
const nodes = DatabaseService.getInstance().getNodes();
|
||||
diagnosticLog('manual reconcile requested', { blueprintId, nodeCount: nodes.length });
|
||||
await this.reconcileBlueprint(blueprint, nodes);
|
||||
}
|
||||
|
||||
private async evaluate(): Promise<void> {
|
||||
if (this.running) return; // prevent overlap on slow ticks
|
||||
this.running = true;
|
||||
const started = Date.now();
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const blueprints = db.listEnabledBlueprints();
|
||||
if (blueprints.length === 0) return;
|
||||
const nodes = db.getNodes();
|
||||
console.info('[BlueprintReconciler] tick start blueprints=%s nodes=%s', blueprints.length, nodes.length);
|
||||
diagnosticLog('tick inputs', { blueprintCount: blueprints.length, nodeCount: nodes.length });
|
||||
for (const blueprint of blueprints) {
|
||||
try {
|
||||
await this.reconcileBlueprint(blueprint, nodes);
|
||||
@@ -95,6 +116,7 @@ export class BlueprintReconciler {
|
||||
console.error(`[BlueprintReconciler] failed for blueprint "${blueprint.name}":`, err);
|
||||
}
|
||||
}
|
||||
console.info('[BlueprintReconciler] tick complete blueprints=%s durationMs=%s', blueprints.length, Date.now() - started);
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
@@ -102,15 +124,28 @@ export class BlueprintReconciler {
|
||||
|
||||
private async reconcileBlueprint(blueprint: Blueprint, allNodes: Node[]): Promise<void> {
|
||||
const decision = this.computeDecision(blueprint, allNodes);
|
||||
diagnosticLog('decision computed', {
|
||||
blueprintId: blueprint.id,
|
||||
blueprintName: blueprint.name,
|
||||
revision: blueprint.revision,
|
||||
deploy: decision.deploy.length,
|
||||
withdraw: decision.withdraw.length,
|
||||
check: decision.check.length,
|
||||
stateReview: decision.stateReview.length,
|
||||
evictBlocked: decision.evictBlocked.length,
|
||||
});
|
||||
|
||||
// 1. State-review guard for stateful blueprints reaching new nodes.
|
||||
for (const node of decision.stateReview) {
|
||||
const existing = DatabaseService.getInstance().getDeployment(blueprint.id, node.id);
|
||||
DatabaseService.getInstance().upsertDeployment({
|
||||
blueprint_id: blueprint.id,
|
||||
node_id: node.id,
|
||||
status: 'pending_state_review',
|
||||
last_checked_at: Date.now(),
|
||||
drift_summary: 'Stateful blueprint awaiting operator confirmation before first deploy',
|
||||
drift_summary: existing
|
||||
? 'Stateful blueprint revision change awaits operator confirmation'
|
||||
: 'Stateful blueprint awaiting operator confirmation before first deploy',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -213,8 +248,11 @@ export class BlueprintReconciler {
|
||||
continue;
|
||||
}
|
||||
if (dep.applied_revision !== blueprint.revision) {
|
||||
// revision drift: re-deploy (stateful never auto-redeploys volume-destroying changes; handled in handleDrift)
|
||||
decision.deploy.push(node);
|
||||
if (blueprint.classification === 'stateful' || blueprint.classification === 'unknown') {
|
||||
decision.stateReview.push(node);
|
||||
} else {
|
||||
decision.deploy.push(node);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (dep.status === 'failed' || dep.status === 'pending') {
|
||||
|
||||
@@ -13,11 +13,31 @@ import { FileSystemService } from './FileSystemService';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './license-headers';
|
||||
import { LicenseService } from './LicenseService';
|
||||
import { enforcePolicyForImageRefs } from './PolicyEnforcement';
|
||||
import { triggerPostDeployScan } from '../helpers/policyGate';
|
||||
import { BlueprintAnalyzer } from './BlueprintAnalyzer';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
const MARKER_FILENAME = '.blueprint.json';
|
||||
const COMPOSE_FILENAME = 'docker-compose.yml';
|
||||
const REMOTE_HTTP_TIMEOUT_MS = 30_000;
|
||||
|
||||
function isDeveloperModeEnabled(): boolean {
|
||||
try {
|
||||
return DatabaseService.getInstance().getGlobalSettings().developer_mode === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function diagnosticLog(message: string, fields: Record<string, string | number | boolean | null | undefined>): void {
|
||||
if (!isDeveloperModeEnabled()) return;
|
||||
const safeFields = Object.fromEntries(
|
||||
Object.entries(fields).map(([key, value]) => [key, typeof value === 'string' ? sanitizeForLog(value) : value]),
|
||||
);
|
||||
console.info(`[BlueprintService:diag] ${message}`, safeFields);
|
||||
}
|
||||
|
||||
export interface BlueprintMarker {
|
||||
blueprintId: number;
|
||||
revision: number;
|
||||
@@ -185,18 +205,34 @@ export class BlueprintService {
|
||||
if (!this.acquireLock(blueprint.id, node.id)) {
|
||||
return { status: 'pending' };
|
||||
}
|
||||
const started = Date.now();
|
||||
console.info('[BlueprintService] deploy start blueprint=%s node=%s type=%s revision=%s',
|
||||
sanitizeForLog(blueprint.name), node.id, node.type, blueprint.revision);
|
||||
diagnosticLog('deploy inputs', {
|
||||
blueprintId: blueprint.id,
|
||||
blueprintName: blueprint.name,
|
||||
nodeId: node.id,
|
||||
nodeType: node.type,
|
||||
revision: blueprint.revision,
|
||||
classification: blueprint.classification,
|
||||
driftMode: blueprint.drift_mode,
|
||||
});
|
||||
try {
|
||||
this.setStatus(blueprint.id, node.id, 'deploying');
|
||||
if (await this.hasNameConflict(blueprint.name, node)) {
|
||||
this.setStatus(blueprint.id, node.id, 'name_conflict', {
|
||||
last_error: `A stack named "${blueprint.name}" already exists on this node and is not managed by Sencho.`,
|
||||
});
|
||||
console.warn('[BlueprintService] deploy name conflict blueprint=%s node=%s durationMs=%s',
|
||||
sanitizeForLog(blueprint.name), node.id, Date.now() - started);
|
||||
return { status: 'name_conflict', error: 'name_conflict' };
|
||||
}
|
||||
const marker = this.buildMarker(blueprint);
|
||||
if (node.type === 'local') {
|
||||
diagnosticLog('deploy branch', { blueprintId: blueprint.id, nodeId: node.id, target: 'local' });
|
||||
await this.deployLocal(blueprint, node, marker);
|
||||
} else {
|
||||
diagnosticLog('deploy branch', { blueprintId: blueprint.id, nodeId: node.id, target: 'remote' });
|
||||
await this.deployRemote(blueprint, node, marker);
|
||||
}
|
||||
this.setStatus(blueprint.id, node.id, 'active', {
|
||||
@@ -206,10 +242,14 @@ export class BlueprintService {
|
||||
drift_summary: null,
|
||||
last_error: null,
|
||||
});
|
||||
console.info('[BlueprintService] deploy complete blueprint=%s node=%s durationMs=%s',
|
||||
sanitizeForLog(blueprint.name), node.id, Date.now() - started);
|
||||
return { status: 'active' };
|
||||
} catch (err) {
|
||||
const message = BlueprintService.formatError(err);
|
||||
this.setStatus(blueprint.id, node.id, 'failed', { last_error: message });
|
||||
console.error('[BlueprintService] deploy failed blueprint=%s node=%s durationMs=%s error=%s',
|
||||
sanitizeForLog(blueprint.name), node.id, Date.now() - started, sanitizeForLog(message));
|
||||
return { status: 'failed', error: message };
|
||||
} finally {
|
||||
this.releaseLock(blueprint.id, node.id);
|
||||
@@ -225,6 +265,16 @@ export class BlueprintService {
|
||||
if (!this.acquireLock(blueprint.id, node.id)) {
|
||||
return { status: 'pending' };
|
||||
}
|
||||
const started = Date.now();
|
||||
console.info('[BlueprintService] withdraw start blueprint=%s node=%s type=%s',
|
||||
sanitizeForLog(blueprint.name), node.id, node.type);
|
||||
diagnosticLog('withdraw inputs', {
|
||||
blueprintId: blueprint.id,
|
||||
blueprintName: blueprint.name,
|
||||
nodeId: node.id,
|
||||
nodeType: node.type,
|
||||
classification: blueprint.classification,
|
||||
});
|
||||
try {
|
||||
this.setStatus(blueprint.id, node.id, 'withdrawing');
|
||||
// Refuse to withdraw a directory we do not own
|
||||
@@ -236,15 +286,21 @@ export class BlueprintService {
|
||||
return { status: 'name_conflict' };
|
||||
}
|
||||
if (node.type === 'local') {
|
||||
diagnosticLog('withdraw branch', { blueprintId: blueprint.id, nodeId: node.id, target: 'local' });
|
||||
await this.withdrawLocal(blueprint, node);
|
||||
} else {
|
||||
diagnosticLog('withdraw branch', { blueprintId: blueprint.id, nodeId: node.id, target: 'remote' });
|
||||
await this.withdrawRemote(blueprint, node);
|
||||
}
|
||||
DatabaseService.getInstance().deleteDeployment(blueprint.id, node.id);
|
||||
console.info('[BlueprintService] withdraw complete blueprint=%s node=%s durationMs=%s',
|
||||
sanitizeForLog(blueprint.name), node.id, Date.now() - started);
|
||||
return { status: 'withdrawn' };
|
||||
} catch (err) {
|
||||
const message = BlueprintService.formatError(err);
|
||||
this.setStatus(blueprint.id, node.id, 'failed', { last_error: `withdraw failed: ${message}` });
|
||||
console.error('[BlueprintService] withdraw failed blueprint=%s node=%s durationMs=%s error=%s',
|
||||
sanitizeForLog(blueprint.name), node.id, Date.now() - started, sanitizeForLog(message));
|
||||
return { status: 'failed', error: message };
|
||||
} finally {
|
||||
this.releaseLock(blueprint.id, node.id);
|
||||
@@ -336,6 +392,17 @@ export class BlueprintService {
|
||||
}
|
||||
|
||||
private async deployLocal(blueprint: Blueprint, node: Node, marker: BlueprintMarker): Promise<void> {
|
||||
const imageRefs = BlueprintAnalyzer.extractImageRefs(blueprint.compose_content);
|
||||
const gate = await enforcePolicyForImageRefs(blueprint.name, node.id, imageRefs, {
|
||||
bypass: false,
|
||||
actor: 'blueprint-reconciler',
|
||||
auditMethod: 'POST',
|
||||
auditPath: `/api/blueprints/${blueprint.id}/apply`,
|
||||
}, undefined, true);
|
||||
if (!gate.ok) {
|
||||
throw new Error(`Policy "${gate.policy?.name}" blocked deploy: ${gate.violations.length} image(s) exceed ${gate.policy?.max_severity}`);
|
||||
}
|
||||
|
||||
const fs = FileSystemService.getInstance(node.id);
|
||||
if (!(await this.stackDirExists(node, blueprint.name))) {
|
||||
await fs.createStack(blueprint.name);
|
||||
@@ -343,6 +410,10 @@ export class BlueprintService {
|
||||
await fs.writeStackFile(blueprint.name, COMPOSE_FILENAME, blueprint.compose_content);
|
||||
await fs.writeStackFile(blueprint.name, MARKER_FILENAME, JSON.stringify(marker, null, 2));
|
||||
await ComposeService.getInstance(node.id).deployStack(blueprint.name, undefined, false);
|
||||
triggerPostDeployScan(blueprint.name, node.id).catch(err => {
|
||||
console.error('[BlueprintService] post-deploy scan failed for "%s" on node %s: %s',
|
||||
sanitizeForLog(blueprint.name), node.id, sanitizeForLog(BlueprintService.formatError(err)));
|
||||
});
|
||||
}
|
||||
|
||||
private async withdrawLocal(blueprint: Blueprint, node: Node): Promise<void> {
|
||||
|
||||
@@ -50,7 +50,6 @@ export async function enforcePolicyPreDeploy(
|
||||
nodeId: number,
|
||||
opts: PolicyEnforcementOptions,
|
||||
): Promise<PolicyEnforcementResult> {
|
||||
const svc = TrivyService.getInstance();
|
||||
const db = DatabaseService.getInstance();
|
||||
const policy = db.getMatchingPolicy(nodeId, stackName, FleetSyncService.getSelfIdentity());
|
||||
|
||||
@@ -58,6 +57,7 @@ export async function enforcePolicyPreDeploy(
|
||||
return { ok: true, bypassed: false, policy: policy ?? undefined, violations: [] };
|
||||
}
|
||||
|
||||
const svc = TrivyService.getInstance();
|
||||
if (!svc.isTrivyAvailable()) {
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
'warning',
|
||||
@@ -88,9 +88,49 @@ export async function enforcePolicyPreDeploy(
|
||||
};
|
||||
}
|
||||
|
||||
return enforcePolicyForImageRefs(stackName, nodeId, imageRefs, opts, policy);
|
||||
}
|
||||
|
||||
export async function enforcePolicyForImageRefs(
|
||||
stackName: string,
|
||||
nodeId: number,
|
||||
imageRefs: string[],
|
||||
opts: PolicyEnforcementOptions,
|
||||
matchedPolicy?: ScanPolicy,
|
||||
failClosedInvalidRefs = false,
|
||||
): Promise<PolicyEnforcementResult> {
|
||||
const db = DatabaseService.getInstance();
|
||||
const policy = matchedPolicy ?? db.getMatchingPolicy(nodeId, stackName, FleetSyncService.getSelfIdentity());
|
||||
|
||||
if (!policy || !policy.enabled || !policy.block_on_deploy) {
|
||||
return { ok: true, bypassed: false, policy: policy ?? undefined, violations: [] };
|
||||
}
|
||||
|
||||
const svc = TrivyService.getInstance();
|
||||
if (!svc.isTrivyAvailable()) {
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
'warning',
|
||||
'scan_finding',
|
||||
`Pre-deploy scan for "${stackName}" skipped: Trivy not installed on this node`,
|
||||
{ stackName },
|
||||
);
|
||||
return { ok: true, bypassed: false, policy, violations: [], trivyMissing: true };
|
||||
}
|
||||
|
||||
const violations: PolicyViolation[] = [];
|
||||
for (const imageRef of imageRefs) {
|
||||
if (!validateImageRef(imageRef)) continue;
|
||||
if (!validateImageRef(imageRef)) {
|
||||
if (failClosedInvalidRefs) {
|
||||
violations.push({
|
||||
imageRef,
|
||||
severity: 'UNKNOWN',
|
||||
criticalCount: 0,
|
||||
highCount: 0,
|
||||
scanId: 0,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const scan = await svc.scanImagePreflight(imageRef, nodeId, stackName);
|
||||
const severity = scan.highest_severity ?? 'UNKNOWN';
|
||||
|
||||
@@ -11,13 +11,17 @@ Blueprints live under **Fleet → Deployments**.
|
||||
Blueprints are a Skipper feature. Configuring blueprints requires an admin role; viewers see the catalog read-only.
|
||||
</Note>
|
||||
|
||||
<Frame>
|
||||
<img src="/images/blueprint-model/catalog.png" alt="Blueprint catalog in Fleet Deployments showing blueprint cards, deployment counts, drift policy, and the New Blueprint action" />
|
||||
</Frame>
|
||||
|
||||
## What problem this solves
|
||||
|
||||
Without Blueprints, running the same stack on multiple nodes means SSHing or clicking through each node's stack manager and keeping them in sync by hand. When something drifts (someone restarts a container, edits a compose, or a node forgets to pull a new image) you find out when it breaks.
|
||||
|
||||
With Blueprints you get:
|
||||
|
||||
- **One declaration covers many nodes.** Pick nodes by label (`production`) or by ID. The set is recomputed every reconciliation tick — adding a node with the right label deploys the stack automatically.
|
||||
- **One declaration covers many nodes.** Pick nodes by label (`production`) or by ID. The set is recomputed every reconciliation tick, and adding a node with the right label deploys the stack automatically.
|
||||
- **Drift detection always on.** Every tick, Sencho compares each target node's actual state to the desired one. You choose what happens when drift is found.
|
||||
- **Safety rails for stateful workloads.** Blueprints are classified as stateless or stateful at author time; stateful blueprints get explicit confirmation prompts before first deploy and before eviction.
|
||||
|
||||
@@ -27,12 +31,18 @@ With Blueprints you get:
|
||||
|---|---|
|
||||
| **Name** | Used as the stack directory on every targeted node (`<COMPOSE_DIR>/<blueprint-name>/`). Lowercase letters, digits, hyphens, and underscores. |
|
||||
| **Description** | Short prose for the catalog tile and detail header. |
|
||||
| **Compose** | Standard `docker-compose.yml`. The same file ships to every targeted node. |
|
||||
| **Compose** | Standard `docker-compose.yml`. The same file ships to every targeted node. The YAML must parse successfully and stay under 96 KiB. |
|
||||
| **Selector** | Either `labels` (any/all expressions) or a list of node IDs. |
|
||||
| **Drift policy** | Observe, Suggest, or Enforce. See below. |
|
||||
| **Reconciler enabled** | Toggle the reconciliation loop without deleting the blueprint. |
|
||||
|
||||
Sencho writes a `.blueprint.json` marker into each targeted node's stack directory. The marker carries the blueprint ID, revision, and the timestamp of the last apply. The reconciler refuses to touch any directory that does not carry a matching marker — so a Blueprint named `nginx` will never overwrite an existing user-authored `nginx` stack on any node.
|
||||
Sencho writes a `.blueprint.json` marker into each targeted node's stack directory. The marker carries the blueprint ID, revision, and the timestamp of the last apply. The reconciler refuses to touch any directory that does not carry a matching marker, so a Blueprint named `nginx` will never overwrite an existing user-authored `nginx` stack on any node.
|
||||
|
||||
Before a local Blueprint deploy starts, Sencho applies the same pre-deploy vulnerability policy gate used by standard stack deploys. If an enabled policy blocks one of the Blueprint's image references, the deployment row moves to failed and the stack is not written to disk. Successful local Blueprint deploys also trigger the normal post-deploy scan. Remote Blueprint deploys are routed through the remote node's stack deploy endpoint, so policy enforcement runs on the remote instance with that node's credentials and scanner state.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/blueprint-model/editor-dialog.png" alt="New Blueprint editor dialog with name, compose YAML, selector, drift policy, and classification banner" />
|
||||
</Frame>
|
||||
|
||||
## Selectors
|
||||
|
||||
@@ -43,11 +53,11 @@ all = [docker]
|
||||
any = [production, staging]
|
||||
```
|
||||
|
||||
This resolves to nodes that have *every* label in `all` AND *at least one* label in `any`. Either side may be empty. An entirely empty labels selector matches nothing — choose at least one label.
|
||||
This resolves to nodes that have *every* label in `all` AND *at least one* label in `any`. Either side may be empty. An entirely empty labels selector matches nothing, so choose at least one label.
|
||||
|
||||
A **nodes** selector picks specific node IDs by hand. Useful when you want a one-off blueprint that runs only on a known node.
|
||||
|
||||
Add labels to nodes from **Settings → Nodes** — each node row has a Labels column with a `+` button.
|
||||
Add labels to nodes from **Settings → Nodes**. Each node row has a Labels column with a `+` button.
|
||||
|
||||
## Drift policy
|
||||
|
||||
@@ -59,7 +69,7 @@ Drift detection runs every minute regardless of the policy. Only the response di
|
||||
| **Suggest** (default) | Sencho dispatches a `blueprint_drift_detected` notification through your notification routes, if any. |
|
||||
| **Enforce** | Sencho re-deploys the blueprint silently when drift is detected. A notification fires only when an auto-fix attempt fails. |
|
||||
|
||||
Even **Observe** keeps Sencho honest about what it found — the deployment row shows "drifted 3h ago: service caddy exited code 1". Silence would forfeit Sencho's authority over your fleet.
|
||||
Even **Observe** keeps Sencho honest about what it found. The deployment row shows "drifted 3h ago: service caddy exited code 1". Silence would forfeit Sencho's authority over your fleet.
|
||||
|
||||
For **stateful** blueprints under Enforce, Sencho declines auto-fixes that would destroy named volumes (for example, when you rename a volume in the compose). The drift downgrades to Suggest semantics for that event with the reason `auto-fix declined: would destroy volume data`.
|
||||
|
||||
@@ -67,9 +77,9 @@ For **stateful** blueprints under Enforce, Sencho declines auto-fixes that would
|
||||
|
||||
Sencho classifies your compose at author time:
|
||||
|
||||
- **Stateless** — no persistent volumes detected, or only `tmpfs`. Sencho can deploy and evict freely.
|
||||
- **Stateful** — named volumes or bind mounts detected. Each node holds its own data; Sencho does not replicate volumes between nodes.
|
||||
- **State unknown** — `external: true` volumes detected. Sencho cannot prove portability and treats the blueprint as stateful for safety.
|
||||
- **Stateless**: no persistent volumes detected, or only `tmpfs`. Sencho can deploy and evict freely.
|
||||
- **Stateful**: named volumes or bind mounts detected. Each node holds its own data; Sencho does not replicate volumes between nodes.
|
||||
- **State unknown**: `external: true` volumes detected. Sencho cannot prove portability and treats the blueprint as stateful for safety.
|
||||
|
||||
The classification appears as a chip on the catalog tile and as a banner above the YAML editor. Click the banner to see exactly what made Sencho classify the way it did.
|
||||
|
||||
@@ -78,11 +88,16 @@ The classification appears as a chip on the catalog tile and as a banner above t
|
||||
| Trigger | What Sencho does |
|
||||
|---|---|
|
||||
| Selector matches a node that has never run this blueprint | Deployment enters `pending_state_review`. The reconciler refuses to deploy until you click **Confirm deploy** in the deployment table. |
|
||||
| A stateful or unknown blueprint revision changes on a node that already runs it | Deployment enters `pending_state_review`. Confirm the redeploy before Sencho writes the new compose revision. |
|
||||
| A node leaves the selector while a deployment is active | Deployment enters `evict_blocked`. The reconciler refuses to evict until you choose **Snapshot, then evict** or **Evict and destroy data**. |
|
||||
| You target more than one node | The editor warns: "Each node will hold its own data — Sencho does not replicate volumes between nodes." |
|
||||
| You target more than one node | The editor warns: "Each node will hold its own data. Sencho does not replicate volumes between nodes." |
|
||||
|
||||
Stateless blueprints flow through these states automatically.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/blueprint-model/detail-state-review.png" alt="Blueprint detail sheet with a deployment row waiting for state review confirmation" />
|
||||
</Frame>
|
||||
|
||||
## Working with Blueprints
|
||||
|
||||
### Create
|
||||
@@ -90,9 +105,11 @@ Stateless blueprints flow through these states automatically.
|
||||
1. Go to **Fleet → Deployments**.
|
||||
2. Click **New Blueprint**.
|
||||
3. Fill in the name, description, compose YAML, selector, and drift policy.
|
||||
4. Watch the classification banner update as you type — it tells you whether the blueprint is portable or pinned.
|
||||
4. Watch the classification banner update as you type. It tells you whether the blueprint is portable or pinned.
|
||||
5. Click **Create blueprint**. Sencho immediately runs one reconciliation tick.
|
||||
|
||||
If the YAML is malformed or larger than 96 KiB, Sencho rejects the save before creating a Blueprint row.
|
||||
|
||||
### Apply on demand
|
||||
|
||||
The reconciler runs every minute. To trigger it now (for example, after editing the selector or compose), click **Apply now** on the detail sheet.
|
||||
@@ -101,6 +118,12 @@ The reconciler runs every minute. To trigger it now (for example, after editing
|
||||
|
||||
Click **Edit** on the detail sheet. Editing the compose bumps the revision; the reconciler will redeploy on every targeted node on the next tick. Stateful blueprints follow the volume-destroying drift rule under Enforce.
|
||||
|
||||
For stateful or state-unknown Blueprints, a compose edit does not redeploy automatically. Each existing deployment enters **Awaiting confirmation** so you can decide whether the new revision is safe for that node's local data.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/blueprint-model/detail-sheet.png" alt="Blueprint detail sheet showing deployment rows, Apply now, Edit, Delete, and the current compose content" />
|
||||
</Frame>
|
||||
|
||||
### Withdraw a single deployment
|
||||
|
||||
In the deployment table, click **Withdraw** on the node's row. For stateless blueprints, Sencho runs `docker compose down` and removes the directory. For stateful blueprints, you choose between **Snapshot, then evict** (records the compose definition to Fleet → Snapshots, then evicts) and **Evict and destroy data** (typed-confirm, destroys named volumes).
|
||||
@@ -111,7 +134,7 @@ In the deployment table, click **Withdraw** on the node's row. For stateless blu
|
||||
|
||||
### Delete the blueprint
|
||||
|
||||
Stateless blueprints withdraw all deployments and then delete. Stateful blueprints with active deployments refuse to delete — withdraw each deployment explicitly first.
|
||||
Stateless blueprints withdraw all deployments and then delete. Stateful blueprints with active deployments refuse to delete. Withdraw each deployment explicitly first.
|
||||
|
||||
## Migrating stateful data between nodes (manual)
|
||||
|
||||
@@ -132,11 +155,27 @@ A directory by the blueprint's name already exists on that node and does not car
|
||||
|
||||
### Stateful blueprint stuck in "Awaiting confirmation"
|
||||
|
||||
Click **Confirm deploy** on the row, then choose **Deploy fresh**. Sencho will create empty named volumes and start the stack.
|
||||
Click **Confirm deploy** on the row, then choose **Deploy fresh**. Sencho will create empty named volumes and start the stack. If the row appeared after a compose edit, review the revision first; confirming writes the new compose file and deploys it on that node.
|
||||
|
||||
### Deploy blocked by vulnerability policy
|
||||
|
||||
Open **Settings → Security** and review the active scan policies for the target node. The Blueprint deployment row records the blocking policy and affected image count. Either fix the image, relax the policy, or deploy through an explicit admin-approved bypass on the stack surface. Blueprints do not silently bypass deploy enforcement.
|
||||
|
||||
### Compose content rejected
|
||||
|
||||
Sencho accepts valid YAML up to 96 KiB. Split very large compose files into smaller stacks or move generated content out of the Blueprint. Do not paste secrets into the compose body; use environment files or fleet secrets where appropriate.
|
||||
|
||||
### Remote node disconnected during apply
|
||||
|
||||
The row moves to failed with the remote error. Reconnect the node, verify whether the stack directory contains `docker-compose.yml` and `.blueprint.json`, then click **Apply now**. If the directory exists without a matching marker, Sencho treats it as a name conflict until you rename or remove the remote stack manually.
|
||||
|
||||
### Docker daemon or registry failure during apply
|
||||
|
||||
The deployment row moves to failed and records the Docker or registry error. Resolve the daemon, socket, registry credentials, rate limit, disk, or volume-permission issue on the affected node, then click **Apply now**. Watch that node's stack activity and security scan status after retry.
|
||||
|
||||
### Drift never gets corrected
|
||||
|
||||
Confirm the drift policy is `enforce` and the blueprint is enabled. Open the detail sheet to see the deployment row's status and the most recent drift summary. If the drift was caused by a compose change that would destroy named volumes, Enforce intentionally downgrades — change the compose to one that preserves volumes, or withdraw and re-deploy with explicit operator confirmation.
|
||||
Confirm the drift policy is `enforce` and the blueprint is enabled. Open the detail sheet to see the deployment row's status and the most recent drift summary. If the drift was caused by a compose change that would destroy named volumes, Enforce intentionally downgrades. Change the compose to one that preserves volumes, or withdraw and re-deploy with explicit operator confirmation.
|
||||
|
||||
### Cannot disable a blueprint
|
||||
|
||||
@@ -161,3 +200,18 @@ By design, Blueprints do not include:
|
||||
- Versioning history with one-click rollback (re-paste the prior compose to revert)
|
||||
|
||||
These omissions keep Blueprints honest: a compose-native fleet primitive that distributes the file you already have to the nodes you choose.
|
||||
|
||||
## Rollback and watch plan
|
||||
|
||||
To roll back a Blueprint hardening change, revert the application build, restart the Sencho backend and frontend, and re-paste the prior compose revision into any affected Blueprint. No database migration rollback is required for the validation and reconciliation guards described here.
|
||||
|
||||
For the first 24 to 48 hours after deployment, watch:
|
||||
|
||||
- Backend logs tagged `[BlueprintReconciler]`, `[BlueprintService]`, and `[BlueprintService:diag]` when Developer Mode is on.
|
||||
- Deployment-row counts by node: `failed`, `pending_state_review`, `name_conflict`, and `drifted`.
|
||||
- Vulnerability policy blocks and post-deploy scan failures on each node.
|
||||
- Docker daemon errors, registry timeouts, image pull failures, volume permission errors, and out-of-disk errors on the affected node.
|
||||
|
||||
Rollback if failed Blueprint deploys exceed 5 percent of apply attempts for 30 minutes, if a single node repeatedly fails all Blueprint deploys after Docker recovers, or if stateful deployments leave `pending_state_review` without an operator action path.
|
||||
|
||||
In a fleet, roll back the controlling instance first so it stops issuing new Blueprint actions. Then roll back remote nodes. During version skew, older remote nodes can still receive stack deploy requests, but they may lack matching validation or diagnostic logs. Inspect per-node logs rather than relying only on aggregate fleet counts.
|
||||
|
||||
@@ -11,6 +11,7 @@ interface BlueprintCatalogProps {
|
||||
blueprints: BlueprintListItem[];
|
||||
onSelect: (id: number) => void;
|
||||
onCreate: () => void;
|
||||
canCreate: boolean;
|
||||
}
|
||||
|
||||
type ModeFilter = 'all' | 'observe' | 'suggest' | 'enforce' | 'drifted';
|
||||
@@ -41,7 +42,7 @@ function statusDot(status: BlueprintDeploymentStatus | null): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function BlueprintCatalog({ blueprints, onSelect, onCreate }: BlueprintCatalogProps) {
|
||||
export function BlueprintCatalog({ blueprints, onSelect, onCreate, canCreate }: BlueprintCatalogProps) {
|
||||
const [filter, setFilter] = useState<ModeFilter>('all');
|
||||
|
||||
const counts = useMemo(() => {
|
||||
@@ -69,10 +70,12 @@ export function BlueprintCatalog({ blueprints, onSelect, onCreate }: BlueprintCa
|
||||
Deployments · Blueprints
|
||||
</span>
|
||||
</div>
|
||||
<Button size="sm" onClick={onCreate} className="gap-2">
|
||||
<Plus className="h-4 w-4" strokeWidth={1.5} />
|
||||
New Blueprint
|
||||
</Button>
|
||||
{canCreate && (
|
||||
<Button size="sm" onClick={onCreate} className="gap-2">
|
||||
<Plus className="h-4 w-4" strokeWidth={1.5} />
|
||||
New Blueprint
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
|
||||
@@ -3,9 +3,10 @@ import { Button } from '@/components/ui/button';
|
||||
|
||||
interface BlueprintEmptyStateProps {
|
||||
onCreate: () => void;
|
||||
canCreate: boolean;
|
||||
}
|
||||
|
||||
export function BlueprintEmptyState({ onCreate }: BlueprintEmptyStateProps) {
|
||||
export function BlueprintEmptyState({ onCreate, canCreate }: BlueprintEmptyStateProps) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl rounded-xl border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
|
||||
<div className="flex flex-col gap-5 p-8">
|
||||
@@ -30,10 +31,12 @@ export function BlueprintEmptyState({ onCreate }: BlueprintEmptyStateProps) {
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-icon">
|
||||
First blueprint, no fleet required
|
||||
</span>
|
||||
<Button size="sm" onClick={onCreate} className="gap-2">
|
||||
<Sparkles className="h-4 w-4" strokeWidth={1.5} />
|
||||
Create your first Blueprint
|
||||
</Button>
|
||||
{canCreate && (
|
||||
<Button size="sm" onClick={onCreate} className="gap-2">
|
||||
<Sparkles className="h-4 w-4" strokeWidth={1.5} />
|
||||
Create your first Blueprint
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,12 +95,13 @@ export function DeploymentsTab() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{blueprints.length === 0 ? (
|
||||
<BlueprintEmptyState onCreate={() => setCreateOpen(true)} />
|
||||
<BlueprintEmptyState onCreate={() => setCreateOpen(true)} canCreate={canEdit} />
|
||||
) : (
|
||||
<BlueprintCatalog
|
||||
blueprints={blueprints}
|
||||
onSelect={setSelectedId}
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
canCreate={canEdit}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user