Files
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

307 lines
13 KiB
YAML

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: