This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
# Base image digest ledger
|
||||
|
||||
Every external base image referenced by `deploy/*.Dockerfile` must be pinned
|
||||
by an immutable `@sha256:` digest before a production release
|
||||
(`docs/operations/DEPLOYMENT_UNRAID.md` §6 "immutable release/image digests
|
||||
recorded"; `docs/architecture/SECURITY_THREAT_MODEL.md` §5 "immutable image
|
||||
digest in production record").
|
||||
|
||||
The digests below were resolved with `docker buildx imagetools inspect` on
|
||||
2026-08-10. Do not hand-type a digest into this table or a Dockerfile without
|
||||
resolving it against the real registry first.
|
||||
|
||||
## Status
|
||||
|
||||
| Image | Used in | Status |
|
||||
|---|---|---|
|
||||
| `golang:1.26.6-alpine` | `agent.Dockerfile:1`, `api.Dockerfile:1`, `migrate.Dockerfile:1`, `worker.Dockerfile:1`, `smoke-fixture.Dockerfile:1` (build stage), `postgres.Dockerfile:4` (`gosu-builder` stage) | DONE — pinned `@sha256:3889b425f035be855a72fb4755265311293b6d414521f0a519d819df32222d83` |
|
||||
| `alpine:3.22` | `agent.Dockerfile:8`, `api.Dockerfile:8`, `migrate.Dockerfile:8`, `worker.Dockerfile:8` (runtime stage) | DONE — pinned `@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce` |
|
||||
| `node:24-bookworm-slim` | `web.Dockerfile:4` (build stage) | DONE — pinned `@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03` |
|
||||
| `nginx:1.29-alpine` | `web.Dockerfile:10` (runtime stage) | DONE — pinned `@sha256:5616878291a2eed594aee8db4dade5878cf7edcb475e59193904b198d9b830de` |
|
||||
| `postgres:17-alpine` | `postgres.Dockerfile:26` (runtime stage) | DONE — pinned `@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193` |
|
||||
|
||||
## Resolving a digest
|
||||
|
||||
Requires network access and Docker. From a trusted machine (not
|
||||
necessarily this repo's build host):
|
||||
|
||||
```sh
|
||||
# Option A: buildx imagetools (works without pulling the image locally)
|
||||
docker buildx imagetools inspect golang:1.26.6-alpine
|
||||
# Read the top-level "Digest:" line for the manifest list, or the
|
||||
# platform-specific line under the linux/amd64 entry if this project only
|
||||
# ever builds for one architecture (check TARGETARCH usage in the
|
||||
# Dockerfile before choosing).
|
||||
|
||||
# Option B: pull + inspect
|
||||
docker pull golang:1.26.6-alpine
|
||||
docker inspect --format='{{index .RepoDigests 0}}' golang:1.26.6-alpine
|
||||
```
|
||||
|
||||
Repeat for every external registry image. Docker's built-in `scratch` rootfs is
|
||||
not a registry image and therefore has no manifest digest to pin.
|
||||
|
||||
## Applying a resolved digest
|
||||
|
||||
1. Edit the Dockerfile's `FROM` line to `FROM <image>:<tag>@sha256:<digest>`
|
||||
(keep the tag alongside the digest for human readability — the digest is
|
||||
what actually pins the build).
|
||||
2. Update the row above from `TODO: unresolved` to `DONE — pinned
|
||||
@sha256:<digest>` plus the resolution date.
|
||||
3. Run `sh deploy/verify-image-digests.sh` and confirm it reports `OK`.
|
||||
4. Record the resolved digest set in the deployment evidence per
|
||||
`docs/operations/DEPLOYMENT_UNRAID.md` §8 step 16 ("record
|
||||
digests/migration/config checksum").
|
||||
|
||||
## Enforcement
|
||||
|
||||
`deploy/verify-image-digests.sh` scans every `deploy/*.Dockerfile`, skips
|
||||
internal multi-stage references (`FROM <earlier-stage-name>`) and the built-in
|
||||
empty `scratch` rootfs, and fails (non-zero exit) if any external registry
|
||||
`FROM` line lacks `@sha256:`. All current external images are pinned and the
|
||||
gate passes. Run it as
|
||||
part of `docs/operations/DEPLOYMENT_UNRAID.md` §8 step 1 ("validate clean
|
||||
build and images") before any production build, and wire it into whichever
|
||||
CI/Makefile target performs that step (`make build`, `make compose-up`) as a
|
||||
blocking pre-flight — this repo's `Makefile`/CI config is outside `deploy/`
|
||||
so it is not modified by this change; that wiring is a follow-up.
|
||||
@@ -0,0 +1,21 @@
|
||||
FROM golang:1.26.6-alpine@sha256:3889b425f035be855a72fb4755265311293b6d414521f0a519d819df32222d83 AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum go.work go.work.sum ./
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/pulse-agent ./cmd/agent
|
||||
|
||||
FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
|
||||
RUN addgroup -S -g 65532 pulse && adduser -S -D -H -u 65532 -G pulse pulse
|
||||
COPY --from=build /out/pulse-agent /usr/local/bin/pulse-agent
|
||||
COPY deploy/pulse-entrypoint.sh /usr/local/bin/pulse-entrypoint.sh
|
||||
COPY deploy/healthcheck-heartbeat.sh /usr/local/bin/pulse-healthcheck.sh
|
||||
RUN chmod 0755 /usr/local/bin/pulse-entrypoint.sh /usr/local/bin/pulse-healthcheck.sh
|
||||
# Mount points for the read-only host procfs/sysfs bind mounts declared in
|
||||
# deploy/compose.yaml. They are created in the image so the destinations exist under
|
||||
# read_only: true and so the image documents the only host access the agent has.
|
||||
RUN mkdir -p /host/proc /host/sys && chmod 0555 /host /host/proc /host/sys
|
||||
USER 65532:65532
|
||||
# Heartbeat contract: docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 CMD ["/usr/local/bin/pulse-healthcheck.sh"]
|
||||
ENTRYPOINT ["/usr/local/bin/pulse-entrypoint.sh", "/usr/local/bin/pulse-agent"]
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM golang:1.26.6-alpine@sha256:3889b425f035be855a72fb4755265311293b6d414521f0a519d819df32222d83 AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum go.work go.work.sum ./
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
ARG PULSE_BUILD_VERSION=development
|
||||
ARG PULSE_BUILD_COMMIT=unknown
|
||||
ARG PULSE_BUILD_TIME=unknown
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X github.com/itworx/pulse/internal/buildinfo.Version=${PULSE_BUILD_VERSION} -X github.com/itworx/pulse/internal/buildinfo.Commit=${PULSE_BUILD_COMMIT} -X github.com/itworx/pulse/internal/buildinfo.BuildTime=${PULSE_BUILD_TIME}" -o /out/pulse-api ./cmd/api
|
||||
|
||||
FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
|
||||
RUN addgroup -S -g 65532 pulse && adduser -S -D -H -u 65532 -G pulse pulse && apk add --no-cache ca-certificates tzdata
|
||||
COPY --from=build /out/pulse-api /usr/local/bin/pulse-api
|
||||
USER 65532:65532
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD ["sh", "-c", "wget -qO- http://127.0.0.1:8081/healthz >/dev/null"]
|
||||
EXPOSE 8081
|
||||
ENTRYPOINT ["/usr/local/bin/pulse-api"]
|
||||
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
pulse-api:
|
||||
environment:
|
||||
PULSE_ENV: development
|
||||
PULSE_AUTH_MODE: mock
|
||||
ports:
|
||||
- "${PULSE_DEV_API_PORT:-18081}:8081"
|
||||
pulse-worker:
|
||||
environment:
|
||||
PULSE_ENV: development
|
||||
pulse-agent:
|
||||
environment:
|
||||
PULSE_ENV: development
|
||||
pulse-postgres:
|
||||
ports:
|
||||
- "${PULSE_DEV_DB_PORT:-55432}:5432"
|
||||
pulse-web:
|
||||
ports:
|
||||
- "${PULSE_DEV_WEB_PORT:-18080}:8080"
|
||||
@@ -0,0 +1,65 @@
|
||||
# Production overlay for the ITWorx Pulse deployment on the Unraid host.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f deploy/compose.yaml -f deploy/compose.prod.yaml up -d
|
||||
#
|
||||
# The base compose file deliberately publishes no host port: ADR-0010 requires a
|
||||
# controlled proxy edge and forbids guessing a host port. The port is no longer a
|
||||
# guess — 1238 was chosen by the operator for this host (ADR-0011) — so it is
|
||||
# recorded here, in a separate overlay, rather than being baked into the base file
|
||||
# that other environments share.
|
||||
#
|
||||
# Only pulse-web is published. pulse-api stays on the internal-only network and is
|
||||
# reached exclusively through nginx inside pulse-web, so the API cannot be addressed
|
||||
# directly from the LAN even by accident.
|
||||
#
|
||||
# The bind address is configurable and defaults to all interfaces, because Nginx
|
||||
# Proxy Manager runs in its own container and cannot reach a 127.0.0.1 binding on the
|
||||
# host. If NPM is configured to reach Pulse over a shared Docker network instead, set
|
||||
# PULSE_PUBLISH_ADDRESS=127.0.0.1 to remove the LAN exposure entirely.
|
||||
services:
|
||||
pulse-web:
|
||||
ports:
|
||||
- "${PULSE_PUBLISH_ADDRESS:-0.0.0.0}:${PULSE_HOST_PORT:-1238}:8080"
|
||||
|
||||
pulse-api:
|
||||
environment:
|
||||
PULSE_ENV: production
|
||||
# Must match the externally reachable URL that terminates TLS, not the
|
||||
# host:port published above, because the OIDC redirect and every absolute
|
||||
# link the API emits have to be valid from the browser's point of view.
|
||||
PULSE_PUBLIC_URL: ${PULSE_PUBLIC_URL:?PULSE_PUBLIC_URL must be set to the externally reachable HTTPS URL}
|
||||
# Fail Compose resolution before touching the running stack if no
|
||||
# identity claim can be mapped onto a bounded Pulse role.
|
||||
PULSE_OIDC_ROLE_MAPPING: ${PULSE_OIDC_ROLE_MAPPING:?PULSE_OIDC_ROLE_MAPPING must contain at least one claim=role entry}
|
||||
PULSE_OIDC_GROUPS_CLAIM: ${PULSE_OIDC_GROUPS_CLAIM:-groups}
|
||||
# The host path is interpolated into the bind mount below; inside the
|
||||
# container the manager always receives this fixed, non-secret path.
|
||||
PULSE_BACKUP_DIR: /var/lib/pulse/backups
|
||||
PULSE_BACKUP_RETENTION: ${PULSE_BACKUP_RETENTION:-5}
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${PULSE_BACKUP_DIR:?PULSE_BACKUP_DIR must be an operator-owned host directory outside the database volume}
|
||||
target: /var/lib/pulse/backups
|
||||
|
||||
pulse-worker:
|
||||
environment:
|
||||
PULSE_ENV: production
|
||||
|
||||
pulse-agent:
|
||||
environment:
|
||||
PULSE_ENV: production
|
||||
# The kernel reports the container's own name through the UTS namespace, so
|
||||
# the real host name has to be supplied explicitly.
|
||||
PULSE_AGENT_HOST_NAME: ${PULSE_AGENT_HOST_NAME:?PULSE_AGENT_HOST_NAME must be set to the Unraid host name}
|
||||
PULSE_UNRAID_CA_FILE: /run/pulse/unraid-ca.pem
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${PULSE_UNRAID_CA_FILE_HOST:?PULSE_UNRAID_CA_FILE_HOST must point to the public Unraid TLS certificate}
|
||||
target: /run/pulse/unraid-ca.pem
|
||||
read_only: true
|
||||
# Resolve the certificate hostname from PULSE_UNRAID_URL to the explicitly
|
||||
# discovered reachable Unraid host address. This avoids host networking and
|
||||
# keeps normal certificate hostname verification active.
|
||||
extra_hosts:
|
||||
- "${PULSE_UNRAID_HOST_NAME:?PULSE_UNRAID_HOST_NAME must match the hostname in PULSE_UNRAID_URL}:${PULSE_UNRAID_HOST_GATEWAY:?PULSE_UNRAID_HOST_GATEWAY must be the reachable address of this Unraid host}"
|
||||
@@ -0,0 +1,10 @@
|
||||
# Optional server-side acceptance overlay. It keeps every Pulse service inside
|
||||
# the uniquely named smoke project while pointing only the bounded read-only
|
||||
# query clients at an operator-supplied Prometheus-compatible source.
|
||||
services:
|
||||
pulse-api:
|
||||
environment:
|
||||
PULSE_PROMETHEUS_URL: ${PULSE_REAL_PROMETHEUS_URL:?PULSE_REAL_PROMETHEUS_URL must be set}
|
||||
pulse-worker:
|
||||
environment:
|
||||
PULSE_PROMETHEUS_URL: ${PULSE_REAL_PROMETHEUS_URL:?PULSE_REAL_PROMETHEUS_URL must be set}
|
||||
@@ -0,0 +1,22 @@
|
||||
# Server-side integration-smoke overlay.
|
||||
#
|
||||
# Unlike compose.dev.yaml this publishes only the web edge. PostgreSQL and the
|
||||
# API remain private to the uniquely named smoke project, which makes this
|
||||
# suitable for validation on the shared Unraid host without widening either
|
||||
# service's exposure.
|
||||
services:
|
||||
pulse-api:
|
||||
environment:
|
||||
PULSE_BACKUP_DIR: /tmp/pulse-backups
|
||||
PULSE_BACKUP_RETENTION: 3
|
||||
|
||||
pulse-agent:
|
||||
# The smoke agent collects only the bounded host/process capabilities and
|
||||
# has no Unraid endpoint. Avoid allocating a third server-side bridge on
|
||||
# shared hosts whose configured Docker address pools are already fully
|
||||
# subnetted.
|
||||
networks: !override [pulse-internal]
|
||||
|
||||
pulse-web:
|
||||
ports:
|
||||
- "${PULSE_SMOKE_PUBLISH_ADDRESS:-0.0.0.0}:${PULSE_SMOKE_WEB_PORT:?PULSE_SMOKE_WEB_PORT must be set}:8080"
|
||||
@@ -0,0 +1,42 @@
|
||||
services:
|
||||
pulse-smoke-fixture:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/smoke-fixture.Dockerfile
|
||||
environment:
|
||||
PULSE_SMOKE_WEBHOOK_TOKEN: ${PULSE_SMOKE_WEBHOOK_TOKEN:?PULSE_SMOKE_WEBHOOK_TOKEN must be set}
|
||||
networks: [pulse-internal]
|
||||
read_only: true
|
||||
tmpfs: ["/tmp"]
|
||||
security_opt: [no-new-privileges:true]
|
||||
cap_drop: [ALL]
|
||||
pids_limit: 64
|
||||
cpus: "0.25"
|
||||
mem_limit: 64m
|
||||
memswap_limit: 64m
|
||||
|
||||
pulse-api:
|
||||
environment:
|
||||
PULSE_ENV: development
|
||||
PULSE_AUTH_MODE: mock
|
||||
PULSE_PROMETHEUS_URL: http://pulse-smoke-fixture:9090
|
||||
depends_on:
|
||||
pulse-smoke-fixture: {condition: service_healthy}
|
||||
|
||||
pulse-worker:
|
||||
environment:
|
||||
PULSE_ENV: development
|
||||
PULSE_CONTAINER_SOURCE_ID: b1011111-1111-4111-8111-111111111111
|
||||
PULSE_PROMETHEUS_URL: http://pulse-smoke-fixture:9090
|
||||
PULSE_NOTIFICATION_WEBHOOK_URL: http://pulse-smoke-fixture:9090/webhook
|
||||
PULSE_NOTIFICATION_WEBHOOK_TOKEN: ${PULSE_SMOKE_WEBHOOK_TOKEN:?PULSE_SMOKE_WEBHOOK_TOKEN must be set}
|
||||
PULSE_NOTIFICATION_WEBHOOK_TIMEOUT: 3s
|
||||
depends_on:
|
||||
pulse-smoke-fixture: {condition: service_healthy}
|
||||
|
||||
pulse-agent:
|
||||
environment:
|
||||
PULSE_ENV: development
|
||||
PULSE_AGENT_ID: pulse-smoke-agent
|
||||
PULSE_AGENT_HOST_NAME: smoke-host
|
||||
PULSE_AGENT_COLLECT_INTERVAL: 2s
|
||||
@@ -0,0 +1,306 @@
|
||||
name: itworx-pulse
|
||||
|
||||
# Unraid DockerMan reads these labels directly for Compose-managed containers.
|
||||
# FolderView3 provides the single application group; every contained service
|
||||
# keeps the same recognizable icon and opens the public Pulse UI when selected.
|
||||
x-unraid-labels: &unraid-labels
|
||||
net.unraid.docker.icon: ${PULSE_UNRAID_ICON_URL:-}
|
||||
net.unraid.docker.webui: ${PULSE_UNRAID_WEBUI_URL:-}
|
||||
net.unraid.docker.managed: composeman
|
||||
net.unraid.docker.shell: sh
|
||||
|
||||
# Resource limits (finding: "no CPU/memory limits on any service")
|
||||
# ---------------------------------------------------------------
|
||||
# Deployment method is plain `docker compose -f deploy/compose.yaml ... up -d`
|
||||
# (see docs/operations/DEVELOPMENT_SETUP.md and DEPLOYMENT_UNRAID.md); this is
|
||||
# NOT Docker Swarm and there is no `docker stack deploy` anywhere in the repo.
|
||||
# `deploy.resources.reservations` is a swarm-only field and is silently
|
||||
# ignored by `docker compose up`; `deploy.resources.limits` support under
|
||||
# plain `up` varies by Compose CLI version and is therefore not a reliable
|
||||
# enforcement point on an operator's host we do not control. This file
|
||||
# already uses the non-swarm legacy resource keys (`pids_limit`), so the
|
||||
# same family (`cpus`, `mem_limit`, `mem_reservation`, `memswap_limit`) is
|
||||
# used here for consistency and guaranteed enforcement by the Compose CLI in
|
||||
# non-swarm mode, regardless of installed Compose version.
|
||||
#
|
||||
# Sizing rationale (see docs/architecture/SYSTEM_ARCHITECTURE.md §7 scale
|
||||
# targets: 1 host, 150 containers, 40 disks, 300 probes, 2,500 active
|
||||
# dashboard series, 10 concurrent users):
|
||||
# - pulse-postgres holds inventory/config/alerts/audit rows for that scale,
|
||||
# not raw time series (Prometheus stays external) -> generous but bounded.
|
||||
# - pulse-api serves 10 concurrent users and bounded query/WebSocket traffic.
|
||||
# - pulse-worker runs discovery/reconciliation/probes/alerts/notifications;
|
||||
# it is the service most exposed to "runaway probe loop" style incidents,
|
||||
# so its cap is deliberately tight relative to its risk.
|
||||
# - pulse-agent is a bounded read-only collector; pulse-web is static nginx.
|
||||
# - pulse-migrate is a short-lived one-shot job.
|
||||
# These are conservative starting points, not measured against the actual
|
||||
# host's free CPU/memory (M0 discovery in DEPLOYMENT_UNRAID.md §2 records
|
||||
# that separately per-host); re-tune after observing real usage. `memswap_limit`
|
||||
# equals `mem_limit` for every service so a service cannot spill onto host
|
||||
# swap and degrade the other ~80 containers on the shared Unraid host.
|
||||
|
||||
services:
|
||||
pulse-postgres:
|
||||
labels: *unraid-labels
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/postgres.Dockerfile
|
||||
image: itworx-pulse-postgres:17-hardened
|
||||
environment:
|
||||
POSTGRES_DB: ${PULSE_POSTGRES_DB:-pulse}
|
||||
POSTGRES_USER: ${PULSE_POSTGRES_USER:-pulse}
|
||||
POSTGRES_PASSWORD: ${PULSE_POSTGRES_PASSWORD:?PULSE_POSTGRES_PASSWORD must be set}
|
||||
volumes:
|
||||
- pulse-postgres-data:/var/lib/postgresql/data
|
||||
networks: [pulse-internal]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
# Read-only exception check (threat model §5 "document and test each"):
|
||||
# the data directory is already the dedicated `pulse-postgres-data`
|
||||
# volume, so PGDATA writes are unaffected. Postgres additionally needs a
|
||||
# writable unix-socket directory (used by both the server and the
|
||||
# `pg_isready` healthcheck above, since no PGHOST is set) and a writable
|
||||
# /tmp for on-disk sort/temp files that fall outside PGDATA. Both are
|
||||
# provided via tmpfs below, matching the pattern of the other five
|
||||
# services. This must be verified during deployment smoke testing
|
||||
# (container starts, healthcheck goes healthy, migrations run
|
||||
# successfully) before this is relied on in production.
|
||||
read_only: true
|
||||
tmpfs: ["/tmp", "/var/run/postgresql"]
|
||||
security_opt: [no-new-privileges:true]
|
||||
cap_drop: [ALL]
|
||||
pids_limit: 256
|
||||
cpus: "1.0"
|
||||
mem_limit: 768m
|
||||
mem_reservation: 256m
|
||||
memswap_limit: 768m
|
||||
|
||||
pulse-api:
|
||||
labels: *unraid-labels
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/api.Dockerfile
|
||||
args:
|
||||
PULSE_BUILD_VERSION: ${PULSE_BUILD_VERSION:-development}
|
||||
PULSE_BUILD_COMMIT: ${PULSE_BUILD_COMMIT:-unknown}
|
||||
PULSE_BUILD_TIME: ${PULSE_BUILD_TIME:-unknown}
|
||||
environment:
|
||||
PULSE_API_ADDR: 0.0.0.0:8081
|
||||
PULSE_ENV: ${PULSE_ENV:-production}
|
||||
PULSE_DATABASE_URL: ${PULSE_DATABASE_URL:?PULSE_DATABASE_URL must be set}
|
||||
PULSE_AUTH_MODE: ${PULSE_AUTH_MODE:-oidc}
|
||||
PULSE_DEFAULT_LOCALE: ${PULSE_DEFAULT_LOCALE:-nl}
|
||||
PULSE_TIMEZONE: ${PULSE_TIMEZONE:-Europe/Brussels}
|
||||
PULSE_LOG_LEVEL: ${PULSE_LOG_LEVEL:-info}
|
||||
PULSE_BREAK_GLASS_ENABLED: ${PULSE_BREAK_GLASS_ENABLED:-false}
|
||||
PULSE_OIDC_ISSUER: ${PULSE_OIDC_ISSUER:-}
|
||||
PULSE_OIDC_CLIENT_ID: ${PULSE_OIDC_CLIENT_ID:-}
|
||||
PULSE_OIDC_CLIENT_SECRET: ${PULSE_OIDC_CLIENT_SECRET:-}
|
||||
PULSE_OIDC_REDIRECT_URL: ${PULSE_OIDC_REDIRECT_URL:-}
|
||||
PULSE_OIDC_GROUPS_CLAIM: ${PULSE_OIDC_GROUPS_CLAIM:-groups}
|
||||
PULSE_OIDC_ROLE_MAPPING: ${PULSE_OIDC_ROLE_MAPPING:-}
|
||||
PULSE_SESSION_IDLE_TTL: ${PULSE_SESSION_IDLE_TTL:-8h}
|
||||
PULSE_SESSION_ABSOLUTE_TTL: ${PULSE_SESSION_ABSOLUTE_TTL:-168h}
|
||||
# API-side metric query/live handlers use the same bounded source as the
|
||||
# worker alert evaluator. Without this explicit wiring the UI receives a
|
||||
# source-unavailable adapter even while worker alerts can query metrics.
|
||||
PULSE_PROMETHEUS_URL: ${PULSE_PROMETHEUS_URL:-}
|
||||
PULSE_PROMETHEUS_TIMEOUT: ${PULSE_PROMETHEUS_TIMEOUT:-10s}
|
||||
depends_on:
|
||||
pulse-migrate: {condition: service_completed_successfully}
|
||||
networks: [pulse-internal, pulse-edge]
|
||||
expose: ["8081"]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8081/healthz >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
tmpfs: ["/tmp"]
|
||||
security_opt: [no-new-privileges:true]
|
||||
cap_drop: [ALL]
|
||||
pids_limit: 256
|
||||
cpus: "1.0"
|
||||
mem_limit: 512m
|
||||
mem_reservation: 128m
|
||||
memswap_limit: 512m
|
||||
|
||||
pulse-worker:
|
||||
labels: *unraid-labels
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/worker.Dockerfile
|
||||
environment:
|
||||
PULSE_ENV: ${PULSE_ENV:-production}
|
||||
PULSE_DATABASE_URL: ${PULSE_DATABASE_URL:?PULSE_DATABASE_URL must be set}
|
||||
# Non-secret public endpoints used to install the bounded, read-only
|
||||
# Pulse and Authentik service checks. Both targets still pass the probe
|
||||
# SSRF policy before any row is written and again at dial time.
|
||||
PULSE_PUBLIC_URL: ${PULSE_PUBLIC_URL:-}
|
||||
PULSE_OIDC_ISSUER: ${PULSE_OIDC_ISSUER:-}
|
||||
# Alert evaluation stays Disabled without a metric source, and container
|
||||
# discovery stays Disabled until a data_sources row exists to attribute
|
||||
# inventory to. Both are reported as Disabled with a reason rather than
|
||||
# silently absent, so an unconfigured capability is visible on the status
|
||||
# page instead of looking healthy.
|
||||
PULSE_PROMETHEUS_URL: ${PULSE_PROMETHEUS_URL:-}
|
||||
PULSE_CONTAINER_SOURCE_ID: ${PULSE_CONTAINER_SOURCE_ID:-}
|
||||
# Private/loopback CIDRs service probes may reach. Empty keeps all private
|
||||
# space blocked; link-local, multicast and cloud metadata stay blocked
|
||||
# regardless of this value.
|
||||
PULSE_PROBE_ALLOWED_NETWORKS: ${PULSE_PROBE_ALLOWED_NETWORKS:-}
|
||||
# Optional HTTPS webhook transport. The token is injected only into the
|
||||
# worker process and is represented in PostgreSQL by an opaque reference.
|
||||
PULSE_NOTIFICATION_WEBHOOK_URL: ${PULSE_NOTIFICATION_WEBHOOK_URL:-}
|
||||
PULSE_NOTIFICATION_WEBHOOK_TOKEN: ${PULSE_NOTIFICATION_WEBHOOK_TOKEN:-}
|
||||
PULSE_NOTIFICATION_WEBHOOK_TIMEOUT: ${PULSE_NOTIFICATION_WEBHOOK_TIMEOUT:-10s}
|
||||
PULSE_HEARTBEAT_FILE: ${PULSE_HEARTBEAT_FILE:-/tmp/healthy}
|
||||
depends_on:
|
||||
pulse-migrate: {condition: service_completed_successfully}
|
||||
# pulse-internal reaches PostgreSQL but deliberately has no public egress.
|
||||
# pulse-edge supplies outbound HTTPS/DNS for SSRF-bounded probes. The
|
||||
# worker has no listener, exposed port or published port on either network.
|
||||
networks: [pulse-internal, pulse-edge]
|
||||
# Heartbeat healthcheck contract: see
|
||||
# docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md and
|
||||
# deploy/healthcheck-heartbeat.sh. `kill -0 1` only proves the PID
|
||||
# exists, not that the scheduling loop is making progress; the script
|
||||
# below checks the freshness of a heartbeat file written by the process
|
||||
# itself and force-restarts the container (via `restart: unless-stopped`)
|
||||
# when it goes stale, because `docker compose up` does not restart a
|
||||
# merely-"unhealthy" container on its own.
|
||||
healthcheck:
|
||||
test: ["CMD", "/usr/local/bin/pulse-healthcheck.sh"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
start_period: 20s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
tmpfs: ["/tmp"]
|
||||
security_opt: [no-new-privileges:true]
|
||||
cap_drop: [ALL]
|
||||
pids_limit: 256
|
||||
cpus: "1.0"
|
||||
mem_limit: 512m
|
||||
mem_reservation: 128m
|
||||
memswap_limit: 512m
|
||||
|
||||
pulse-agent:
|
||||
labels: *unraid-labels
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/agent.Dockerfile
|
||||
environment:
|
||||
PULSE_ENV: ${PULSE_ENV:-production}
|
||||
PULSE_DATABASE_URL: ${PULSE_DATABASE_URL:?PULSE_DATABASE_URL must be set}
|
||||
# Optional read-only Unraid GraphQL source. This is an API key, never a
|
||||
# Docker socket or a host-control credential; omit both values to keep
|
||||
# container telemetry explicitly Unknown.
|
||||
PULSE_UNRAID_URL: ${PULSE_UNRAID_URL:-}
|
||||
PULSE_UNRAID_API_TOKEN: ${PULSE_UNRAID_API_TOKEN:-}
|
||||
PULSE_UNRAID_CA_FILE: ${PULSE_UNRAID_CA_FILE:-}
|
||||
PULSE_AGENT_ID: ${PULSE_AGENT_ID:-pulse-agent}
|
||||
PULSE_AGENT_COLLECT_INTERVAL: ${PULSE_AGENT_COLLECT_INTERVAL:-10s}
|
||||
# The host's procfs and sysfs are bind mounted read-only below; the collector
|
||||
# reads nothing else.
|
||||
PULSE_AGENT_PROC_ROOT: /host/proc
|
||||
PULSE_AGENT_SYS_ROOT: /host/sys
|
||||
# /proc/sys/kernel/hostname is resolved through the reader's UTS namespace, so
|
||||
# inside the container it returns the container name. Name the host explicitly.
|
||||
PULSE_AGENT_HOST_NAME: ${PULSE_AGENT_HOST_NAME:-}
|
||||
# Filesystem capacity is opt-in: it needs a read-only host root mount, which is a
|
||||
# wider grant than /proc and /sys and is therefore an explicit operator decision.
|
||||
# Set PULSE_AGENT_FS_ROOT=/host/root and add "- /:/host/root:ro" below to enable.
|
||||
PULSE_AGENT_FS_ROOT: ${PULSE_AGENT_FS_ROOT:-}
|
||||
# Read-only, no-exec bind mounts of the host's kernel interfaces. This is the whole
|
||||
# of the agent's host access: no Docker socket (ADR-0005), no writable host path,
|
||||
# no extra capability — statfs(2) and reading procfs need none, and the container
|
||||
# keeps the read-only root filesystem, the dropped capabilities and no published ports.
|
||||
volumes:
|
||||
- /proc:/host/proc:ro
|
||||
- /sys:/host/sys:ro
|
||||
depends_on:
|
||||
pulse-api: {condition: service_healthy}
|
||||
networks: [pulse-internal, pulse-collector]
|
||||
# See the heartbeat healthcheck contract note on pulse-worker above and
|
||||
# docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md.
|
||||
healthcheck:
|
||||
test: ["CMD", "/usr/local/bin/pulse-healthcheck.sh"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
start_period: 20s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
tmpfs: ["/tmp"]
|
||||
security_opt: [no-new-privileges:true]
|
||||
cap_drop: [ALL]
|
||||
pids_limit: 256
|
||||
cpus: "0.5"
|
||||
mem_limit: 256m
|
||||
mem_reservation: 64m
|
||||
memswap_limit: 256m
|
||||
|
||||
pulse-web:
|
||||
labels: *unraid-labels
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/web.Dockerfile
|
||||
depends_on:
|
||||
pulse-api: {condition: service_healthy}
|
||||
networks: [pulse-edge]
|
||||
expose: ["8080"]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
tmpfs: ["/tmp:rw,noexec,nosuid", "/var/cache/nginx:rw,noexec,nosuid", "/var/run:rw,noexec,nosuid"]
|
||||
security_opt: [no-new-privileges:true]
|
||||
cap_drop: [ALL]
|
||||
pids_limit: 256
|
||||
cpus: "0.5"
|
||||
mem_limit: 128m
|
||||
mem_reservation: 32m
|
||||
memswap_limit: 128m
|
||||
|
||||
pulse-migrate:
|
||||
labels: *unraid-labels
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/migrate.Dockerfile
|
||||
environment:
|
||||
PULSE_DATABASE_URL: ${PULSE_DATABASE_URL:?PULSE_DATABASE_URL must be set}
|
||||
depends_on:
|
||||
pulse-postgres: {condition: service_healthy}
|
||||
networks: [pulse-internal]
|
||||
restart: "no"
|
||||
read_only: true
|
||||
tmpfs: ["/tmp"]
|
||||
security_opt: [no-new-privileges:true]
|
||||
cap_drop: [ALL]
|
||||
pids_limit: 256
|
||||
cpus: "1.0"
|
||||
mem_limit: 256m
|
||||
mem_reservation: 64m
|
||||
memswap_limit: 256m
|
||||
|
||||
volumes:
|
||||
pulse-postgres-data:
|
||||
|
||||
networks:
|
||||
pulse-internal:
|
||||
internal: true
|
||||
pulse-edge:
|
||||
# Dedicated outbound boundary for the non-root read-only agent. No other
|
||||
# service joins it, and the agent still exposes no port or host capability.
|
||||
pulse-collector:
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/bin/sh
|
||||
# Heartbeat liveness check for pulse-worker and pulse-agent.
|
||||
#
|
||||
# Full contract: docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md
|
||||
#
|
||||
# Summary: the running process must write/touch $PULSE_HEARTBEAT_FILE at
|
||||
# least every ~10s as long as its main loop is alive and making forward
|
||||
# progress. This script treats the file's mtime as the source of truth
|
||||
# (not its contents) so it never needs to parse a timestamp format.
|
||||
#
|
||||
# `docker compose up` (non-swarm) does not restart a container solely
|
||||
# because its HEALTHCHECK reports "unhealthy" -- that status is only
|
||||
# informational under plain Compose. To actually recover a hung-but-alive
|
||||
# process without an external watcher (which would need Docker socket
|
||||
# access, forbidden host-wide per ADR-0005), this script self-terminates
|
||||
# PID 1 once staleness is confirmed, which turns "unhealthy" into a real
|
||||
# container exit that `restart: unless-stopped` then recovers from.
|
||||
#
|
||||
# It deliberately does NOT self-terminate merely because the heartbeat file
|
||||
# is absent shortly after container start (that's the normal boot path,
|
||||
# handled separately below using the entrypoint-recorded start time), which
|
||||
# avoids a restart loop on legitimately slow startups (DB dial, migrations
|
||||
# wait, etc.).
|
||||
set -eu
|
||||
|
||||
HEARTBEAT_FILE="${PULSE_HEARTBEAT_FILE:-/tmp/healthy}"
|
||||
STARTED_AT_FILE="${PULSE_STARTED_AT_FILE:-/tmp/.pulse-started-at}"
|
||||
MAX_AGE_SECONDS="${PULSE_HEARTBEAT_MAX_AGE_SECONDS:-45}"
|
||||
|
||||
now=$(date +%s)
|
||||
|
||||
self_restart() {
|
||||
reason="$1"
|
||||
echo "pulse-healthcheck: $reason; forcing container exit for restart" >&2
|
||||
# Same-UID SIGKILL to PID 1 requires no capabilities (cap_drop: [ALL] is
|
||||
# fine): standard POSIX signal permission only requires a matching UID,
|
||||
# not CAP_KILL, when sender and target share a UID.
|
||||
kill -9 1 2>/dev/null || true
|
||||
}
|
||||
|
||||
if [ ! -f "$HEARTBEAT_FILE" ]; then
|
||||
started_at=$(cat "$STARTED_AT_FILE" 2>/dev/null || echo "$now")
|
||||
uptime=$((now - started_at))
|
||||
if [ "$uptime" -gt "$MAX_AGE_SECONDS" ]; then
|
||||
self_restart "no heartbeat ${uptime}s after container start"
|
||||
else
|
||||
echo "pulse-healthcheck: heartbeat file not yet written (uptime ${uptime}s)" >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mtime=$(stat -c %Y "$HEARTBEAT_FILE" 2>/dev/null) || {
|
||||
echo "pulse-healthcheck: cannot stat heartbeat file $HEARTBEAT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
age=$((now - mtime))
|
||||
|
||||
if [ "$age" -gt "$MAX_AGE_SECONDS" ]; then
|
||||
self_restart "heartbeat stale (${age}s > ${MAX_AGE_SECONDS}s)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM golang:1.26.6-alpine@sha256:3889b425f035be855a72fb4755265311293b6d414521f0a519d819df32222d83 AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum go.work go.work.sum ./
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/pulse-migrate ./cmd/migrate
|
||||
|
||||
FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
|
||||
RUN addgroup -S -g 65532 pulse && adduser -S -D -H -u 65532 -G pulse pulse && apk add --no-cache ca-certificates
|
||||
COPY --from=build /out/pulse-migrate /usr/local/bin/pulse-migrate
|
||||
USER 65532:65532
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD ["sh", "-c", "test -x /usr/local/bin/pulse-migrate"]
|
||||
ENTRYPOINT ["/usr/local/bin/pulse-migrate"]
|
||||
@@ -0,0 +1,87 @@
|
||||
pid /tmp/nginx.pid;
|
||||
error_log /dev/stderr warn;
|
||||
events { worker_connections 256; }
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
access_log /dev/stdout;
|
||||
sendfile on;
|
||||
client_body_temp_path /tmp/client_temp;
|
||||
proxy_temp_path /tmp/proxy_temp;
|
||||
fastcgi_temp_path /tmp/fastcgi_temp;
|
||||
uwsgi_temp_path /tmp/uwsgi_temp;
|
||||
scgi_temp_path /tmp/scgi_temp;
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
|
||||
add_header Permissions-Policy "camera=(), geolocation=(), microphone=(), payment=()" always;
|
||||
# Defence-in-depth per SECURITY_THREAT_MODEL.md §4 ("HSTS when HTTPS
|
||||
# deployment is stable"). TLS terminates at the external Nginx Proxy
|
||||
# Manager (ADR-0010); this container only ever serves plain HTTP on
|
||||
# 8080. Browsers ignore Strict-Transport-Security on a non-HTTPS
|
||||
# response, so emitting it here unconditionally is safe even before/if
|
||||
# this service is ever reached directly over HTTP, and it still reaches
|
||||
# the browser once NPM proxies the HTTPS response through unless NPM is
|
||||
# configured to strip it (verify during deployment smoke test, see
|
||||
# docs/operations/DEPLOYMENT_UNRAID.md §7). `includeSubDomains` is set;
|
||||
# `preload` is intentionally omitted because preload-list submission is
|
||||
# effectively irreversible and should only be added once the HTTPS/DNS
|
||||
# setup at the NPM edge has been stable in production for a while.
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
default_type text/plain;
|
||||
return 200 "ok\n";
|
||||
}
|
||||
# Never let conventional internal-observability paths fall through to the
|
||||
# SPA with a misleading HTTP 200. Pulse exposes bounded authenticated
|
||||
# diagnostics below /api/v1/system/*; raw metrics/debug handlers are not a
|
||||
# public web contract.
|
||||
location = /metrics { access_log off; return 404; }
|
||||
location ^~ /debug/ { access_log off; return 404; }
|
||||
# Dependency-aware readiness must reach the API. Without this exact route,
|
||||
# SPA fallback returns index.html with HTTP 200 and monitoring falsely sees
|
||||
# a ready backend even when PostgreSQL or migrations are unavailable.
|
||||
location = /readyz {
|
||||
proxy_pass http://pulse-api:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
location /api/ {
|
||||
proxy_pass http://pulse-api:8081;
|
||||
proxy_http_version 1.1;
|
||||
# Preserve a non-default deployment port. The WebSocket library's
|
||||
# same-origin check compares Origin against Host and correctly rejects
|
||||
# a port-stripped Host header.
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 65s;
|
||||
}
|
||||
location ~ ^/(auth|session)/ {
|
||||
proxy_pass http://pulse-api:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
location / { try_files $uri $uri/ /index.html; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
# Keep PostgreSQL on the official image, but rebuild its privilege-drop helper
|
||||
# with the repository's current Go toolchain so the static helper does not carry
|
||||
# stale Go standard-library vulnerabilities.
|
||||
FROM golang:1.26.6-alpine@sha256:3889b425f035be855a72fb4755265311293b6d414521f0a519d819df32222d83 AS gosu-builder
|
||||
|
||||
ARG TARGETARCH
|
||||
WORKDIR /src
|
||||
|
||||
RUN apk add --no-cache git \
|
||||
&& git init \
|
||||
&& git remote add origin https://github.com/tianon/gosu.git \
|
||||
&& git fetch --depth=1 origin refs/tags/1.19 \
|
||||
&& git checkout --detach FETCH_HEAD
|
||||
|
||||
RUN go get golang.org/x/sys@v0.44.0
|
||||
|
||||
RUN case "${TARGETARCH}" in \
|
||||
amd64) export GOARCH=amd64 ;; \
|
||||
arm64) export GOARCH=arm64 ;; \
|
||||
arm) export GOARCH=arm ;; \
|
||||
386) export GOARCH=386 ;; \
|
||||
*) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
|
||||
esac \
|
||||
&& CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/gosu .
|
||||
|
||||
FROM postgres:17-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193
|
||||
|
||||
COPY --from=gosu-builder /out/gosu /usr/local/bin/gosu
|
||||
|
||||
RUN apk upgrade --no-cache \
|
||||
&& chmod 0755 /usr/local/bin/gosu \
|
||||
&& gosu --version \
|
||||
&& gosu nobody true
|
||||
|
||||
USER postgres
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 CMD pg_isready -U $${POSTGRES_USER:-postgres} -d $${POSTGRES_DB:-postgres}
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
# Minimal entrypoint wrapper used by pulse-worker and pulse-agent images.
|
||||
#
|
||||
# It records the container's process start time (as seen from inside the
|
||||
# container) before exec'ing the real binary, so
|
||||
# deploy/healthcheck-heartbeat.sh can grant a startup grace period without
|
||||
# depending on Docker's HEALTHCHECK `start_period` semantics, which only
|
||||
# suppress unhealthy *status* transitions and do not stop the HEALTHCHECK
|
||||
# CMD (including its self-restart action) from running during that window.
|
||||
#
|
||||
# Contract: this script does not replace or delay the real entrypoint. It
|
||||
# performs one tmpfs write and then immediately execs the given command,
|
||||
# replacing this shell process (PID 1 stays the real Go binary).
|
||||
set -eu
|
||||
|
||||
date +%s > /tmp/.pulse-started-at
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM golang:1.26.6-alpine@sha256:3889b425f035be855a72fb4755265311293b6d414521f0a519d819df32222d83 AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum go.work go.work.sum ./
|
||||
COPY tools/integrationfixture ./tools/integrationfixture
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/pulse-integration-fixture ./tools/integrationfixture
|
||||
|
||||
FROM scratch
|
||||
COPY --from=build /out/pulse-integration-fixture /pulse-integration-fixture
|
||||
USER 65532:65532
|
||||
EXPOSE 9090
|
||||
HEALTHCHECK --interval=2s --timeout=2s --retries=20 CMD ["/pulse-integration-fixture", "health", "http://127.0.0.1:9090/healthz"]
|
||||
ENTRYPOINT ["/pulse-integration-fixture"]
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/bin/sh
|
||||
# Blocking pre-flight gate: fail if any deploy/*.Dockerfile pulls an
|
||||
# external base image without an immutable `@sha256:` digest pin.
|
||||
#
|
||||
# Required by docs/operations/DEPLOYMENT_UNRAID.md §6 ("immutable
|
||||
# release/image digests recorded") and
|
||||
# docs/architecture/SECURITY_THREAT_MODEL.md §5 ("immutable image digest in
|
||||
# production record"). See deploy/IMAGE_DIGESTS.md for the current ledger
|
||||
# and the exact commands to resolve a real digest.
|
||||
#
|
||||
# This script intentionally does NOT contain any digest value itself. It
|
||||
# only checks that Dockerfiles reference one. It must be run (and must pass)
|
||||
# before any production image build/release; wire it into the release
|
||||
# pipeline (e.g. `make build`, `make compose-up`, or CI) as a blocking step,
|
||||
# alongside DEPLOYMENT_UNRAID.md §8 step 1 ("validate clean build and
|
||||
# images").
|
||||
#
|
||||
# Usage: deploy/verify-image-digests.sh
|
||||
# Exit status: 0 if every external base image is digest-pinned, 1 otherwise.
|
||||
|
||||
set -eu
|
||||
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
status=0
|
||||
|
||||
for dockerfile in "$script_dir"/*.Dockerfile; do
|
||||
[ -f "$dockerfile" ] || continue
|
||||
|
||||
# Stage names declared with `AS <name>` in this file: a later `FROM
|
||||
# <name>` refers to a previous build stage, not a registry image, and
|
||||
# must not be treated as something that needs a digest.
|
||||
stage_names=$(grep -Eio '^FROM[[:space:]]+.*[[:space:]]AS[[:space:]]+[a-zA-Z0-9_.-]+' "$dockerfile" \
|
||||
| awk '{print tolower($NF)}' || true)
|
||||
|
||||
grep -Ein '^FROM[[:space:]]' "$dockerfile" | while IFS= read -r line; do
|
||||
lineno=${line%%:*}
|
||||
rest=${line#*:}
|
||||
# First token after FROM, ignoring an optional --platform=... flag.
|
||||
image=$(printf '%s\n' "$rest" | awk '{
|
||||
for (i = 1; i <= NF; i++) {
|
||||
if (tolower($i) == "from") continue
|
||||
if ($i ~ /^--platform=/) continue
|
||||
print $i
|
||||
break
|
||||
}
|
||||
}')
|
||||
image_lc=$(printf '%s' "$image" | tr 'A-Z' 'a-z')
|
||||
|
||||
is_stage=0
|
||||
for s in $stage_names; do
|
||||
if [ "$s" = "$image_lc" ]; then
|
||||
is_stage=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ "$is_stage" -eq 1 ] && continue
|
||||
|
||||
case "$image_lc" in
|
||||
scratch) : ;; # Docker's built-in empty rootfs; no registry manifest or digest exists.
|
||||
*@sha256:*) : ;; # pinned, OK
|
||||
*)
|
||||
echo "UNPINNED: $(basename "$dockerfile"):$lineno: FROM $image" >&2
|
||||
echo " action: resolve a real digest (see deploy/IMAGE_DIGESTS.md) and" >&2
|
||||
echo " change this line to 'FROM $image@sha256:<digest>'." >&2
|
||||
echo "unpinned" >> "$script_dir/.verify-image-digests.tmp"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
|
||||
if [ -f "$script_dir/.verify-image-digests.tmp" ]; then
|
||||
rm -f "$script_dir/.verify-image-digests.tmp"
|
||||
status=1
|
||||
fi
|
||||
|
||||
if [ "$status" -ne 0 ]; then
|
||||
echo >&2
|
||||
echo "deploy/verify-image-digests.sh: FAILED - one or more base images are not pinned by digest." >&2
|
||||
echo "This blocks release per DEPLOYMENT_UNRAID.md §6 / SECURITY_THREAT_MODEL.md §5." >&2
|
||||
else
|
||||
echo "deploy/verify-image-digests.sh: OK - all external base images are digest-pinned."
|
||||
fi
|
||||
|
||||
exit "$status"
|
||||
@@ -0,0 +1,20 @@
|
||||
# Vite uses native lightningcss bindings during production minification. Use the
|
||||
# glibc builder variant so pnpm selects the reproducibly available linux-x64-gnu
|
||||
# optional package; the small nginx runtime remains Alpine-based.
|
||||
FROM node:24-bookworm-slim@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 AS build
|
||||
WORKDIR /src
|
||||
RUN npm install --global pnpm@10.33.0
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/web/package.json ./apps/web/package.json
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY apps/web ./apps/web
|
||||
RUN pnpm --filter @itworx/pulse-web build
|
||||
|
||||
FROM nginx:1.29-alpine@sha256:5616878291a2eed594aee8db4dade5878cf7edcb475e59193904b198d9b830de
|
||||
RUN apk upgrade --no-cache
|
||||
COPY deploy/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY --from=build /src/apps/web/dist /usr/share/nginx/html
|
||||
RUN rm -rf /docker-entrypoint.d/*
|
||||
USER nginx
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD ["sh", "-c", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null"]
|
||||
EXPOSE 8080
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM golang:1.26.6-alpine@sha256:3889b425f035be855a72fb4755265311293b6d414521f0a519d819df32222d83 AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum go.work go.work.sum ./
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/pulse-worker ./cmd/worker
|
||||
|
||||
FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce
|
||||
RUN addgroup -S -g 65532 pulse \
|
||||
&& adduser -S -D -H -u 65532 -G pulse pulse \
|
||||
&& apk add --no-cache ca-certificates tzdata
|
||||
COPY --from=build /out/pulse-worker /usr/local/bin/pulse-worker
|
||||
COPY deploy/pulse-entrypoint.sh /usr/local/bin/pulse-entrypoint.sh
|
||||
COPY deploy/healthcheck-heartbeat.sh /usr/local/bin/pulse-healthcheck.sh
|
||||
RUN chmod 0755 /usr/local/bin/pulse-entrypoint.sh /usr/local/bin/pulse-healthcheck.sh
|
||||
USER 65532:65532
|
||||
# Heartbeat contract: docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 CMD ["/usr/local/bin/pulse-healthcheck.sh"]
|
||||
ENTRYPOINT ["/usr/local/bin/pulse-entrypoint.sh", "/usr/local/bin/pulse-worker"]
|
||||
Reference in New Issue
Block a user