Publish ITWorx Pulse source
Public source validation / validate (push) Failing after 3m8s

This commit is contained in:
ITWorx Pulse release export
2026-09-03 02:09:19 +02:00
commit bd774932d5
614 changed files with 77116 additions and 0 deletions
+67
View File
@@ -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.
+21
View File
@@ -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"]
+17
View File
@@ -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"]
+19
View File
@@ -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"
+65
View File
@@ -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}"
+10
View File
@@ -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}
+22
View File
@@ -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"
+42
View File
@@ -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
+306
View File
@@ -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:
+63
View File
@@ -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
+13
View File
@@ -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"]
+87
View File
@@ -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; }
}
}
+37
View File
@@ -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}
+18
View File
@@ -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 "$@"
+12
View File
@@ -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"]
+84
View File
@@ -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"
+20
View File
@@ -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
+19
View File
@@ -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"]