Make the shipped example envs ones that can actually start
Some checks failed
CI / test (push) Failing after 4m40s
CI / fixture (push) Failing after 9s

The krow-2 deploy failed on the ANTHROPIC_API_KEY guard, which is the guard
doing its job. Checking what an operator hits *after* fixing it turned up two
older faults in the files they are told to copy — both predating the Groq
switch, both fatal at boot.

HTTP_WRITE_TIMEOUT shipped as 30s in .env.example, .env.docker.example and the
compose default, while validateWriteTimeout refuses anything at or under the
deep tier's 2m deadline. `cp .env.docker.example .env && docker compose up`
could not start. Now 180s. krow-2 never saw this because someone had already
overridden it in that environment.

.env.docker.example carried no model block at all, so a production stack built
from it is refused for a missing MODEL_API_KEY. Added, with the Groq defaults
and the reasoning-effort note (most non-reasoning models reject the request
rather than ignoring the key).

Neither was subtle. Both survived because the examples were prose to every test
in this package: the validator and the file documenting it had no mechanical
connection, so tightening one silently invalidated the other. That connection
is now TestShippedExampleEnvActuallyBoots, which parses each example and runs
Load() on it under the APP_ENV the file itself declares — production for the
docker one, development for the root one, each internally consistent. Verified
by mutation: reverting the timeout, removing the key line, and restoring a
claude-* id each fail it with the message an operator would see.

Go does not treat these files as test inputs, so an example-only edit can be
served a stale pass from the test cache. Noted in the test; use -count=1.

Also documented the upgrade path in handover.md, including the one thing
startup validation cannot catch: renaming ANTHROPIC_API_KEY to MODEL_API_KEY
without replacing the value boots fine and 401s on every run.

gofmt clean, go vet clean, 15/15 packages pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
This commit is contained in:
2026-09-07 12:25:20 +05:30
parent 34fa58a6b9
commit bd9a8f91fc
5 changed files with 206 additions and 3 deletions

View File

@@ -16,7 +16,12 @@ LOG_LEVEL=info # debug | info | warn | error
HTTP_HOST=127.0.0.1 HTTP_HOST=127.0.0.1
HTTP_PORT=8080 HTTP_PORT=8080
HTTP_READ_TIMEOUT=15s HTTP_READ_TIMEOUT=15s
HTTP_WRITE_TIMEOUT=30s # 180s, not 30s. internal/config REFUSES TO START when this is below the deep
# tier's 2m agent deadline: the server would abort the response mid-run and the
# caller would see 502 from the proxy in front, a gateway error for something no
# gateway did. 30s shipped here for a long time and was the cause of exactly
# that incident. Anything at or under 2m0s is a container that will not boot.
HTTP_WRITE_TIMEOUT=180s
HTTP_IDLE_TIMEOUT=60s HTTP_IDLE_TIMEOUT=60s
HTTP_SHUTDOWN_TIMEOUT=10s HTTP_SHUTDOWN_TIMEOUT=10s
# Browser origins allowed to call this API cross-origin, comma-separated. # Browser origins allowed to call this API cross-origin, comma-separated.

View File

@@ -196,6 +196,31 @@ otherwise produce a service that boots cleanly and fails every agent run.
The default with nothing set is Groq. The default with nothing set is Groq.
### Upgrading a deployment that ran Claude
A running stack does not migrate itself, and the first thing it does after this
change is refuse to start:
```
ERROR fatal error="ANTHROPIC_API_KEY is set but is no longer read, and
MODEL_API_KEY is empty: the Anthropic path was removed..."
```
That is the guard working. Two edits to the deployment's env fix it:
1. `MODEL_API_KEY=<a Groq key>`
2. Delete `ANTHROPIC_API_KEY` from the environment entirely.
**Renaming the variable without replacing the value is the trap.** An
`sk-ant-...` under the name `MODEL_API_KEY` passes every startup check — the
process cannot tell one opaque string from another — and then fails every run
with `the model credentials were refused` and Groq's own text. Startup
validation catches the *shape* of a stale configuration, never a wrong secret.
`ANTHROPIC_API_KEY` is still passed through in `docker-compose.yml` on purpose:
a host that kept exporting it gets the loud failure above instead of a
container that boots with no credential and fails one run at a time.
```bash ```bash
# Groq (the default — base URL and ids below are what you get unset) # Groq (the default — base URL and ids below are what you get unset)
MODEL_BASE_URL=https://api.groq.com/openai/v1 MODEL_BASE_URL=https://api.groq.com/openai/v1

View File

@@ -0,0 +1,132 @@
package config
import (
"bufio"
"os"
"path/filepath"
"strings"
"testing"
)
// TestShippedExampleEnvActuallyBoots loads each example env exactly as an
// operator would and asserts the result passes validation.
//
// THIS TEST EXISTS BECAUSE BOTH EXAMPLES SHIPPED A CONFIGURATION THAT COULD NOT
// START. HTTP_WRITE_TIMEOUT was 30s in files an operator is told to copy, while
// validateWriteTimeout refuses anything at or under the deep tier's 2m
// deadline — so `cp .env.docker.example .env && docker compose up` failed at
// boot. Separately, .env.docker.example carried no model block at all, which in
// production is a second refusal for a missing MODEL_API_KEY.
//
// Neither was a subtle bug. Both survived because the examples were prose to
// every test in this package: the validator and the file documenting it had no
// mechanical connection, so tightening one silently invalidated the other.
// That connection is this test.
//
// CAVEAT: `go test` does not treat these files as inputs, so a run that changes
// ONLY an example env can be served a stale pass from the test cache. Verify
// example edits with `-count=1`. `make test` and CI run from a clean cache and
// are not affected.
func TestShippedExampleEnvActuallyBoots(t *testing.T) {
for _, tc := range []struct {
path string
// Values an operator must supply, standing in for the placeholders the
// file ships. Only credentials and hostnames belong here — anything
// else would be this test papering over a broken example.
operatorSupplies map[string]string
}{
{
path: filepath.Join("..", "..", "..", "infrastructure", ".env.docker.example"),
operatorSupplies: map[string]string{"MODEL_API_KEY": "gsk-operator-supplied"},
},
{
path: filepath.Join("..", "..", "..", ".env.example"),
operatorSupplies: map[string]string{"MODEL_API_KEY": "gsk-operator-supplied"},
},
} {
t.Run(filepath.Base(tc.path), func(t *testing.T) {
env, err := parseDotenv(tc.path)
if err != nil {
t.Fatalf("reading %s: %v", tc.path, err)
}
for k, v := range tc.operatorSupplies {
env[k] = v
}
// Each file is validated under the APP_ENV IT DECLARES, not under
// one this test imposes. The two examples describe different
// deployments and each is internally consistent: .env.docker.example
// is production with sslmode=require, .env.example is development
// with sslmode=disable. Forcing production onto the development file
// fails it on a setting that is correct for what it is.
if env["APP_ENV"] == "" {
t.Fatalf("%s declares no APP_ENV; every example must say what it is", tc.path)
}
os.Clearenv()
for k, v := range env {
t.Setenv(k, v)
}
cfg, err := Load()
if err != nil {
t.Fatalf("%s cannot start: %v\n\n"+
"An operator copying this file gets this error, not a running service. "+
"Fix the example, not this test.", tc.path, err)
}
// Load() succeeding is the assertion. These guard the two specific
// regressions above, so a future edit that reintroduces either one
// fails by name rather than as a generic validation error.
if cfg.HTTP.WriteTimeout <= DeepestAgentDeadline {
t.Errorf("HTTP_WRITE_TIMEOUT is %s, which does not exceed the deep tier's %s deadline",
cfg.HTTP.WriteTimeout, DeepestAgentDeadline)
}
for _, m := range []struct{ key, id string }{
{"MODEL_FAST", cfg.Model.Fast},
{"MODEL_BALANCED", cfg.Model.Balanced},
{"MODEL_DEEP", cfg.Model.Deep},
} {
if m.id == "" {
t.Errorf("%s resolved empty", m.key)
}
}
})
}
}
// parseDotenv reads the KEY=value lines an example file ships.
//
// Deliberately simple: it handles what these files actually contain — comments,
// blank lines, trailing `# ...` notes on a value, and optional quotes. It is
// not a general dotenv implementation, and an example needing one would be an
// example too clever for the operator who has to read it.
func parseDotenv(path string) (map[string]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
env := map[string]string{}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
line = strings.TrimPrefix(line, "export ")
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
// A trailing comment, but only when it is spaced off the value — a
// bare # inside a password is part of the password.
if i := strings.Index(value, " #"); i >= 0 {
value = value[:i]
}
value = strings.TrimSpace(value)
value = strings.Trim(value, `"'`)
env[key] = value
}
return env, scanner.Err()
}

View File

@@ -79,9 +79,48 @@ DATABASE_CONN_MAX_LIFETIME=30m
DATABASE_CONNECT_TIMEOUT=10s DATABASE_CONNECT_TIMEOUT=10s
DATABASE_STATEMENT_TIMEOUT=10s DATABASE_STATEMENT_TIMEOUT=10s
# ── Model gateway ───────────────────────────────────────────────────────────
# THIS BLOCK WAS MISSING and a deployment copying this file could not start:
# APP_ENV=production with no MODEL_API_KEY is refused, because the alternative
# is an API that accepts agent runs and fails every one of them at the gateway.
#
# One wire protocol: the openai chat-completions shape. Groq, Gemini,
# OpenRouter, Together, vLLM and a local Ollama all serve it, so switching
# vendors is a base URL and a model id, not a code change.
#
# Empty MODEL_PROVIDER means openai — the only implementation. Empty
# MODEL_BASE_URL means Groq.
MODEL_PROVIDER=openai
MODEL_BASE_URL=https://api.groq.com/openai/v1
# REQUIRED in production. There is no ANTHROPIC_API_KEY fallback: that variable
# is now REFUSED at startup if it is set while this one is empty, because
# silently authenticating to Groq with a key named for a vendor this service
# cannot call is a lie the next operator has to unpick. Rename it here, and
# replace the value — an Anthropic key boots fine and then fails every run with
# 401, which the startup check cannot catch and only the model call can.
MODEL_API_KEY=
# Model ids must be ones MODEL_BASE_URL actually serves. A leftover claude-*
# id is refused at startup by name and tier: nothing configured serves one, so
# every run on that tier would 400 at the gateway.
MODEL_FAST=llama-3.1-8b-instant
MODEL_BALANCED=llama-3.3-70b-versatile
MODEL_DEEP=llama-3.3-70b-versatile
# Off. Most non-reasoning models — the llama ids above included — reject the
# whole request rather than ignoring reasoning_effort. Turn it on only for a
# model documented to take it.
MODEL_REASONING_EFFORT=
# ── HTTP timeouts ─────────────────────────────────────────────────────────── # ── HTTP timeouts ───────────────────────────────────────────────────────────
HTTP_READ_TIMEOUT=15s HTTP_READ_TIMEOUT=15s
HTTP_WRITE_TIMEOUT=30s # 180s, not 30s. internal/config REFUSES TO START when this is below the deep
# tier's 2m agent deadline: the server would abort the response mid-run and the
# caller would see 502 from the proxy in front, a gateway error for something no
# gateway did. 30s shipped here for a long time and was the cause of exactly
# that incident. Anything at or under 2m0s is a container that will not boot.
HTTP_WRITE_TIMEOUT=180s
HTTP_IDLE_TIMEOUT=60s HTTP_IDLE_TIMEOUT=60s
HTTP_SHUTDOWN_TIMEOUT=10s HTTP_SHUTDOWN_TIMEOUT=10s

View File

@@ -121,7 +121,9 @@ services:
HTTP_HOST: 0.0.0.0 HTTP_HOST: 0.0.0.0
HTTP_PORT: "8080" HTTP_PORT: "8080"
HTTP_READ_TIMEOUT: ${HTTP_READ_TIMEOUT:-15s} HTTP_READ_TIMEOUT: ${HTTP_READ_TIMEOUT:-15s}
HTTP_WRITE_TIMEOUT: ${HTTP_WRITE_TIMEOUT:-30s} # Must exceed the deep tier's 2m agent deadline or config refuses to
# start — see .env.docker.example. The old 30s default could not boot.
HTTP_WRITE_TIMEOUT: ${HTTP_WRITE_TIMEOUT:-180s}
HTTP_IDLE_TIMEOUT: ${HTTP_IDLE_TIMEOUT:-60s} HTTP_IDLE_TIMEOUT: ${HTTP_IDLE_TIMEOUT:-60s}
HTTP_SHUTDOWN_TIMEOUT: ${HTTP_SHUTDOWN_TIMEOUT:-10s} HTTP_SHUTDOWN_TIMEOUT: ${HTTP_SHUTDOWN_TIMEOUT:-10s}
# Browser origins allowed to call this API. Empty means same-origin only, # Browser origins allowed to call this API. Empty means same-origin only,