hq: keep key-store-codex-login active (exited) to stop switch start-limit failures #277

Closed
infra wants to merge 6 commits from infra/hq-codex-login-remain into main
Member

Problem (Redmine #47)

Two consecutive HQ generation switches (a8d14c50, 0b80bf41) failed activation with exit 4: key-store-codex-login.service went to Result=start-limit-hit even though every run exited 0 (ExecMainStatus=0). Manual systemctl reset-failed + start recovers; zero failed units afterwards.

Root cause (hypothesis, from repo inspection)

key-store-codex-login is Type=oneshot with no RemainAfterExit, so it returns to inactive/dead after every success. During switch-to-configuration each concurrent start source re-runs the script: the multi-user.target start phase plus the supervisor unit's wants=/after= on codex-login (nixos/modules/services/agent-dispatcher/default.nix). Enough rapid re-runs within the start-limit window trip StartLimitBurst. The secrets module only orders consumers after the materializer (After=/wants=) and only try-restarts restartUnits (this unit is in units with empty restartUnits), so rotation pickup stays manual either way.

Change

One option added in nixos/keystore-hq.nix: RemainAfterExit = true on key-store-codex-login. After a success the unit stays active (exited) and duplicate starts become no-ops. systemctl restart still re-executes, so the documented post-rotation manual re-run is unchanged. No secret, paging, watchdog, or dependency changes. Forge copy (nixos/hosts/keystore-forge/common.nix) has the same shape and is deliberately untouched — propose as follow-up if HQ validates.

Rollback

git revert this commit, rebuild + switch HQ. The unit returns to plain-oneshot behavior; worst case is a repeat of the current (recoverable via reset-failed + start) failure. No state migration: nothing persists besides the unit's active state.

On-host verification (needs root on HQ)

# 1. canonical eval/build of the HQ config (from sandbox: not possible, no Nix daemon)
nixos-rebuild dry-activate --flake .#keystore-hq   # or the canonical HQ build path
# 2. real switch; must exit 0 with no manual reset-failed
nixos-rebuild switch --flake .#keystore-hq
echo $?
systemctl is-failed key-store-codex-login.service   # expect: inactive (no 'failed')
systemctl show key-store-codex-login.service -p ActiveState,SubState,Result,ExecMainStatus
# 3. rotation semantics spot-check (manual re-run still works)
systemctl restart key-store-codex-login.service; echo $?
# 4. confirm trigger sources for the record
systemctl show key-store-codex-login.service -p StartLimitIntervalUSec,StartLimitBurst
journalctl -u key-store-codex-login.service --since '2026-09-14 01:30' --no-pager

Journald paths for review

  • journalctl -u key-store-codex-login.service (bootstrap runs)
  • journalctl -u openbao-secrets-materializer.service (secret ordering during switch)
  • journalctl -u key-store-agent-dispatcher-supervisor.service (supervisor restarts pulling wants= starts)
  • Prior evidence: /root/work/codex-assistant-wakeups-rollout-20260914/{switch-status.json,bootstrap-recovery.json} on HQ.

Sandbox validation done

  • git diff reviewed (one option + comment, HQ file only).
  • Full Nix eval/build NOT possible from this sandbox (no Nix daemon, read-only /nix/store) — canonical validation must happen on a privileged host before merge/switch.

Review requested: root (HQ owner). Do NOT merge without the on-host switch test in Redmine #47 acceptance.

## Problem (Redmine #47) Two consecutive HQ generation switches (a8d14c50, 0b80bf41) failed activation with exit 4: `key-store-codex-login.service` went to `Result=start-limit-hit` even though every run exited 0 (`ExecMainStatus=0`). Manual `systemctl reset-failed` + start recovers; zero failed units afterwards. ## Root cause (hypothesis, from repo inspection) `key-store-codex-login` is `Type=oneshot` with no `RemainAfterExit`, so it returns to inactive/dead after every success. During switch-to-configuration each concurrent start source re-runs the script: the multi-user.target start phase plus the supervisor unit's `wants=`/`after=` on codex-login (`nixos/modules/services/agent-dispatcher/default.nix`). Enough rapid re-runs within the start-limit window trip StartLimitBurst. The secrets module only orders consumers after the materializer (`After=`/`wants=`) and only try-restarts `restartUnits` (this unit is in `units` with empty `restartUnits`), so rotation pickup stays manual either way. ## Change One option added in `nixos/keystore-hq.nix`: `RemainAfterExit = true` on `key-store-codex-login`. After a success the unit stays `active (exited)` and duplicate starts become no-ops. `systemctl restart` still re-executes, so the documented post-rotation manual re-run is unchanged. No secret, paging, watchdog, or dependency changes. Forge copy (`nixos/hosts/keystore-forge/common.nix`) has the same shape and is deliberately untouched — propose as follow-up if HQ validates. ## Rollback `git revert` this commit, rebuild + switch HQ. The unit returns to plain-oneshot behavior; worst case is a repeat of the current (recoverable via reset-failed + start) failure. No state migration: nothing persists besides the unit's active state. ## On-host verification (needs root on HQ) ``` # 1. canonical eval/build of the HQ config (from sandbox: not possible, no Nix daemon) nixos-rebuild dry-activate --flake .#keystore-hq # or the canonical HQ build path # 2. real switch; must exit 0 with no manual reset-failed nixos-rebuild switch --flake .#keystore-hq echo $? systemctl is-failed key-store-codex-login.service # expect: inactive (no 'failed') systemctl show key-store-codex-login.service -p ActiveState,SubState,Result,ExecMainStatus # 3. rotation semantics spot-check (manual re-run still works) systemctl restart key-store-codex-login.service; echo $? # 4. confirm trigger sources for the record systemctl show key-store-codex-login.service -p StartLimitIntervalUSec,StartLimitBurst journalctl -u key-store-codex-login.service --since '2026-09-14 01:30' --no-pager ``` ## Journald paths for review - `journalctl -u key-store-codex-login.service` (bootstrap runs) - `journalctl -u openbao-secrets-materializer.service` (secret ordering during switch) - `journalctl -u key-store-agent-dispatcher-supervisor.service` (supervisor restarts pulling wants= starts) - Prior evidence: `/root/work/codex-assistant-wakeups-rollout-20260914/{switch-status.json,bootstrap-recovery.json}` on HQ. ## Sandbox validation done - `git diff` reviewed (one option + comment, HQ file only). - Full Nix eval/build NOT possible from this sandbox (no Nix daemon, read-only /nix/store) — canonical validation must happen on a privileged host before merge/switch. Review requested: root (HQ owner). Do NOT merge without the on-host switch test in Redmine #47 acceptance.
KSE-0198: GoAlert on-call paging on HQ host (proposal, no cutover)
Some checks failed
Build: KaiOS / Build (KaiOS) (pull_request) Successful in 25s
Build: Web / Build (Web) (pull_request) Successful in 42s
Build: Linux / Build (Linux x86_64) (pull_request) Successful in 3m11s
Build: Linux / Build (keystore-forge x86_64) (pull_request) Has been skipped
Build: Apple / Check (SwiftLint) (pull_request) Failing after 4m34s
Build: Apple / Build (macOS) (pull_request) Has been skipped
Build: Apple / Build (iOS) (pull_request) Has been skipped
Build: Apple / Build (visionOS) (pull_request) Has been skipped
ab485215bf
Pinned OCI module (goalert:v0.34.1, digest-verified), stdlib webhook
adapter with idempotent signed dispatcher mirror + Zulip mirror,
HQ-local postgresql_16 + daily backup, alerts.key.store DNS/Caddy,
OpenBao secret entries, dispatcher goalertWebhookSecretFile option.

Adapter tests: 4/4 pass (HMAC shape, dedupe, mapping, fail-closed).
Nix eval/build requires HQ host (no daemon socket in sandbox).
OIDC provider, image tarball sha256, /webhook/goalert route, and
cutover are separately-authorized follow-ups (see KSE-0198 §7).

Redmine #35. No deploy, no merge, no secret material.
KSE-0198: wire --goalert-secret-file, document runtime gap
Some checks failed
Build: Apple / Build (macOS) (pull_request) Has been skipped
Build: Apple / Build (iOS) (pull_request) Has been skipped
Build: Apple / Build (visionOS) (pull_request) Has been skipped
Build: Linux / Build (keystore-forge x86_64) (pull_request) Has been skipped
Build: Apple / Check (SwiftLint) (pull_request) Failing after 21s
Build: Web / Build (Web) (pull_request) Successful in 37s
Build: KaiOS / Build (KaiOS) (pull_request) Successful in 42s
Build: Linux / Build (Linux x86_64) (pull_request) Successful in 3m18s
465d3b933f
The option existed but was never passed to ExecStart. Now wired
conditionally (null default = flag absent, safe). Verified against
pinned dispatch_src rev caa2df9b: no /webhook/goalert route and no
--goalert-secret-file flag exist, so the secret must stay null until
the dispatcher runtime ships support (older runtime fails api
startup on the unknown flag). KSE-0198 §7 records the exact gap,
owner (dispatcher + security reviewer), and the no-false-independence
note. Redmine #35.
goalert: replace broken container wiring with pinned native unit (founder steer a7744ed8)
Some checks failed
Build: KaiOS / Build (KaiOS) (pull_request) Successful in 23s
Build: Apple / Check (SwiftLint) (pull_request) Failing after 17s
Build: Apple / Build (macOS) (pull_request) Has been skipped
Build: Apple / Build (iOS) (pull_request) Has been skipped
Build: Apple / Build (visionOS) (pull_request) Has been skipped
Build: Web / Build (Web) (pull_request) Successful in 39s
Build: Linux / Build (Linux x86_64) (pull_request) Successful in 3m13s
Build: Linux / Build (keystore-forge x86_64) (pull_request) Has been skipped
f83f39a062
Container model was unworkable by construction: host-store entrypoint
script plus host bash/cat closure and host secret files are invisible
inside the OCI image, and the container-loopback db-url points at the
container netns instead of HQ PostgreSQL. Image ref was tag-only
despite digest comments. The pinned nixpkgs rev carries no goalert
package (verified zero hits), so the module wraps the pinned upstream
v0.34.1 tarball (URL + SRI) in a host-namespace systemd unit:
loopback --listen/--listen-prometheus/--status-addr,
LoadCredential db-url + data-encryption-key read by a store wrapper,
After postgresql + openbao-secrets-ready, loopback assertions, package
override option. OIDC is runtime config (no CLI flags exist per
upstream app/cmd.go) and stays a cutover step. HQ units entries
retargeted to goalert.service; KSE-0198 updated to the native model;
new stdlib wiring test pins the no-container/SRI/HQ-ordering shape
(12 tests green) alongside the existing adapter tests (4 green).

Review still needed: privileged eval/build (root), port/5432
confirmation, dispatcher route+flag follow-up, security review.
goalert: adapter v2 per founder steer — real upstream auth, required Zulip wiring, exact-2xx, PG17
Some checks failed
Build: KaiOS / Build (KaiOS) (pull_request) Successful in 25s
Build: sqlite3mc / sqlite3mc smoke (Linux x86_64) (pull_request) Successful in 39s
Build: Web / Build (Web) (pull_request) Successful in 40s
Build: Apple / Check (SwiftLint) (pull_request) Failing after 18s
Build: Apple / Build (visionOS) (pull_request) Has been skipped
Build: Apple / Build (macOS) (pull_request) Has been skipped
Build: Apple / Build (iOS) (pull_request) Has been skipped
Build: sqlite3mc / sqlite3mc smoke (macOS) (pull_request) Has been skipped
Build: Linux / Build (Linux x86_64) (pull_request) Successful in 3m25s
Build: Linux / Build (keystore-forge x86_64) (pull_request) Has been skipped
ab570567fd
- adapter.py: secret URL path token (constant-time) replacing bearer —
  pinned upstream v0.34.1 sender sets Content-Type only; service map +
  default destination; bounded queue (default 1000, 429+counted, never
  silent eviction) with background drain thread; dispatcher-delivery
  success is exactly 2xx (3xx alone never acknowledges durable enqueue);
  Zulip mirror is required product wiring — missing/blank URL or
  unreadable bot credentials fail closed at startup (exit 2), while
  per-delivery Zulip POST failures stay best-effort.
- tests: 44-test stdlib suite — HMAC/vector shape, dedupe, steer mapping
  (no sms/voice/phone), fail-closed configs, 302-kept-and-redelivered
  e2e, exact-2xx deliver_one matrix, Zulip Basic-auth + stable-subject
  mirror test, unit wiring test (all env names set, no scoping carve-outs).
- default.nix: adapter service-map/default/queue/drain options + five
  Zulip options (URL/email/key files via LoadCredential, channel, topic
  prefix); full GOALERT_ADAPTER_* environment.
- keystore-hq.nix: goalert/zulip-bot-email + goalert/zulip-bot-key
  OpenBao entries; HQ PostgreSQL 16 -> 17 (pinned nixpkgs 2026-06-03
  ships PG 17, no incompatibility recorded); Zulip service options
  (https://chat.key.store, incidents stream).
- KSE-0198: event contract + checklist updated (six secrets, PG17,
  required Zulip wiring, exact-2xx, queue semantics).

Suite: 44/44 OK (python3 -m unittest tests.test_adapter).
Nix build/parse NOT verified here (sandbox /nix/store remount denied);
root privileged build is the real check. No merge/deploy/cutover.
goalert: fix upstream binary path (sourceRoot) + dispatch#20 HMAC contract + hermetic tests
Some checks failed
Build: sqlite3mc / sqlite3mc smoke (macOS) (pull_request) Has been skipped
Build: KaiOS / Build (KaiOS) (pull_request) Successful in 47s
Build: sqlite3mc / sqlite3mc smoke (Linux x86_64) (pull_request) Successful in 46s
Build: Linux / Build (Linux x86_64) (pull_request) Successful in 4m39s
Build: Linux / Build (keystore-forge x86_64) (pull_request) Has been skipped
Build: Apple / Check (SwiftLint) (pull_request) Failing after 4m54s
Build: Apple / Build (macOS) (pull_request) Has been skipped
Build: Apple / Build (iOS) (pull_request) Has been skipped
Build: Apple / Build (visionOS) (pull_request) Has been skipped
Build: Web / Build (Web) (pull_request) Successful in 7m50s
c5a35d2cfe
- default.nix: pin sourceRoot=goalert (tarball has a single top-level
  dir; Nix auto-cds into it, so cp goalert/bin/goalert failed with
  'cannot stat' per root's package-build.log). install via
  install -Dm755 bin/goalert; add doInstallCheck guarding this class.
  Layout + SRI hash verified 2026-09-14 against the real release asset.
- adapter.py: X-KeyStore-Signature is bare-hex HMAC-SHA256 of the exact
  request bytes per dispatch#20 ingress contract (Redmine #35), not the
  sha256=-prefixed KSE-0101 style the verifier 403s. post_raw signs and
  sends the same bytes; deliver_one serializes once.
- tests: FakeDispatcher verifies bare-hex-of-raw-body; deliver test
  mocks the post_raw seam.
- CI: new //bazel/forge:goalert_adapter_tests hermetic sh_test +
  flake checks.goalert-adapter-tests runCommand twin.

Verified: adapter 44/44, wiring 12/12, runner emulation OK.
Privileged nix build + module eval still root's (sandbox has no daemon).
hq: keep key-store-codex-login active (exited) to stop switch start-limit failures
Some checks failed
Build: sqlite3mc / sqlite3mc smoke (macOS) (pull_request) Has been skipped
Build: Apple / Check (SwiftLint) (pull_request) Failing after 18s
Build: Apple / Build (macOS) (pull_request) Has been skipped
Build: Apple / Build (iOS) (pull_request) Has been skipped
Build: Apple / Build (visionOS) (pull_request) Has been skipped
Build: sqlite3mc / sqlite3mc smoke (Linux x86_64) (pull_request) Successful in 48s
Build: Web / Build (Web) (pull_request) Successful in 41s
Build: KaiOS / Build (KaiOS) (pull_request) Successful in 1m22s
Build: Linux / Build (Linux x86_64) (pull_request) Successful in 3m57s
Build: Linux / Build (keystore-forge x86_64) (pull_request) Has been skipped
6b829e8d5a
Type=oneshot without RemainAfterExit returns to inactive/dead after every
successful run, so each concurrent start source during switch-to-configuration
(multi-user.target start phase + supervisor wants=) re-runs the bootstrap
script. Enough rapid re-runs trip StartLimitBurst and the activation exits 4
with Result=start-limit-hit despite ExecMainStatus=0 (observed 2026-09-13/14,
Redmine #47). With RemainAfterExit=yes the duplicate starts are no-ops.

Rotation procedure unchanged: openaiApiKey/codexAuthJson list this unit in
units (ordering) with empty restartUnits, so post-rotation re-run stays manual
(systemctl restart still re-executes).

Ref: Redmine #47.
Owner

Superseded by the isolated, reviewed, tested and deployed PR279. This original branch inherited the separate unmerged GoAlert stack; that work remains in PR270.

Superseded by the isolated, reviewed, tested and deployed PR279. This original branch inherited the separate unmerged GoAlert stack; that work remains in PR270.
founder closed this pull request 2026-09-14 02:31:52 +00:00
Some checks failed
Build: sqlite3mc / sqlite3mc smoke (macOS) (pull_request) Has been skipped
Build: Apple / Check (SwiftLint) (pull_request) Failing after 18s
Build: Apple / Build (macOS) (pull_request) Has been skipped
Build: Apple / Build (iOS) (pull_request) Has been skipped
Build: Apple / Build (visionOS) (pull_request) Has been skipped
Build: sqlite3mc / sqlite3mc smoke (Linux x86_64) (pull_request) Successful in 48s
Build: Web / Build (Web) (pull_request) Successful in 41s
Build: KaiOS / Build (KaiOS) (pull_request) Successful in 1m22s
Build: Linux / Build (Linux x86_64) (pull_request) Successful in 3m57s
Build: Linux / Build (keystore-forge x86_64) (pull_request) Has been skipped

Pull request closed

Sign in to join this conversation.
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
key-store/key.store!277
No description provided.