feat: nested proxy: config, gateway hardening, and end-to-end smoke tests - #84
Merged
Conversation
Introduce a `proxy:` block in app.yml that consolidates the gateway's
forwarding behaviour and protects against three real production
failure modes the previous code was exposed to:
proxy:
host: www.local.host # was top-level proxy_host (still honored)
upstream_timeout: 30s # NEW — bound the entire upstream round-trip
max_request_body_size: 10MB # NEW — opt-in cap, 413 before dialing upstream
Why each knob:
- `upstream_timeout`: the previous proxy used `http.Client{}` per request
with no timeout. A single hung upstream leaked a goroutine and an FD
forever. Default 30s, configurable per deployment.
- `max_request_body_size`: previously unbounded. A client sending a
500MB body buffered ~500MB of heap before the gateway noticed.
- `host`: was read via `os.Getenv("PROXY_HOST")` per request. Captured
once at Generator construction so config + runtime agree on one
source of truth.
Also: stream upstream responses with `io.Copy` instead of
`io.ReadAll` + `Writer.Write` — kills the per-request memory spike
proportional to response body size.
Backwards compatibility: top-level `proxy_host:` still works. If both
`proxy.host` and `proxy_host:` are set, `proxy.host` wins so operators
can stage the migration. New apps generated by `altair new` use the
nested block.
Cleanup: drop `core/migrator.go` and `core/provider.go` — the plugin
registry refactor superseded `Migrator`, `MigrationProvider`,
`MigrationProviderDispatcher`, and `PluginProviderDispatcher`. Zero
production callers remained.
Also fix env.sample's broken `DATABASE_USERNAME=root` (MySQL 5.7's
entrypoint refuses MYSQL_USER=root and crash-loops the container).
Test coverage: every new behaviour is pinned by a test before the
implementation lands per the TDD rule in CLAUDE.md. Router coverage
held at 90.1%.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a build-tagged smoke suite that drives the actual altair binary
end-to-end: builds it, runs `altair new` to scaffold, spawns it as a
subprocess, exchanges oauth tokens via the basic-auth-gated plugin
admin endpoint, and asserts forwarding + auth + the new proxy options
behave under a real HTTP roundtrip — not just at the function level.
Implements the spec at
docs/superpowers/specs/2026-04-23-altair-smoke-test-design.md.
Coverage matrix (10 subtests, ~8s wall time):
Phase A — gateway-only (no MySQL):
T1 forwarding_unauthed method/path/X-Request-Id propagated
T2' proxy_host_injected proxy.host arrives as upstream Host
T3' body_size_cap_rejects 413 before dialing upstream
T4' upstream_timeout_fires 502 within ~300ms vs 3s upstream
T5' no_body_cap_default 4KB POST round-trips when cap=0
Phase B — oauth + MySQL:
T2 oauth_happy_path bearer + scope -> upstream reached
T3 oauth_missing_token 4xx, no upstream hit
T4 oauth_invalid_token 4xx
T5 oauth_wrong_scope 4xx (scope mismatch)
T6 oauth_body_and_headers POST round-trip with header + body
Architecture:
- e2e/harness/{ports,upstream,harness,oauth}.go — subprocess
orchestrator, in-process echo upstream, free-port allocator, oauth
application seeder. All `//go:build e2e` tagged.
- e2e/{smoke,oauth_smoke}_test.go — 10 subtests; each spawns its own
altair instance so config-level differences (timeout, body cap)
don't require a single bloated harness.
- Makefile: `make smoke` opt-in, `make test` excludes /e2e.
- CI: new `smoke` job in general.yml with `services: mysql:5.7`. No
docker-in-docker; native runner-side service.
The mock upstream honors r.Context() during sleeps so a gateway-side
timeout cancellation drains the goroutine immediately — without this,
httptest.Server.Close() blocked the test teardown for the full sleep
duration (3s -> 0.5s).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI's golangci-lint uses misspell with US locale. Three doc comments slipped through with the British spelling — the linter is stricter than CLAUDE.md noted (US locale per repo conventions). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
proxy:block inapp.ymlconsolidates host + adds two production-stability knobs (upstream_timeout,max_request_body_size). Backwards compatible with the legacy top-levelproxy_host:.io.Copy(no more proportional heap spike).smokeCI job ingeneral.yml.core/migrator.go+core/provider.go(plugin registry refactor superseded them; zero callers remained).env.samplethat crash-looped the docker-compose MySQL container (MYSQL_USER=rootis rejected by MySQL 5.7).What's new in
app.ymlIf both
proxy.hostandproxy_host:are set, the nested form wins so operators can stage migrations without an outage.Why each gateway change
http.Client{}per request, no timeout — leaked goroutines and FDs forever*http.ClientwithTimeout = proxy.upstream_timeout(default 30s)io.ReadAllofc.Request.Body; ~500MB heap on a 500MB POSThttp.MaxBytesReaderwith typed*http.MaxBytesError→ 413 before dialing upstreamio.ReadAll(proxyRes.Body)thenWriter.Write— full body in heapio.Copy(c.Writer, proxyRes.Body)— streamedos.Getenv("PROXY_HOST")every requestNewGeneratorfromappConfig.ProxyHost()Smoke test matrix
10 subtests, ~8s wall time. Each spawns its own altair subprocess with a per-test config (
WithUpstreamTimeout(300ms)for the timeout test,WithMaxRequestBodySize(16B)for the cap test, etc.) so behaviours don't have to share an instance.Phase A — gateway only (no MySQL):
X-Request-Idarrive at upstreamproxy.hostarrives as upstreamHostheaderPhase B — oauth + MySQL:
CI
New
smokejob in.github/workflows/general.ymlwithservices: mysql:5.7.needs: verifyso we don't burn CI minutes on broken PRs. Build-tagged via//go:build e2e, somake test(the unit suite + Coveralls path) is unchanged.Test plan
verifygreenlintgreentest(Coveralls) greensmokegreen — proves binary scaffolds, boots, forwards, rejects oversized bodies, fires timeouts, issues + validates oauth tokensgovulncheck/codeql/trivygreencp env.sample .env && docker compose --env-file .env up -dthenmake smoke→ 10 PASS🤖 Generated with Claude Code