Skip to content

docs(ops): document the #3315 revision contract, and correct it after the health split - #3402

Merged
Xore merged 2 commits into
mainfrom
oc/3315-deploy-revision-stamp
Sep 27, 2026
Merged

Xore merged 2 commits into
mainfrom
oc/3315-deploy-revision-stamp

Conversation

@Xore

@Xore Xore commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Summary

#3315's stamping is already merged and working. The work this PR does is the
part that did not survive: the documentation of the contract, which the
/healthz refactor (PR #3382) invalidated.

Found by re-verifying the issue end to end rather than re-implementing it. The
runtime code is correct and current: revision is on the liveness body, both
Dockerfiles take ARG GIT_SHA, build.rs carries it in, the frontend bakes
/build.json, and scripts/verify-deploy.sh exists. What is wrong is that both
docs describing the contract now contradict the code.

Before Now
docs/OPERATIONS.md §Service health contract {"live":…,"built":…} — no revision field at all documents the field, plus the whole contract
docs/ARCANE-GIT-SYNC.md §Proving which revision is deployed {"ok":true,"es":true,"revision":…} {"live":…,"built":…,"revision":…}

ok and es have not existed since #3317 split the endpoint. That is the worse
of the two: an operator greps a live response for a field the docs promised and
concludes the stamp is broken.

Scope

Docs + one test file. No route, handler, status code, auth tier, or
openapi.json byte changed. The one additive field on the health response was
already in main.

Also: this PR was red, and now is not

The three red checks — Docs regression tests (#2576), Scripts and Compose,
Quality gate — are one failure, not three. Docs regression tests (#2576)
is a matrix row, so the other two are just the aggregate jobs reading its
result.

The row was failing on main too (17 failures, run 36319112911), and not
because of anything #3315 touches:

composable-templates: reading /tmp/arkime-composable-shadow/shadow.json: EACCES

run_script in tests/docs/test_3283_fix.py passed no SHADOW_FILE, so
composable-templates.js fell back to its default — one fixed name under
os.tmpdir(). The script opens that path 0700 and the file 0600 for a real
reason (#3343: the stash is the only record that db.pl's templates existed), so
whoever touches the directory first owns it and every later run by another user
is locked out. Green in a fresh container, red on a long-lived runner: the suite
was reporting the host, not the code.

The fix is one TemporaryDirectory per run, which is what the suite's sibling
test_3343_fix.py already does — so this brings 3283 in line rather than
inventing a convention. Deliberately not a chmod and not a skip: a test suite
must not need to write outside its own tmpdir to run at all.

Two tests pin it, and both fail with the SHADOW_FILE line removed:

Test Reproduces
test_the_stash_is_ours_alone_and_never_the_default_tmp_path the runner's exact condition — the default path, planted unreadable under a private TMPDIR
test_two_runs_do_not_see_each_others_stash shadow then generate, the pairing production uses; the second must not restore what it never shadowed

Left alone on purpose: two scripts/tests failures in
test_compose_drift_watch_sweep (privileged-fallback rows). They reproduce with
this change stashed and that CI row is green on this branch, so they are a local
environment artifact, not a regression to fold in here.

Verified

cargo test                                   549 passed, 0 failed, 1 ignored
cargo run --bin openapi | diff openapi.json  byte-identical, no drift
openapi.json                                 132 paths
                                             /healthz {200,502} /livez {200} /readyz {200}
cargo build --release                        ok
pytest tests/docs                            523 passed, 1 xfailed, 0 failed
pytest scripts/tests/test_3315_image_revision.py  40 passed, 48 subtests
4 doc gates (paths-exist, reachable, stale-paths, compose-env)   pass

Live, against a release binary built with GIT_SHA=$(git rev-parse HEAD):

/healthz 200 {"live":true,"built":"…","revision":"feb4b37a20617f471e29fff643a9259a0f6226f5"}
/livez   200 (identical body)          # ES pointed at a dead port
/readyz  503 {"ready":false,"reason":"elasticsearch is unreachable: …","cluster":"unreachable",…}

/livez answering 200 with Elasticsearch on a dead port is the constraint the
issue named, and it holds: no ES on the liveness path, so no restart loop.

verify-deploy.sh driven against that process: 0 on a matching revision,
1 on a stale one, 2 — not 1 — when the endpoint cannot be read, so an
unreadable body can never read as a stale deploy.

Why docs/OPERATIONS.md specifically

The issue says to document the contract there, and it is also the right home:
it already owns the /livez · /healthz · /readyz table and the
"What to point at what" list, and it is the page an operator opens to learn what
those endpoints answer. Added ### Which revision is deployed (#3315): the four
surfaces, where GIT_SHA comes from, what "unknown" means and why it is the
honest default, how to turn it on, and the three-valued exit codes. Also stated
why /readyz deliberately carries no revision — readiness is a property of
the running container, not of a build, and the two answers diverge during a
rollback.

Issues

Refs #3315

Closes is deliberately not used: merging this does not finish #3315, because
GIT_SHA is still not set on the homeserver. Nothing repo-tracked writes it — the
stacks' .env files are root-owned and provisioned outside this repository, and
/opt/stacks/apiary is an rsync with .git/ excluded, so there is no repository
on the host for compose to ask. The first verify-deploy.sh run will correctly
report not stamped until an operator adds one line per stack, which is
documented rather than invented here.

Security impact

  • No real credentials, private addresses, payloads, PCAPs, keys, or .env files were added.
  • Sandbox/network-isolation implications were reviewed.
  • Publicly exposed ports and routes are unchanged, or documented below.

Ports, routes and auth tiers are untouched. revision was already public on
/livez; a git object name names no secret, and it is the same string the image
already carries in org.opencontainers.image.revision.

Validation

The constraints the issue named, checked rather than assumed.

cargo test                                    549 passed; 0 failed; 1 ignored
cargo run --bin openapi | diff openapi.json   byte-identical — no drift
openapi.json                                  132 paths
/healthz ['200','502']  /livez ['200']  /readyz ['200']   (unchanged)
cargo build --release                         ok, with GIT_SHA stamped in

openapi.json was regenerated, never hand-edited — and it needed no
regeneration, because the health responses are annotated
body = inline(serde_json::Value), so an added struct field does not reach the
document. The 1 ignored test is the pre-existing
report_pdf::tests::scratch_dump_multi_page_pdf ("scratch: dumps a multi-page PDF
to /tmp for manual inspection"). No test was weakened or deleted.

Live curl against a running release binary (GIT_SHA=$(git rev-parse HEAD),
ES deliberately pointed at a dead port):

/healthz  200  {"live":true,"built":"2026-09-27T12:52:54+00:00","revision":"17c7e412adfb2d4177cf8093a31548cdef941df5"}
/livez    200  (identical body)
/readyz   503  {"ready":false,"reason":"elasticsearch is unreachable: …","cluster":"unreachable","write_blocked":[]}

Revision populated, not null and not empty. That third line is the constraint
that mattered: /livez returned 200 with Elasticsearch unreachable, so the
probe is still ES-independent and an ES blip cannot restart-loop the container,
while /readyz still 503s with a reason. The split is intact and unmoved.

scripts/verify-deploy.sh driven end to end against that live process:

Case Exit
matching revision 0 PASS … matches the expected revision
stale revision 1 FAIL deployed 17c7e412…, expected 0000…
git rev-parse --short HEAD 0 PASS 17c7e412 is an abbreviation of 17c7e412adfb… — a reported abbreviation, not a cry-wolf
endpoint unreadable 2 could not tell — not 1, so an unreadable body can never read as a stale deploy
frontend /build.json 0 same tool, static-file form

Image labels exercised for real. The runtime-stage ARG/LABEL block was
extracted from both Dockerfiles and built against busybox so the shipped
text was under test, not a retyped copy. Stamped → the real revision;
unstamped → unknown for both revision and created — the honest default,
never a fabricated value.

17c7e412adfb2d4177cf8093a31548cdef941df5 | 2026-09-27T12:00:00Z | https://github.com/Xore/APIARY
unknown                                | unknown               | https://github.com/Xore/APIARY

Both normalizers agree, which is the real risk in a two-language stamp:
write-build-info.mjs fed an UPPERCASE sha emits
3dca4457f1b2c0d4e5a69788796a5b4c3d2e1f0ab and "unknown" when unset,
matching Rust's normalize_revision and the shared
revision-corpus.json table. If those two ever disagree the failure is a deploy
mismatch that is not one.

scripts/tests/test_3315_image_revision.py   40 passed, 48 subtests
build_stamp_tests                           4 passed (incl. the shared-corpus test)
readiness_tests                             7 passed
check-doc-paths-exist / -docs-reachable / -doc-stale-paths / -compose-env-docs   all pass
bash -n scripts/verify-deploy.sh            ok
shellcheck --severity=error (the CI gate)   clean

Not validated: the two dashboard images were not built end to end
(docker build of the real backend Dockerfile is a full cargo release build
under the 1.98.0 toolchain — the label mechanism was proven instead, by
extracting and building the real ARG/LABEL block as described above). The
frontend image was not built either; write-build-info.mjs was run directly and
its output shape verified. Nothing was run against the live homeserver, and
GIT_SHA remains unset there, so the end-to-end "stamped deploy" path is
untested by design — that is the state the docs describe.

Rollout

None — docs only, and merging does not deploy.

Turning the stamp on is separate and operator-side, unchanged by this PR: add
GIT_SHA=$(git rev-parse HEAD) to each stack's .env, then the
POST /projects/{id}/build that a content-change sync does not do.

… the health split

#3315 stamped both dashboard images with their git revision and exposed it
from /healthz. The stamping is in main and works; what did not survive is
the documentation of it, because the health endpoint was refactored
underneath it.

Two stale facts, both now wrong against the code:

- docs/OPERATIONS.md's "Service health contract" section showed the /livez
  body as {"live":…,"built":…} with no revision at all. That is the one
  page an operator opens to learn what these endpoints answer, and the
  field this issue exists to add was missing from it.
- docs/ARCANE-GIT-SYNC.md showed /healthz as {"ok":true,"es":true,
  "revision":…}. #3317 split the endpoint into /livez (no Elasticsearch) and
  /readyz (503 + reason), so ok and es no longer exist. The doc described a
  response shape the service stopped returning a refactor ago, which is
  worse than saying nothing: an operator greps for it and concludes the
  stamp is broken.

Adds the revision contract to OPERATIONS.md where the task asked for it --
the four surfaces (health body, boot log, /build.json, image label), where
GIT_SHA comes from, what "unknown" means and why it is the honest default,
how to turn it on, and verify-deploy.sh's three-valued exit codes. Also
states why /readyz deliberately carries no revision: readiness is a
property of the running container, not of a build, and the two answers
diverge during a rollback.

Verification (this branch, no code changed):

  cargo test                                  549 passed, 0 failed, 1 ignored
  cargo run --bin openapi | diff openapi.json  byte-identical, no drift
  openapi.json                                132 paths; /healthz {200,502},
                                              /livez {200}, /readyz {200}
  cargo build --release                       ok (GIT_SHA stamped in)

Live, against a release binary built with GIT_SHA=$(git rev-parse HEAD):

  /healthz 200 {"live":true,"built":"…","revision":"17c7e412adfb…"}
  /livez   200 (identical body)
  /readyz  503 {"ready":false,"reason":"elasticsearch is unreachable: …"}

That last pair is the constraint the task named: /livez answered 200 with
Elasticsearch pointed at a dead port, so it stays ES-independent and cannot
restart-loop the container, while /readyz 503s with a reason.

verify-deploy.sh driven end to end against that live process: 0 on a
matching revision, 1 on a stale one, 0 on an abbreviated
`git rev-parse --short HEAD` (a reported abbreviation, not a cry-wolf), and
2 — not 1 — when the endpoint cannot be read, so an unreadable body can
never read as a stale deploy. Same for the frontend's /build.json form.

Image labels exercised for real: the runtime-stage ARG/LABEL block was
extracted from both Dockerfiles and built against busybox, stamped and
unstamped. Stamped -> the real revision; unstamped -> "unknown" for both
revision and created, never a fabricated value.

scripts/tests/test_3315_image_revision.py: 40 passed, 48 subtests. The four
docs gates (paths-exist, reachable, stale-paths, compose-env) pass.

Refs #3315

Co-authored-by: OpenCode <opencode@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

… reporting the host

The "Docs regression tests (#2576)" lane, "Scripts and Compose" and
"Quality gate" are one failure, not three. All 20-odd tests in
test_3283_fix.py died on the homeserver runner before reaching an
assert:

  composable-templates: reading /tmp/arkime-composable-shadow/shadow.json:
  EACCES: permission denied

run_script passed no SHADOW_FILE, so composable-templates.js fell back to
its default -- one fixed name under os.tmpdir(). It opens that path 0700
and the file 0600 for a reason (#3343: the stash is the only record that
db.pl's templates existed), so the first user to touch the directory owns
it and every later run by another user is locked out. On a fresh container
nothing else has been there and the suite is green; on a long-lived
runner it is not, and which of those you get depends on the machine
rather than the code.

The suite's sibling already had this right: test_3343_fix.py passes
SHADOW_FILE and inherits os.environ. This brings 3283 in line, one
TemporaryDirectory per run, so the env stays the closed set it was rather
than becoming os.environ.

Two tests pin it, and both fail without the SHADOW_FILE line:

- the default path under a private TMPDIR is planted unreadable, which is
  the runner's exact condition, and the run must still succeed. Planting
  it in the real /tmp would make the test depend on the shared state it
  is asserting independence from.
- `shadow` then `generate` is how the stash travels in production, so
  that is the pairing driven here. The second run must not put back a
  template it never shadowed. generate alone cannot catch this: it reads
  the stash but never writes it.

Not a chmod, and not a skip. The suite must not need to write outside its
own tmpdir to run at all.

Unrelated and left alone: two scripts/tests failures in
test_compose_drift_watch_sweep (privileged-fallback rows) reproduce with
this change stashed, and that CI row is green on this branch.

Refs #3315

Co-authored-by: OpenCode <opencode@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants