Skip to content

Router: ship the Go router in the container image and remove the Python implementation #10358

Closed

Description

@rabeckett

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 urllib command to the Go binary's
    healthcheck subcommand;
  • 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 rename tests/fixtures/ to testdata/;
  • 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/*.go and go.mod move up to containers/router/, one flat main package next to
    openapi.yaml, routing/, schemas/, examples/ and trust/.
  • The module path becomes github.com/github/gh-aw-firewall/containers/router, matching its
    directory.
  • tests/fixtures/ becomes testdata/, which the Go tool ignores when building packages.
  • test-router.yml runs from containers/router, and the README builds with
    go build -o gh-aw-router ..

Image

  • A multi-stage Dockerfile:

    • the builder is the official golang Alpine image, pinned by digest at go.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 $BUILDPLATFORM and cross-compiles a static binary (CGO_ENABLED=0,
      -trimpath), so multi-platform builds need no emulation;
    • the runtime stage is FROM scratch.
  • Every check runs in the builder, because the runtime has no shell. The build fails unless
    OPENAPI_SHA256 matches openapi.yaml, and unless the optional VERSION argument
    matches the version constant in main.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 in containers/router/trust/ (fix: mitigate compromised eMudhra CA trust across AWF #10343), as the Python image did, so
      scripts/security/ca-trust.test.ts still holds.

    Directories and the binary are 0555; data files are 0444.

  • These stay the same: the labels, USER 10001:10001, GH_AW_ROUTER_ROUTING_TABLES=/routing,
    EXPOSE 8737, STOPSIGNAL SIGTERM, and the serve command.

  • The image sets GOMAXPROCS=2 and GOMEMLIMIT=384MiB, to fit AWF's 512 MiB limit.

  • .dockerignore is an allowlist: the Dockerfile, openapi.yaml, the non-test Go files,
    go.mod, routing/cost-*.json and trust/.

  • OPENAPI_SHA256 on its own is still enough for a build, so AWF's --build-local and
    supply-chain-scan.yml are unchanged.

AWF

  • In src/services/router-service.ts, ROUTER_HEALTHCHECK_TEST becomes 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, so ROUTER_PORT is no longer needed.
  • src/compose-generator.test.ts expects the new command.
  • tests/integration/model-routing.test.ts used to default ROUTER_IMAGE to the legacy
    Python image. It now builds the router from containers/router when ROUTER_IMAGE is
    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.lock and requirements.lock;
    • the Python .gitignore entries.
  • test-router.yml:
    • drops the Python checks, advisories (pip-audit) and differential jobs;
    • keeps the Go job, which already runs govulncheck on Linux and weekly;
    • the new container job builds the image and runs the container tests.
  • release.yml build-router loses the QEMU step. Its build arguments and label checks are
    unchanged.
  • dependabot.yml:
    • removes the router's uv entry and the python ignore;
    • keeps the router's Docker entry, which now tracks the golang builder;
    • adds no gomod entry, because the module has no dependencies.
  • The router README is rewritten for Go: build, CLI, healthcheck, serving limits, the log
    format, the container, and tests. docs/releasing.md names 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.js validates 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/amd64 and linux/arm64 without emulation.
  • The image keeps the same user, port, env, tables path, CMD, STOPSIGNAL and labels,
    and declares no HEALTHCHECK.
  • 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_SHA256 works. A wrong OPENAPI_SHA256 or a mismatched
    VERSION fails 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.md describe the Go router.

Validation

  1. test-router. CI runs the Go job on Linux and Windows and the container job.
  2. AWF. Run the compose generator's unit tests. test-model-routing runs only on main,
    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 its cleanup() prunes stopped containers and unused networks. Run
    test-model-routing once on main after merging.
  3. 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.
  4. Release dry run. A two-platform buildx build with build-router's arguments.
  5. Sandbox. After the AWF release that ships this change, run the routing task set in
    the sandbox, pinned to that release. Compare the firewall.model_routing records in
    usage/aw_session.jsonl against 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    cidependenciesPull requests that update a dependency filedockerPull requests that update docker codeenhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions