Skip to content

fix: launcher restart policy + complete nginx lazy-resolution migration - #77

Merged
man4ish merged 1 commit into
mainfrom
fix/launcher-restart-policy-and-nginx-lazy-resolution
Sep 2, 2026
Merged

fix: launcher restart policy + complete nginx lazy-resolution migration#77
man4ish merged 1 commit into
mainfrom
fix/launcher-restart-policy-and-nginx-lazy-resolution

Conversation

@man4ish

@man4ish man4ish commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

Root cause of tonight's docs.omnibioai.org outage (502, ~4h): launcher exited with no logged reason and no restart policy, sitting dead. nginx resolves upstream {} blocks eagerly at startup — one unresolvable hostname (launcher) prevented nginx from starting at all, taking down every route behind nginx-router, not just launcher's own.

Fixes

  1. Added restart: unless-stopped to launcher (docker-compose.yml), matching the jupyter/rstudio/vscode convention for persistent, dependency-critical services whose absence has outsized blast radius.

  2. Completed the lazy-resolution migration nginx-router.conf was already partway through (auth/workbench/lims/policy-engine/hpc-policy-engine/billing-service had already been converted, per that file's own in-progress comment, after a prior real incident on auth-service). Converted all 24 remaining static upstream {} blocks (31 call-sites total) to the same set $<name>_upstream host:port; proxy_pass http://$<name>_upstream; pattern, using Docker's embedded DNS resolver (127.0.0.11, already globally declared) for per-request lazy resolution instead of eager startup-time resolution. No upstream in this file load-balances across multiple replicas, so there was no tradeoff in converting all of them uniformly.

Verification

  • Scratch-copy nginx -t comparison confirmed the original config fails with the exact tonight's-incident error (host not found in upstream "rag:8096") while the migrated version passes cleanly.
  • Smoke-tested all 31 converted call-sites plus 3 already-migrated ones for comparison, post-reload: zero 502s, all responses application-level (200s, and expected redirects/401s/404s reaching real backends) rather than proxy failures.
  • Graceful reload only (nginx -s reload) — zero container restart, zero downtime for this change itself.
  • launcher's restart policy applied via docker compose up -d launcher (confirmed stateless — no data volumes, safe to recreate) and verified via docker inspectRestartPolicy=unless-stopped.

⚠️ One unresolved item — please verify or investigate independently

control (public)'s /_svc/control/summary route returns 401 post-migration. This location is marked no-JWT-required in-file (# Control center — public read-only endpoints (no JWT required)), and the diff confirms this pass didn't touch its selector or add any auth_request — only inserted a set $control_upstream ...; line. So the 401 most likely originates from control-center's own application logic, independent of this nginx change, but this was not verified against the pre-migration baseline (would have meant an extra live reload back to the backup for one status code). Your call whether to check this before or after merging — flagging explicitly rather than silently passing over it.

🤖 Generated with Claude Code

Root cause of tonight's docs.omnibioai.org outage (502, ~4h): launcher
exited with no logged reason and no restart policy, sitting dead. nginx
resolves upstream {} blocks eagerly at startup -- one unresolvable
hostname (launcher) prevented nginx from starting at all, taking down
every route behind nginx-router, not just launcher's own.

Fixes:
1. Added restart: unless-stopped to launcher (docker-compose.yml),
   matching the jupyter/rstudio/vscode convention for persistent,
   dependency-critical services whose absence has outsized blast radius.
2. Completed the lazy-resolution migration nginx-router.conf was
   already partway through (auth/workbench/lims/policy-engine/
   hpc-policy-engine/billing-service had already been converted,
   per that file's own in-progress comment, after a prior real
   incident on auth-service). Converted all 24 remaining static
   upstream {} blocks (31 call-sites total) to the same
   set $<name>_upstream host:port; proxy_pass http://$<name>_upstream;
   pattern, using Docker's embedded DNS resolver (127.0.0.11,
   already globally declared) for per-request lazy resolution instead
   of eager startup-time resolution. No upstream in this file
   load-balances across multiple replicas, so there was no tradeoff
   in converting all of them uniformly.

Verified: scratch-copy nginx -t comparison confirmed the original
config fails with the exact tonight's-incident error
(host not found in upstream "rag:8096") while the migrated version
passes cleanly. Smoke-tested all 31 converted call-sites plus 3
already-migrated ones for comparison post-reload: zero 502s, all
responses application-level (200s, and expected redirects/401s/404s
reaching real backends) rather than proxy failures. Graceful reload
only, zero container restart/downtime.

One unresolved, explicitly flagged item: control (public)'s
/_svc/control/summary route returns 401 post-migration; this location
is marked no-JWT-required in-file and the diff confirms this pass
didn't touch its selector or add any auth_request, so the 401 most
likely originates from control-center's own app logic, not from this
nginx change -- but this was not verified against the pre-migration
baseline and should be checked independently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VSmsTvqeqcp9kg9uEVLCFx
@man4ish
man4ish merged commit 35a330b into main Sep 2, 2026
6 of 7 checks passed
man4ish added a commit that referenced this pull request Sep 4, 2026
* test: raise UI test coverage to 95%+ across the board

packages/omnibioai-ui (Table/Card/Tabs component library):
- Add branch-coverage tests for Card's padding/actions-only header, Tabs'
  empty/no-match tab states, and Table's null-comparator and equal-value
  sort branches.
- Raise the branches threshold in vitest.config.ts from 90% to 95%, matching
  statements/lines/functions.
Result: 98.59% stmts / 98.19% branches / 100% funcs / 100% lines.

Root src/ui app (Electron + web UI):
- Add 23 new test files under tests/ui/ covering every page and component
  that was previously thin or untested: Jobs, Settings, Launch, Services,
  RoleManagement, Cloud/LLM/HPC, the App shell, GrafanaViewer, LicenseGate,
  OAuthLinkConfirm, Videos, Logs, Workbench, IdeServices, Wizard,
  ServiceViewer, BugReport, ErrorBoundary, Mode, Sidebar/MobileNav/
  UpdateBanner, and the web-build session/API/roles modules.
- Extend session.test.js and store.test.js with the branches their existing
  tests missed (password login, OAuth link/redirect edge cases, refresh
  failure paths, launchSystem failure, setConfig/setSystemStatus).
- Enforce a 95% threshold (statements/lines/functions/branches) in
  vitest.app.config.js, already wired to `npm run coverage:ui`.
- Remove the leftover scratch vitest.coverage.config.js — its include list
  duplicated vitest.app.config.js's existing test glob — and gitignore the
  generated coverage/ report directory.
Result: went from 47% to 99.01% stmts / 95.02% branches / 99.47% funcs /
99.47% lines.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012pAAjARYEX2VDvfGPJcbj2

* fix(nginx): forward Authorization to control-center on /_svc/control

The gated /_svc/control location correctly validates the request via
auth_request /internal/auth/verify -- which resolves $control_authorization
from either the real Authorization header or, when absent (an iframe
navigation can't set custom headers), the omnibioai_access_token cookie
(map added in 1329fed, Jul 29, specifically for this iframe case). That
covers the *gate*, but the actual proxied request to control-center:7070
was never told to carry that resolved header -- it just forwarded
whatever the original request had, which for a cookie-only iframe
navigation is nothing. control-center's own require_permission()
dependency then independently 401s with "Missing or malformed
Authorization header", since it re-validates the JWT itself rather than
trusting nginx's gate alone.

/_svc/toolserver (a few lines below) already gets this right --
proxy_set_header Authorization $control_authorization; on its proxy_pass,
not just on the auth_request subrequest. /_svc/control was missing the
equivalent line.

This is not related to tonight's PR #77/#78 (the /report/status, /llms,
/report/data, /report/public-stats gating changes in control-center's
main.py) -- neither touched GET / (what the iframe actually loads),
which has stayed platform.manage_infra-gated throughout both. The real
chain: 1329fed (Jul 29) added the cookie fallback but only wired it into
the auth_request subrequest, not the main proxy_pass -- harmless at the
time, since GET / had no backend-side auth yet. 8705cbf (Sept 1, an
unrelated control-center route audit) gated GET / for the first time,
which is what first exposed this dormant gap as a visible failure.

Verified against the live nginx-router container with a real access
token from a real /auth/login call (not a synthetic JWT):
  - /_svc/control/ (the iframe's actual URL) with the cookie: 401 -> 200,
    real HTML instead of {"detail":"Missing or malformed Authorization
    header"}.
  - /_svc/control/docker/containers, /knowledge-base, /storage,
    /cron/jobs, /coverage/status (all gated, non-allowlist): 200 with the
    cookie.
  - No cookie at all: still 401 via nginx's own @cc_unauthorized --
    the gate itself is unaffected.
  - The public allowlist (health/summary/report/report/data) is
    byte-for-byte untouched and behaves identically to before (health:
    200, summary/report: 401 from control-center's own independent
    gating, report/data: 200 per tonight's PR #78) -- they never needed
    forwarding since they were never nginx-gated to begin with.

Applied via: dated backup (docker/nginx-router.conf.bak-2026-09-03-
pre-control-auth-forward, gitignored per existing convention, not
committed), syntax-tested in a throwaway container before touching the
real file, then against the live container's own nginx -t. A plain
`nginx -s reload` did NOT pick up the change -- discovered live that this
docker-compose service bind-mounts the single config *file* by inode,
and an editor's atomic rename-on-save orphans that bind mount from any
subsequent host-side edit regardless of how many times nginx reloads.
Required `docker compose up -d --force-recreate nginx-router` (this one
container only, ~2s of its own downtime, nothing else in the stack
touched) to re-resolve the mount against the current file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VevfQdYwX27LkcGqfRAxQL

* fix(web-ui): wire webApi.loadConfig() in, stop false setup banner

App.jsx's header shows "Setup required — configure data directory"
whenever config.settings.data_dir is unset. data_dir is an Electron-only
concept (the local Docker stack's data directory, read via
window.api.loadConfig() -- electron/preload.js). On the web/cloud
deployment (webstudio.omnibioai.org), window.api is never injected into
a plain browser tab, so that whole branch was skipped and `config` stayed
stuck at its hardcoded initial default forever -- data_dir structurally
can never be set there, so the banner showed unconditionally for every
web visitor, regardless of anything actually being misconfigured.

src/ui/lib/web/webApi.js already had a purpose-built loadConfig() for
exactly this case -- returns { mode: "beta", ..., settings: {} } with a
comment explaining there's nothing to load, since there's no local
Docker stack to configure when connecting to an already-running backend
-- but its own top-of-file comment says it was never wired into any
existing shared page, "out of scope for this isolation-only pass".

Two changes, both required -- wiring in the call alone would NOT have
fixed the visible banner, since webApi.loadConfig()'s return value
(settings: {}) is identical in shape to the hardcoded default already in
useState, so it changes nothing observable by itself:

1. When window.api is unavailable, detect web mode via the existing
   isElectron() helper (lib/session.js -- same helper ServiceViewer.jsx
   already uses for its own Electron/web branching) and call
   webApi.loadConfig(), setting config from its result. No first-run
   setStep(8) redirect for this path, unlike the Electron branch --
   data_dir isn't a first-run condition to redirect out of when it
   structurally doesn't apply.
2. Gate the banner itself on config.mode !== "beta", not just
   !data_dir -- "beta" is this codebase's own existing signal for "no
   local Docker stack" (already used identically by Launch.jsx's and
   Services.jsx's isBeta checks), and correctly applies whether beta
   mode is reached via the web deployment or via choosing "Beta" inside
   the Electron app itself -- both cases have no data_dir to configure,
   for the same reason.

Electron's own branch (window.api.loadConfig() present) is completely
untouched -- confirmed by diff. Existing local/hpc/cloud/hybrid-mode
Electron behavior, including tests/ui/app-shell.test.jsx's own
first-run-banner test (mode: "local", no data_dir -> banner still
expected), is unaffected: that test and the full suite (28 files, 198
tests) pass unchanged. Grepped the whole src/ui/ tree for other
config.settings readers -- only these two call sites exist anywhere.

Verified: rebuilt and redeployed the web-ui container
(`docker compose up -d --build web-ui`), confirmed in the actual served
production bundle (not just source) that the compiled condition is
`o?.mode!=="beta"&&!o?.settings?.data_dir&&(...Setup required...)` --
config.mode defaults to "beta" on web both before and after this change
and nothing there ever moves it off "beta", so the banner no longer
renders.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VevfQdYwX27LkcGqfRAxQL

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.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.

1 participant