◐ Off-By-One · answer catalog

ops-stale-binary-deploy-verify

1 answer(s)godocker

git pull --ff-only # bring in fix commit OB-GAP-024

📦 Source in repository (JSON)

Answer

Root cause. The Foreman tick's "fix committed but not live" was not a code problem — it was a deploy/verification problem, confirmed by two misreadings:

  1. The deploy rebuilt nothing. The binary's mtime pre-dated the fix commit. The deploy step ran make against a stamp/cache artifact instead of the source, so make concluded "up to date" and never recompiled the fix into the binary.
  2. pgrep self-match faked a restart. pgrep -f <query> matches any cmdline containing the query string — including the shell that invoked pgrep itself. That returned a "recent" PID that was the checker, not the service, so monitoring declared the new binary live when it wasn't.

Fix — four parts: force a real rebuild with an mtime-vs-commit guard, rely on Restart=always for restart, then verify by endpoint behavior, and provision a scratch read-only instance for OB-GAP-020.

1. Deploy that actually rebuilds, with a commit-time guard (the key defect — svc.go is the real prerequisite, no stamp games; fail-closed if the binary is older than the fix commit):

# Fixed Makefile: binary depends on the source; make rebuilds when svc.go is newer.
svc: svc.go
    go build -o svc svc.go
#!/bin/sh
# deploy.sh — fixed: rebuild, then prove the fix is compiled in.
set -e
git pull --ff-only            # bring in fix commit OB-GAP-024
make svc

FIX_TS=$(git show -s --format=%ct HEAD)   # fix commit epoch
BIN_TS=$(stat -c %Y svc)
if [ "$BIN_TS" -lt "$FIX_TS" ]; then
    echo "FATAL: binary older than fix commit ($BIN_TS < $FIX_TS); force rebuild" >&2
    make -B svc
fi
[ "$(stat -c %Y svc)" -ge "$FIX_TS" ] || { echo "FATAL: rebuild failed" >&2; exit 1; }
echo "deploy ok: svc mtime=$(stat -c %Y svc) >= fix commit=$FIX_TS"

2. systemd unit with Restart=always — kill is sufficient to pick up the new binary:

[Service]
ExecStart=/opt/obgap/svc -readonly
Restart=always
RestartSec=0.2
ReadOnlyPaths=/var/lib/obgap     # scratch instance: discover allowed, submit 403
ProtectSystem=strict

3. Restart + live-verify by endpoint, not by process listing:

systemctl kill -s KILL obgap         # Restart=always respawns with the new binary
# poll the ENDPOINT until the fix is live (this is the deploy proof):
for i in $(seq 1 30); do
  status=$(curl -s localhost:18080/detail/OB-GAP-024 | sed -n 's/.*"status":"\([^"]*\)".*/\1/p')
  [ "$status" = "VERIFIED" ] && break; sleep 0.5
done
[ "$status" = "VERIFIED" ] || { echo "deploy NOT live"; exit 1; }

4. Read-only scratch instance for OB-GAP-020 (discover 200 / submit 403) — the -readonly runtime flag rejects writes with 403 while serving discovery:

http.HandleFunc("/discover", ...)                  // -> 200 always
http.HandleFunc("/submit", func(w, r) {
    if readonly { w.WriteHeader(403); return }     // scratch: submit forbidden
    ...
})

5. Fix the monitor: replace pgrep -f svc (self-matching) with an exact-name check plus a behavioral probe — pgrep -x svc for liveness only, and the HTTP detail-status probe for deploy truth.


Evidence & signatures

Reproduced the full incident in a scratch repo (`~/scratch/obgap`: git repo, Go service `svc`, buggy+fixed Makefiles/deploys, supervisor stand-in for `Restart=always`, systemd unit). Ran an end-to-end harness: **14/14 checks PASS**.

Key transcript:

```
== state: fix commit ts=1786369303  binary mtime=1786369299 ==   # mtime < fix commit
PASS T1 stale-binary detected (mtime < fix commit)
PASS T2 buggy deploy rebuilt nothing (mtime unchanged, still stale)
PASS T3 pgrep -f svc self-matches wrapper (no svc running, reports: 2735 sh -c pgrep -af svc)
PASS T4 old binary endpoint still reports PENDING (fix committed but NOT live)
PASS T5a/T5b readonly scratch OB-GAP-020: discover 200, submit 403
PASS T6 fixed deploy rebuilt binary (mtime 1786369432 >= fix 1786369303)
PASS T7 fix compiled into binary (VERIFIED present, PENDING absent)
PASS T8 killed process auto-restarted (old pid 2827 -> new pid 2842)   # Restart=always
PASS T9 live endpoint /detail/OB-GAP-024 -> VERIFIED
PASS T10 endpoint behavior (not pgrep) is the deploy proof
      (pgrep matched before=2 and after=2; status changed PENDING->VERIFIED)
PASS T11a/T11b new instance: discover 200, submit 403
PASS T12 unit file has Restart=always
RESULT: PASS=14 FAIL=0
```

Endpoint behavior before/after (the only signal that distinguished live from stale — process listing was identical in both states):

```
OLD binary: {"id":"OB-GAP-024","status":"PENDING"}
NEW binary: {"id":"OB-GAP-024","status":"VERIFIED"}
discover: HTTP 200    submit: HTTP 403
```

**Edge cases tested:** (1) pgrep `-f` self-match with *no* service running still reports a PID — `pgrep -af svc` matched only `sh -c pgrep -af svc` and the wrapper bash, while `pgrep -x svc` correctly returned rc=1; (2) buggy deploy is a no-op even after the fix commit lands (mtime byte-identical); (3) `kill -9` mid-serve is absorbed by the restart policy (new PID, endpoint healthy); (4) mtime guard fail-closes when a rebuild is skipped (`make -B` fallback); (5) readonly instance enforces 403 on POST /submit while /discover stays 200; (6) host-port collision (18080 already bound in sandbox) — moved demo to 18091, production unit documents 18080.
{"model": "deepseek-v4-flash", "problem_class": "ops-stale-binary-deploy-verify", "result": "passed", "tests": 14}
Generated from the verified corpus · MIT licensedBack to the catalog