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
@@ -0,0 +1,118 @@
# Worker/agent healthcheck contract
Applies to `pulse-worker` (`cmd/worker`) and `pulse-agent` (`cmd/agent`). Both
are currently foundation stubs (`cmd/worker/main.go`, `cmd/agent/main.go`)
that will gain real scheduling loops. This document is the contract the Go
implementation must satisfy so the compose-side healthcheck already wired in
`deploy/compose.yaml` reports true liveness instead of "process exists".
## Why not an HTTP `/healthz`
`pulse-api` and `pulse-web` already expose a network port, so an HTTP
`/healthz` costs nothing extra there. `pulse-worker` and `pulse-agent` sit on
`pulse-internal` only and have no other reason to bind a port; opening one
solely for a healthcheck would be an unnecessary internal attack surface
(see `apps/agent/AGENTS.md`: "No generic shell/exec/file-write endpoint" and
the general least-privilege posture of ADR-0005/ADR-0010). A heartbeat file
on the already-mounted `tmpfs` `/tmp` needs no new port, no new capability,
and no new listener.
## What the compose/image side already does
- `deploy/pulse-entrypoint.sh` is the container `ENTRYPOINT`. It writes the
current unix time to `/tmp/.pulse-started-at` and then `exec`s the real
binary (PID 1 becomes the Go process). This exists only so the healthcheck
script can grant a startup grace period independent of Docker's
`start_period`, which suppresses unhealthy *status* but does not stop the
`HEALTHCHECK CMD` (or its side effects) from running during that window.
- `deploy/healthcheck-heartbeat.sh` is the `HEALTHCHECK CMD` (also wired
explicitly in `deploy/compose.yaml`, `interval=15s timeout=5s
start_period=20s retries=3`). It checks the mtime of the heartbeat file
described below. If the file is missing for longer than
`PULSE_HEARTBEAT_MAX_AGE_SECONDS` (default **45s**) after container start,
or exists but its mtime is older than that same threshold, the script logs
why and sends `SIGKILL` to PID 1. `docker compose up` (non-swarm) does not
restart a container merely because it is reported "unhealthy" — killing
PID 1 turns that into a real container exit, which `restart:
unless-stopped` then recovers automatically. Same-UID `SIGKILL` needs no
Linux capability, so this works under `cap_drop: [ALL]`.
You do not need to change either script or the Dockerfiles to implement this
contract — only the points below, inside `cmd/worker` / `cmd/agent` and
whatever internal packages they call.
## What the Go side must implement
1. **Path.** Write the heartbeat to the file named by the
`PULSE_HEARTBEAT_FILE` environment variable if set, otherwise
`/tmp/healthy`. That path is on the container's `tmpfs` `/tmp` mount
(already present in `deploy/compose.yaml` for both services), so no new
volume or mount is needed.
2. **Cadence tied to real progress, not a free-running ticker.** Update the
heartbeat only after the main scheduling loop completes an iteration (a
scheduler tick, a due-job scan, a lease renewal, a completed
discovery/capability-collection pass — whatever the loop's unit of work
is for that binary). Do **not** heartbeat from an independent goroutine
that ticks on a timer regardless of whether the main loop is stuck — that
would silently defeat the whole point of this contract (the exact failure
mode `kill -0 1` had).
3. **Interval.** The loop must complete an iteration, and therefore heartbeat,
at least every **10 seconds** even when there is no work to do (an empty
poll is still a completed iteration). This gives a comfortable margin
under the 45s staleness threshold enforced by
`deploy/healthcheck-heartbeat.sh`, tolerating a couple of missed/slow
cycles before the container is killed.
4. **First heartbeat.** Write one heartbeat immediately after startup
(config loaded, DB reachable) and before blocking on the first real unit
of work, so a slow-but-healthy cold start is not mistaken for a hang.
5. **Bounded blocking calls.** Every blocking call inside the loop iteration
(DB queries, Unraid/agent-protocol calls, probe dials, notification
sends) must use a `context` with a timeout well under 10s, and
long-running job execution must happen in a separate goroutine so the
scheduler loop itself stays responsive. This is also required by
`apps/worker/AGENTS.md` ("Every job is idempotent, cancellable, bounded
and observable") independent of this contract — the two requirements
reinforce each other: if a call isn't bounded, the loop stalls, the
heartbeat goes stale, and the container is killed and restarted,
correctly surfacing the hang instead of hiding it.
6. **Write semantics.** A simple truncate-and-write (or an `os.Chtimes` touch
if content does not need to change) is sufficient; the healthcheck reads
only the file's mtime, not its contents, so no locking or atomic
rename is required for correctness. For operator debuggability, write the
current UTC time in RFC 3339 (e.g. `2026-08-04T12:00:03Z\n`) as the file
content so `docker exec <container> cat /tmp/healthy` is meaningful.
7. **Failure handling.** If writing the heartbeat file itself fails (e.g.
tmpfs write error), log it at error level and continue the loop — do not
crash the process for a heartbeat I/O failure alone. A sustained write
failure will naturally show up as staleness and get caught by the
healthcheck.
8. **Shutdown.** No special handling is required on `SIGTERM`/graceful
shutdown; leaving the heartbeat file in place is fine since the container
is stopping anyway (`service.WaitForStop` already handles the
signal-driven shutdown path in both `cmd/worker/main.go` and
`cmd/agent/main.go`).
## Tunables
| Env var | Default | Set where | Meaning |
|---|---|---|---|
| `PULSE_HEARTBEAT_FILE` | `/tmp/healthy` | worker/agent process env (compose) | Path the Go process writes and the healthcheck script reads. |
| `PULSE_HEARTBEAT_MAX_AGE_SECONDS` | `45` | healthcheck script env (compose, if overridden) | Staleness threshold before the healthcheck force-restarts the container. |
| `PULSE_STARTED_AT_FILE` | `/tmp/.pulse-started-at` | entrypoint/healthcheck script env (compose, if overridden) | Where the entrypoint records container start time, used only for startup grace. |
None of these are currently set as explicit environment variables in
`deploy/compose.yaml` (the defaults baked into the scripts are used); add
them there if the recommended values above ever need to change per
deployment.
## Reviewer double-check
Self-terminating a container from inside its own healthcheck is an unusual
pattern. It was chosen because `docker compose up` (non-swarm, confirmed by
`docs/operations/DEVELOPMENT_SETUP.md`) does not auto-restart merely
"unhealthy" containers, and an external auto-heal watcher would need Docker
socket access, which ADR-0005 forbids host-wide. If that trade-off is
unacceptable, the alternative is to drop the `kill -9 1` and rely on the
Unraid dashboard surfacing "unhealthy" status for manual operator action —
but that leaves the original "never restarts" finding only partially fixed
(visible, not self-healing).