Repository navigation
Router: ship the Go router in the container image and remove the Python implementation #10358
Description
Part of github/gh-aw-firewall#10261
(step 5 of the plan; the earlier step that let AWF inherit the image's healthcheck was
dropped, see below).
Summary
The Go module in containers/router/go/ now implements the router's complete CLI and HTTP
server, plus a table-independent healthcheck command. This step makes it the router:
- build the router image from the Go binary on
scratch; - switch AWF's router healthcheck from a Python
urllibcommand to the Go binary's
healthchecksubcommand; - port the remaining Python-only tests to Go;
- delete the Python implementation, its packaging, CI jobs and Dependabot entries;
- move the Go module up to
containers/router/and renametests/fixtures/totestdata/; - bump the router to 0.2.0.
The HTTP API, the CLI, the image name, the port, the environment variables and the tables
path stay the same. The only API change is that openapi.yaml no longer documents the 504
request_timeout response: Go's net/http server closes a connection that passes its
read or write deadline instead of answering it.
AWF keeps owning the healthcheck command in its compose config, like every other AWF
service, and the image declares no HEALTHCHECK, like every other AWF image. An earlier
proposal (#10346) let AWF inherit the image's healthcheck so that one AWF release could run
either router image. It was dropped for consistency, since the router only runs inside AWF.
The cost is that an AWF release can't be paired with a router image of the other runtime,
which shapes the rollback section below.
Exact parity isn't a goal
The Go router doesn't reproduce Python byte for byte. It uses Go's standard library
(net/http, encoding/json, log/slog, Go's Unicode tables) where the differences don't
matter to callers. The parity fixtures' README lists every such decision. What stays
identical is what callers depend on: the routing results, the classifier prompts, the
response shapes, the HTTP statuses and error codes, and the CLI exit statuses.
Changes
Layout
With Python gone, the Go module no longer needs its own subdirectory:
go/*.goandgo.modmove up tocontainers/router/, one flatmainpackage next to
openapi.yaml,routing/,schemas/,examples/andtrust/.- The module path becomes
github.com/github/gh-aw-firewall/containers/router, matching its
directory. tests/fixtures/becomestestdata/, which the Go tool ignores when building packages.test-router.ymlruns fromcontainers/router, and the README builds with
go build -o gh-aw-router ..
Image
-
A multi-stage
Dockerfile:- the builder is the official
golangAlpine image, pinned by digest atgo.mod's
toolchain version (1.27.3, the same pin fix: mitigate compromised eMudhra CA trust across AWF #10343 gave AWF's other Go builds); - it runs on
$BUILDPLATFORMand cross-compiles a static binary (CGO_ENABLED=0,
-trimpath), so multi-platform builds need no emulation; - the runtime stage is
FROM scratch.
- the builder is the official
-
Every check runs in the builder, because the runtime has no shell. The build fails unless
OPENAPI_SHA256matchesopenapi.yaml, and unless the optionalVERSIONargument
matches theversionconstant inmain.go. -
The image holds the following and nothing else:
- the binary at
/usr/local/bin/gh-aw-router; - the six tables under
/routing; /app/openapi.yaml;- a CA bundle, for HTTPS OTLP export later. The builder filters it through AWF's CA
distrust policy incontainers/router/trust/(fix: mitigate compromised eMudhra CA trust across AWF #10343), as the Python image did, so
scripts/security/ca-trust.test.tsstill holds.
Directories and the binary are 0555; data files are 0444.
- the binary at
-
These stay the same: the labels,
USER 10001:10001,GH_AW_ROUTER_ROUTING_TABLES=/routing,
EXPOSE 8737,STOPSIGNAL SIGTERM, and theservecommand. -
The image sets
GOMAXPROCS=2andGOMEMLIMIT=384MiB, to fit AWF's 512 MiB limit. -
.dockerignoreis an allowlist: theDockerfile,openapi.yaml, the non-test Go files,
go.mod,routing/cost-*.jsonandtrust/. -
OPENAPI_SHA256on its own is still enough for a build, so AWF's--build-localand
supply-chain-scan.ymlare unchanged.
AWF
- In
src/services/router-service.ts,ROUTER_HEALTHCHECK_TESTbecomes the exec-form
CMD /usr/local/bin/gh-aw-router healthcheck. AWF's timings are unchanged. That command
needs no tables and never uses a proxy, soROUTER_PORTis no longer needed. src/compose-generator.test.tsexpects the new command.tests/integration/model-routing.test.tsused to defaultROUTER_IMAGEto the legacy
Python image. It now builds the router fromcontainers/routerwhenROUTER_IMAGEis
unset, so it can't pair the Go command with a Python image.
Tests
The Python-only tests become Go tests in the flat package:
| Python test | Go test |
|---|---|
test_container.py |
container_test.go, which runs when GH_AW_ROUTER_TEST_IMAGE names a built image. It checks the image config: user, port, entrypoint, no HEALTHCHECK, and labels. It runs the image hardened as AWF does, plus a read-only root filesystem, waits for healthcheck, and checks that there's no shell, that the router exits 0 within the 1 s that AWF's teardown (docker compose down -t 1) allows, and that the logs are JSON. Then it replays the routing-contract corpus against the image, mounting a single table or a subset of profiles as each case needs. |
test_http.py corpus replay |
corpus_test.go replays the routing-contract corpus through the HTTP handler. It also checks that case IDs are unique, that every endpoint has a case, and that the explicit-* cases cover all six profiles. A corpus file that is neither a list of cases nor a case's response_file fails the tests instead of being skipped. |
test_openapi.py |
openapi_test.go checks that openapi.yaml documents exactly the served paths and methods and exactly the error codes, and that every $ref resolves. Without a YAML dependency, it reads the file by indentation. |
test_table_schema.py |
schema_test.go pins the published schema's hash and checks its enums and patterns against the Go types. The bundled and contract tables must match the patterns. The loader's own tests already cover invalid tables. |
test_package.py |
The existing version-consistency test. |
test_examples.py |
docs_test.go posts the README's curl examples to a server with the bundled tables, checks that each ranks every offered choice, and checks the README's classifier output and route response examples against the route example and its actual response. The CLI tests already run both examples. |
| Documentation link check | docs_test.go |
The contract-archive exporter (tests/contract_corpus.py) is dropped rather than ported.
The routing-contract corpus is the source of truth, and the API proxy's tests copy the cases
they need from it. The Python tests that only exercised Python packaging or the 504 deadline
have no Go counterpart. Nor does the jsonschema validation of corpus requests and example
responses against openapi.yaml's schemas, because Go's standard library has no JSON Schema
validator. The corpus replay pins the runtime behavior; keeping the OpenAPI schemas in step
with it is left to review, as the README asks.
The shutdown tests now signal once the server holds the test request, rather than after a
fixed 100 ms sleep, which let the signal arrive before the request under load.
The parity fixtures (testdata/parity/, formerly tests/fixtures/parity/) stay as data for
the Go replay tests.
- The Go tests' known-differences maps are folded into the expectations, so the fixtures
now record Go's behavior. - From now on the expectations are edited by hand, only in a reviewed PR.
unicode/is deleted.- The fixture README is rewritten to match, and its decisions table stays as the record of
where Go differs from Python.
Removal, CI and docs
- Deleted:
src/gh_aw_router/;- the Python tests and helpers;
pyproject.toml,uv.lockandrequirements.lock;- the Python
.gitignoreentries.
test-router.yml:- drops the Python
checks,advisories(pip-audit) anddifferentialjobs; - keeps the Go job, which already runs
govulncheckon Linux and weekly; - the new
containerjob builds the image and runs the container tests.
- drops the Python
release.ymlbuild-routerloses the QEMU step. Its build arguments and label checks are
unchanged.dependabot.yml:- removes the router's
uventry and thepythonignore; - keeps the router's Docker entry, which now tracks the
golangbuilder; - adds no
gomodentry, because the module has no dependencies.
- removes the router's
- The router README is rewritten for Go: build, CLI,
healthcheck, serving limits, the log
format, the container, and tests.docs/releasing.mdnames the version's new location.
Out of scope
- OpenTelemetry (the next step).
- Bumping gh-aw's router pin and its default AWF version.
- The API proxy doesn't compare the router version from
/capabilities
(routing-runtime.jsvalidates only the name). That predates this change; a follow-up can
decide whether it should.
Acceptance criteria
- The router image runs the static Go binary with no Python and no shell. It builds for
linux/amd64andlinux/arm64without emulation. - The image keeps the same user, port, env, tables path,
CMD,STOPSIGNALand labels,
and declares noHEALTHCHECK. - AWF's compose healthcheck runs
/usr/local/bin/gh-aw-router healthcheck, and the router
becomes healthy under AWF's hardened settings. - A build with only
OPENAPI_SHA256works. A wrongOPENAPI_SHA256or a mismatched
VERSIONfails the build. - The router version is 0.2.0 everywhere, and the Go version test keeps the copies in step.
- The container, corpus, OpenAPI, schema, version and examples tests pass in Go, and the
parity fixtures record Go's behavior. - No router Python source, tests, packaging, lock files, CI jobs or Dependabot entries
remain. - The router README, the fixture README and
docs/releasing.mddescribe the Go router.
Validation
test-router. CI runs the Go job on Linux and Windows and the container job.- AWF. Run the compose generator's unit tests.
test-model-routingruns only onmain,
so the PR gets no routed end-to-end run: before merging, run the model-routing
integration test locally with the router built from source, on a disposable Docker host,
because itscleanup()prunes stopped containers and unused networks. Run
test-model-routingonce onmainafter merging. - Supply-chain scan. The PR's blocking Grype gate rebuilds the router image and fails on
High, which now covers the Go standard library compiled into the binary. - Release dry run. A two-platform
buildxbuild withbuild-router's arguments. - Sandbox. After the AWF release that ships this change, run the routing task set in
the sandbox, pinned to that release. Compare thefirewall.model_routingrecords in
usage/aw_session.jsonlagainst a run on the previous release. Capabilities, served
profiles, and error and no-route rates should match. Selections should match whenever the
recorded classification matches. The classifier is an LLM call, so the Go tests remain
the deterministic evidence. Bump gh-aw's default AWF version only after this passes.
Rollback
AWF and its router image ship together, so roll back both together:
- pin the previous AWF release, which brings its own Python router image and healthcheck;
- or revert this PR before later Go-only changes land on it.
Activity