Skip to content

fix(deploy): curl-free healthchecks, /ready, non-root images, k8s probes, state dir under /data - #342

Merged
IanFrelinger merged 9 commits into
masterfrom
fix/container-health-and-k8s
Aug 17, 2026
Merged

fix(deploy): curl-free healthchecks, /ready, non-root images, k8s probes, state dir under /data#342
IanFrelinger merged 9 commits into
masterfrom
fix/container-health-and-k8s

Conversation

@IanFrelinger

Copy link
Copy Markdown
Owner

Production-readiness audit, M8 + M9 + M12 + L2 + L3 + L4. Ledger: https://claude.ai/code/artifact/15fa4198-97b8-4da6-bea0-0c24d364a7f3

Why

  • M8 — .docker/Dockerfile.api|quickstart|fleet-host declared a curl HEALTHCHECK on mcr.microsoft.com/dotnet/aspnet:8.0, which ships no curl, so every container was (unhealthy) forever and compose up --wait could never pass; scripts/prod-dry-run.sh hid that with 2>/dev/null and burned ~90 s. /health was a constant 200 and there was no /ready.
  • M9 — deploy/k8s/nexo-mesh-worker-deployment.yaml set NEXO_DEPLOYMENT_PROFILE=Worker, which AddNexo() rejects → CrashLoopBackOff; no probes.
  • M12 — LiteDB stores / snapshots defaulted to CWD / repo root (kernel registrar, adaptation/self-context/rollback DI, every CLI command): lost on container recreate, littered into the bind-mounted repo in agent-server.
  • L2 — gRPC host had no appsettings, no port/TLS docs; API client's Nexo:GrpcTransport defaults name /run/secrets/* no compose mounts.
  • L3 — docker-compose.ephemeral.yml pointed at a non-existent .docker/Dockerfile.ephemeral with the wrong context; docker-compose.provenance.yml had an obsolete version:.
  • L4 — containers ran as root; Swagger UI was unconditional.

What

Five commits:

  1. feat(api) — GET /ready: 200 only between ApplicationStarted and ApplicationStopping, 503 (starting/stopping) otherwise; decision is a pure EvaluateReadiness so lifetime transitions are testable. Swagger on only in Development or Nexo:Api:EnableSwagger=true (logged when enabled outside Development); appsettings.Testing.json keeps it on for existing ingress tests. /ready added to IngressCatalog. ProdStyle test Tests/VirtualProduction/HealthAndReadinessProdStyleTests.cs hits /health + /ready, incl. with ApiKey auth on.
  2. feat(state) — RepoPathResolver.ResolveStateDirectory: NEXO_STATE_DIR (absolute or root-relative) else <repo/app root>/.nexo/state (.nexo/ already gitignored, created on first use). Wired into kernel registrar (copilot tasks, default pattern store), adaptation/self-context/rollback extensions, FileSnapshotStore, and the 8 CLI commands (--store-path help updated). Back-compat: if nexo-*.db already sit at the root and .nexo/state does not exist, the root keeps being used (no move, no log — documented as the simpler correct thing; create .nexo/state or set NEXO_STATE_DIR to migrate). Portal / agent-server / private / cloud compose stacks mount a nexo-state named volume at /data/state + NEXO_STATE_DIR. 4 unit tests in RepoPathResolverTests. Docs: Configuration.md, api/index.md, SelfHostedAgentServer.md, agent-server.env.example, private-backup-restore.md.
  3. fix(docker) — HEALTHCHECK via bash /dev/tcp GET /health + grep " 200 " on the three aspnet images (agent-server is SDK, keeps curl); prod-dry-run.sh keeps --wait stderr. Runtime stages of api/quickstart/fleet-host/cli run as USER $APP_UID (aspnet app user, uid 1654 — the spec's 64198 is not what the MS images use); /data/state + /data/dailies created and chowned to app, NEXO_STATE_DIR / NEXO_DAILIES_PATH point there so nothing writes under root-owned /app. Dockerfile.agent-server intentionally stays root (tester/optimizer agents dotnet build/test a host bind mount of unknown ownership) — called out in the file.
  4. fix(k8s) — profile server (Server = Full composition; Edge strips NCR + agents, wrong for a worker that executes bricks), accepted set commented inline; httpGet liveness /health + readiness /ready; named port; NEXO_STATE_DIR; runAsNonRoot + allowPrivilegeEscalation: false; commented ghcr.io/ianfrelinger/nexo-api:<tag> with pin advice.
  5. docs(deploy) — Nexo.Transport.Grpc.Server.Host/appsettings.json (Urls http://127.0.0.1:5001 — avoids the API's 5000, Kestrel:EndpointDefaults:Protocols=Http2, Nexo:GrpcTransport null cert paths) + docs/GrpcHost.md (h2c vs TLS, mTLS, client options, compose secrets snippet mounting the /run/secrets/* the API defaults expect) + DocsIndex row. Ephemeral compose: kept the nexo service (it is what "ephemeral" is for: run --rm nexo ... against disposable Ollama/Postgres) and pointed it at .docker/Dockerfile.cli with context: ../.., behind the full profile so compose-gate's --profile db lane is untouched; README/DEPLOYMENT rows say what it is. Dropped version: from provenance. docs/DEPLOYMENT.md gets the container health/readiness/non-root/Swagger/k8s-profile section and the runtime-state section.

Verification

  • dotnet build (DOTNET_ROLL_FORWARD=Major): Nexo.API, Nexo.CLI, Nexo.Transport.Grpc.Server.Host (appsettings.json copied to output), Nexo.Tests.Application, Nexo.Tests.Infrastructure (net8.0 + net9.0) — 0 warnings, 0 errors.
  • dotnet test -f net9.0 Tests.Infrastructure: HealthAndReadinessProdStyleTests + MiddlewareIngressIntegrationTests 9/9 pass; Rollback|Snapshot|Adaptation 107/107 pass. Tests.Application RepoPathResolverTests 12/12 pass. Test run created <worktree>/.nexo/state/ and it is gitignored (confirms M12 + .nexo/ ignore).
  • docker compose -f <each touched compose> config --quiet — all six valid (provenance / cloud need their required env vars, as before).
  • Docker daemon was down on this host (Docker Desktop up 18 min, docker-desktop WSL distro Stopped, docker version/docker info hang past 5 min) → could not docker build/run the images locally or run kubeconform; kubectl client dry-run needs cluster discovery. k8s YAML validated by careful read. CI's full-platform-readiness-gate docker-all-images job builds api/quickstart/agent-server and requires /health (dispatch on the branch to prove); prod-dry-run-pr is workflow_dispatch.

Notes / deliberate scope calls

  • Kept Dockerfile.agent-server root (see above). Dockerfile.game-director (commercial, SDK image with curl) and Fleet.Host's unconditional Swagger left untouched — outside the listed files.
  • nexo-step-modes.json (StepExecutionModeStore) still defaults to repo root: it is a JSON config file, not LiteDB state, and its Save() already fails soft; not moved.
  • Named volumes created by older root-running images keep root ownership; one-time chown -R 1654:1654 documented in DEPLOYMENT.md.

🤖 Generated with Claude Code
Reviewer follow-ups applied on top (verified defects): the gRPC host's appsettings.jsonUrls silently overrode ASPNETCORE_URLS (loads after host config) — the loopback default now lives in Program.cs and yields to env/--urls; under USER app, stores defaulting to <cwd>/.nexo threw UnauthorizedAccessException — runtime-studio roots now point under /data/state and /app/.nexo is writable. Docker is down on the dev box, so the non-root images are proven by dispatching full-platform-readiness on this branch (link in comments).

PlzTouchGrassand others added 6 commits August 16, 2026 23:29
…pment
/health was the only probe and it is a constant 200 by design (liveness),
so nothing told an orchestrator when the host had actually finished
starting or had begun to shut down. /ready now answers 200 only between
IHostApplicationLifetime.ApplicationStarted and ApplicationStopping, and
503 ("starting" / "stopping") otherwise, so k8s readinessProbe, compose
--wait and load balancers can drain before Kestrel closes. The decision
is a pure function (EvaluateReadiness) so the lifetime transitions can be
pinned without an in-process host, which cannot show the drain window.
Swagger UI + document were served unconditionally. They enumerate every
mapped route and schema, so they are now on only in Development or when
Nexo:Api:EnableSwagger is set (Nexo__Api__EnableSwagger=true), and the
host logs when it is enabled outside Development. appsettings.Testing.json
keeps it on for the existing ingress tests.
ProdStyle coverage under Tests/VirtualProduction hits /health and /ready
on the real pipeline, including with built-in ApiKey auth on, since probes
carry no credentials.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LiteDB stores and rollback snapshots (nexo-patterns.db, nexo-adaptation.db,
nexo-adaptation-audit.db, nexo-copilot-tasks.db, nexo-execution.db,
nexo-test-failures.db, nexo-snapshots/) defaulted to the CWD / repo root
in the kernel registrar, the adaptation, self-context and rollback DI
extensions and every CLI command. In a container that is /app, which is
lost on recreate; in the agent-server stack it littered the bind-mounted
repo under /work.
RepoPathResolver.ResolveStateDirectory now centralises the default:
NEXO_STATE_DIR (absolute, or relative to the resolved root) else
<repo or app root>/.nexo/state (already gitignored via .nexo/), created on
first use. Nexo:PatternStorePath / --store-path still win and keep the
sibling files co-located with them.
Backward compatibility is the simplest correct thing: an install with
nexo-*.db already at the root and no .nexo/state yet keeps using the root
(nothing is moved or logged); creating .nexo/state, or setting
NEXO_STATE_DIR, switches to the new layout. Documented in
docs/Configuration.md.
The portal, agent-server, private and cloud compose stacks mount a
nexo-state named volume at /data/state and set NEXO_STATE_DIR to match.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… surface --wait failures
Dockerfile.api, Dockerfile.quickstart and Dockerfile.fleet-host declared a
curl HEALTHCHECK on mcr.microsoft.com/dotnet/aspnet:8.0, which ships no
curl, so every container sat at "(unhealthy)" forever and `docker compose
up --wait` could never succeed. The probe now uses bash's /dev/tcp (bash
and grep are in the runtime image) to GET /health and require a 200.
Dockerfile.agent-server is an SDK image and keeps curl.
scripts/prod-dry-run.sh piped `up -d --wait` stderr to /dev/null, which
hid exactly that failure and burned ~90 s in the fallback loop with no
hint why; stderr is kept now.
The runtime stages of the api, quickstart, fleet-host and cli images now
run as the aspnet image's unprivileged `app` user (USER $APP_UID, uid 1654
in .NET 8+, numeric so k8s runAsNonRoot can verify it). Everything the
process writes lives under /data (state at NEXO_STATE_DIR=/data/state,
dailies at NEXO_DAILIES_PATH=/data/dailies), created and chowned to app;
/app stays root-owned and read-only. Dockerfile.agent-server intentionally
stays root: its tester/optimizer agents run dotnet build/test against a
host bind mount whose ownership the image cannot know.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…orker sample
The sample set NEXO_DEPLOYMENT_PROFILE=Worker, which AddNexo() rejects
(valid: full, server, edge, air-gapped, system), so the pod threw at
startup and CrashLoopBackOff'd. A mesh worker executes bricks over the
runtime transport, which needs the Server composition (same modules as
Full; Edge strips NCR + agents), so it is `server` now, with the accepted
set called out inline.
Adds httpGet livenessProbe on /health and readinessProbe on /ready, a
named http port, NEXO_STATE_DIR=/data/state (per-replica scratch unless a
PVC is mounted), a runAsNonRoot/allowPrivilegeEscalation=false security
context matching the image's app user, and a commented ghcr.io image
reference with the pin-a-tag advice from docs/DEPLOYMENT.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…yment notes
Nexo.Transport.Grpc.Server.Host had no appsettings.json, forced HTTP/2 in
code and documented neither its port nor TLS; the API client's
Nexo:GrpcTransport defaults name /run/secrets/* files that no compose
file mounts. Adds appsettings.json with the knobs the host already reads
(Urls http://127.0.0.1:5001 so it does not collide with the API on 5000,
Kestrel EndpointDefaults Http2, Nexo:GrpcTransport with null cert paths)
and docs/GrpcHost.md covering the listen address, h2c vs TLS, mTLS, the
client-side options, and a compose secrets snippet that mounts the
/run/secrets/* paths the API defaults expect.
docker-compose.ephemeral.yml carried a commented-out nexo service pointing
at a non-existent .docker/Dockerfile.ephemeral with a `.` context that
would have been deploy/compose/. "Ephemeral" is disposable Ollama (+
optional Postgres) plus a one-off `run --rm nexo ...`, so the service is
kept and pointed at .docker/Dockerfile.cli with context ../..; the two
doc rows now say what the file is for. docker-compose.provenance.yml
drops the obsolete `version:` key.
docs/DEPLOYMENT.md gains the container health / readiness / non-root /
Swagger / k8s-profile notes and the runtime-state section that the other
commits in this PR refer to.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-studio stores writable under the non-root user
Reviewer verified two defects on the branch:
- appsettings.json 'Urls' loads AFTER host configuration, so ASPNETCORE_URLS was
silently ignored (bound to loopback :5001 with TLS never engaged). The loopback
default now lives in Program.cs and applies only when no URL is configured.
- With USER app, stores that default to <cwd>/.nexo (/app, root-owned) threw
UnauthorizedAccessException; runtime-studio roots now point under /data/state
and /app/.nexo is created writable as a safety net for the rest.
Also reword the ephemeral compose header (the env var never switched the profile).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cursor

cursorBot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@IanFrelinger

Copy link
Copy Markdown
OwnerAuthor

Docker daemon is unresponsive on the dev box, so the non-root images + /ready are being proven in CI: full-platform-readiness dispatched on this branch → https://github.com/IanFrelinger/Nexo/actions/runs/31992535899

…ullable error in RepoPathResolver
The readiness run on this branch (31992535899) caught both: Nexo.API failed to start
as uid 1654 with UnauthorizedAccessException on /app/config (TrustPolicyPackRegistry
writes <repoRoot>/config/trust-packs), and the netstandard2.0 build of
Nexo.Core.Application failed CS8602 at RepoPathResolver.cs:89 (no nullable annotation
on IsNullOrWhiteSpace there). Pre-create and chown /app/config like /app/.nexo; add the
null-forgiving operator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@IanFrelinger

Copy link
Copy Markdown
OwnerAuthor

Readiness run 31992535899 caught two real defects (non-root /app/config write; netstandard2.0 CS8602) — fixed in e229462. Re-dispatched: https://github.com/IanFrelinger/Nexo/actions/runs/31996300624

…ter's renormalized file + this PR's state-dir edits)
@IanFrelinger
IanFrelinger merged commit 444f2e4 into masterAug 17, 2026
15 of 18 checks passed
@IanFrelinger
IanFrelinger deleted the fix/container-health-and-k8s branch August 17, 2026 05:40
Sign up for freeto 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

@IanFrelinger@PlzTouchGrass