Troubleshooting
On this page
Docker & DevStack
Garage initialization fails
The S3-compatible Garage service needs bucket and key initialization after startup. Since C4-gate
(#1012) that runs as the in-graph garage-init compose one-shot on every docker compose up
path — xtask and manual alike — before the seed (which gates on it completing). If you see errors
about missing buckets or access denied:
-
Ensure Garage is healthy:
docker compose ps garage -
Check the one-shot’s outcome:
docker compose ps -a garage-initanddocker compose logs garage-init -
Re-run it in place:
docker compose up -d garage-init(idempotent — a persisted garage volume makes it a fast no-op)
Keycloak hostname errors
Keycloak 26+ uses "hostname v2" configuration.
The KC_HOSTNAME_PORT variable is silently ignored.
KC_HOSTNAME must be a full URL including the port:
KC_HOSTNAME: http://host.docker.internal:8180 # Correct
KC_HOSTNAME: localhost # Wrong — port will default to 443
Postgres "permission denied" / 42501 after #1310
Since #1310 the devstack splits each service’s DB credential: gates connect as
craig_<svc>_owner, serving containers as craig_<svc>_app (DML-only). Symptoms and
remedies:
-
Services fail auth (
password authentication failed for user "craig_<svc>_…") right after pulling #1310: the roles are provisioned bydevstack/postgres/init.sql, which runs only on a FRESH data directory — runcargo xtask dev reseed. -
permission denied(SQLSTATE 42501) on a table: the table was created by a role other than the service’s owner role (e.g. hand-created as thecraigsuperuser), so the owner’s default privileges never fired. Recreate it through a migration (gate-applied), orGRANT SELECT, INSERT, UPDATE, DELETE ON <table> TO craig_<svc>_app;. -
A 42501 on
CREATE/ALTER/DROPfrom a serving container is the boundary WORKING — serving binaries never issue DDL (ADR-063); schema changes go through the migration gates.
RabbitMQ authentication failures
The RabbitMQ user must be defined in devstack/rabbitmq/definitions.json with a SHA-256 password hash.
Environment variables alone do not configure users.
If you change the password, regenerate the hash and update definitions.json.
Port conflicts
Default service ports:
| Service | Port |
|---|---|
craig-rules |
8001 |
craig-cases |
8002 |
craig-placement |
8003 |
craig-exchange |
8004 |
craig-financial |
8005 |
craig-reporting |
8006 |
craig-security |
8007 |
craig-intake |
8008 |
craig-composition |
8009 |
craig-intake-standalone |
8010 |
craig-web |
8080 |
Keycloak |
8180 |
PostgreSQL |
5432 |
RabbitMQ |
5672 |
Garage (S3) |
3900 |
If a port is in use, check for lingering Docker containers: docker ps -a
Testing
Random 30-second reqwest timeouts
Nextest defaults to logical CPU count for parallelism (e.g., 32 threads on a dev machine). This overwhelms the devstack with concurrent connections.
Fix: Set test-threads = 16 (physical core count) in .config/nextest.toml.
This is already configured in the repository.
Keycloak 401 errors during long test runs
Keycloak access tokens have a 30-minute lifetime. If your test session exceeds this, you’ll see 401 Unauthorized errors.
Fix: Re-run the tests. For very long sessions, consider increasing the token lifetime in the Keycloak admin console (Realm Settings → Tokens).
E2E tests fail with DNS errors in CI
CI runs in Docker-in-Docker (DinD).
Services are reachable via Docker Compose service names, not host.docker.internal or localhost.
Correct CI URLs:
CRAIG_E2E_BASE_URL=http://craig-web:8080 # Not http://localhost:8080
OIDC_ISSUER=http://keycloak:8080/realms/craig # Not http://host.docker.internal:8180
Test results location
All test output is saved to test-results/ at the repo root:
-
test-results/e2e/artifacts/— failure screenshots and traces -
test-results/e2e/junit.xml— structured test results -
test-results/ci/unit.xml— unit test results (CI) -
test-results/ci-integration/integration.xml— integration test results (CI)
Pre-push sdk-test pypi DNS flake
The pre-push battery’s final sdk-test stage runs the Python SDK suite in a python:3.13-alpine container that pip install`s the build backend (`hatchling) from pypi.org.
It intermittently fails to resolve pypi.org with [Errno -3] Try again (EAI_AGAIN) — a transient DNS failure inside the container, not a code or test defect:
NameResolutionError(host='pypi.org', port=443): Failed to resolve 'pypi.org' ([Errno -3] Try again)
ERROR: No matching distribution found for hatchling
Fix: If the push fails only at the sdk-test/Python stage with this pypi DNS error and every earlier battery stage passed, just re-push (the whole battery re-runs).
Confirm the branch actually landed afterwards with git ls-remote.
reqwest "dns error" against an IPv4 literal = redirect chain
A reqwest error like ConnectError("dns error", …) whose url is a literal IPv4 address (e.g. http://127.0.0.1:38901/) is almost always a redirect chain landing on an unresolvable hostname — not an actual DNS problem with the literal IP.
reqwest’s Error::url() preserves the original requested URL even when the failure occurs mid-redirect, so the message names 127.0.0.1 while the failed lookup was really something like host.docker.internal (a container-only name that does not resolve from outside containers).
Fix: Before suspecting nsswitch / NSS / IPv4-vs-IPv6, disable redirect following — Client::builder().redirect(reqwest::redirect::Policy::none()).
If the error disappears, you were following a redirect chain to an unreachable host.
(A DNS error against a real hostname such as https://example.com may be a genuine DNS problem; this rule applies when the failing URL is a literal IP and the error is unexpected.)
Development
gen or ref used as identifier
Rust 2024 reserves gen and ref as keywords.
If you see a compilation error about reserved keywords, rename the identifier.
For Askama templates specifically:
{% if let Some(x) = value %} // Correct
{% if let Some(ref x) = value %} // Wrong — `ref` is reserved
curl -sf returns wrong status code
When using curl with -f (fail on HTTP errors) and -w '%{http_code}', the -f flag causes the status code to be concatenated with the error output.
Fix: Use -s without -f, and handle errors with || true:
STATUS=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/health || true)
CI Pipeline
Pipeline stages
The CI pipeline has 4 stages: scan → promote → deploy → triage (see .gitlab-ci.yml).
-
scan: the security, docs, and lint gates — SAST, secret detection, dependency scanning,
cargo audit(change-gated to manifest/advisory files),ci-tests,cargo-deny,secrets-policy, the docs checks, and the scheduled pentest/perf jobs -
promote:
docker-promotebuilds and pushes service images (main/tag commits);sbom+releaseon tags -
deploy: Antora docs (review apps on MRs, Pages on main)
-
triage: the weekly scheduled
triage:stalejob (see Stale Work Policy)
Why CI doesn’t run the full battery
The full test battery (validate, integration, e2e, perf, security regression) runs in
the pre-push hook on every developer machine — CI deliberately does not duplicate it
(see the deployment guide's CI/CD Pipeline section). CI
does run a devstack-free subset: ci-tests executes cargo xtask validate
--skip-devstack --skip-docker (fmt, clippy, build, the ci nextest profile’s
unit-test pass) on MR pipelines touching Rust/Cargo/xtask paths, and the scheduled
pentest/perf-load/perf-stress/cluster-tests jobs exercise the promoted
images out-of-band. Anything devstack-bound ran in the pre-push battery, not CI.
GitLab API gotchas
Epic notes POST returns 404
Adding a comment to an epic via POST /groups/<id>/epics/<iid>/notes returns 404 for this group.
This is stable behavior (tied to the gitlab.com epic-vs-work-item migration state), not a transient bug or a permissions problem — issue notes (POST /projects/<id>/issues/<iid>/notes) work normally, and only epic notes 404.
Workaround: Append the comment text to the epic’s description instead — GET /groups/<id>/epics/<iid>, then PUT /groups/<id>/epics/<iid> with {"description": "<old>\n\n---\n\n<new>"}.
Closing the epic still works normally via PUT /groups/<id>/epics/<iid> with {"state_event": "close"}.
glab api -f field=@file sends the literal "@file"
glab api --method PUT … -f "description=@/tmp/file" does not read the file — it sends the literal string @/tmp/file as the field value, silently overwriting the target field with that string.
Fix: For large or multi-line PUT bodies (such as an epic/issue description), use curl with an explicit JSON content type:
curl -sS --request PUT \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
--header "Content-Type: application/json" \
--data @/tmp/body.json \
"https://gitlab.com/api/v4/groups/<id>/epics/<iid>" # body.json = {"description": "..."}
glab api --input file.json returns 415 on this endpoint (it does not set Content-Type: application/json).
Always re-fetch after a description PUT and assert the expected markers are present — a silent wrong-body write is otherwise invisible.