This commit is contained in:
@@ -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:
|
||||
Reference in New Issue
Block a user