Skip to content

Deploy the test-server stack, and keep it current with Watchtower #30

Description

@yschimke

test-server builds and publishes to ghcr.io/yschimke/okhttp-testbed/test-server on every push to main (#28). Nothing runs it. Until something does, the suites can only reach it through Testcontainers, and the two things a hosted instance is for — a real network path, and an endpoint the Android matrix can reach without Docker on the device — stay out of reach.

This is the deployment, plus the part that keeps a deployment from going stale: Watchtower watching the published tag and restarting the container when the digest moves.

The host

Anything that runs Docker and has a name. A 1 vCPU / 1 GB VM is generous — the server is a static Go binary with no state and no disk use beyond logs.

Requirements, in order of how much they constrain the choice:

  1. A DNS name. Needed for a publicly trusted certificate, and for the suites to have something to configure other than an IP.
  2. Inbound 443. See below.
  3. Inbound on the remaining listener ports (or a decision to publish only some of them).
  4. IPv6, ideally — Happy Eyeballs against real dual-stack and single-stack names #21 wants a dual-stack name, and GitHub-hosted runners having no IPv6 is already the limiting factor there. A v6-reachable instance doesn't fix the runner, but it means the v6 half is testable from anywhere else.

Ports

test-server/README.md covers this in full; the short version is that a non-standard port changes nothing about the results — every absolute URL the server emits is built from the Host the request arrived with, and the CI smoke test runs the image on non-default ports to keep that true.

The exception worth spending a real port on: 443 is the only port that tests the internet. Interception middleboxes, CDN behaviour and captive portals mostly act on 443, so an instance on 8443 is not being handled the way a user's traffic is. Put https there; the per-version and hostile ports can go wherever.

Listener Container Suggested published Notes
https 8443 443 the one that matters
http 8080 80 plain HTTP, and the cleartext-policy cases
raw 8081 8081 request-head echo; not an HTTP server
tls10tls13 8410–8413 8410–8413 badssl-style, non-standard by design

Certificates, and the one real conflict

The image has two modes and they are mutually exclusive today:

  • Generated CA (default) — mints its own CA at startup, serves it at /ca.pem. This is what lets a test assert a chain is accepted.
  • Supplied certificate (TLS_CERT_FILE, TLS_KEY_FILE) — a real, publicly trusted chain. /ca.pem 404s.

A hosted instance wants both, for different suites: a publicly trusted chain exercises the platform's own trust anchors (which is the whole point of a deployment over a container), while the fixture CA is what the positive-chain assertions need. Both currently apply to every TLS listener at once.

Three ways out, in preference order:

  1. Per-listener certificateshttps gets the real chain, the per-version ports keep the generated one. A small change to tlsServer, and the honest fix.
  2. Two instances — one with TLS_CERT_FILE, one without, on different ports or names. No code change; two things to keep running.
  3. Generated CA only, for now — the deployment adds a real network path but not real trust anchors. Fine as a first step, and worth being explicit that it is one.

Whichever, the certificate has to renew, so a deployment with a real chain needs an ACME client writing to a volume the container reads, and a restart or reload on renewal. Caddy is already in the compose stack and can do the ACME half.

Watchtower

Watchtower polls the registry, compares digests, and recreates a container when its tag moves. Since test-server:latest is republished on every push to main, that closes the loop: merge, and the deployment follows within the poll interval.

Use the maintained fork, nicholas-fedor/watchtower — published as nickfedor/watchtower on Docker Hub and ghcr.io/nicholas-fedor/watchtower, currently 1.20.3 and actively released. The original containrrr/watchtower has been quiet since 1.7.1. The fork keeps the same com.centurylinklabs.watchtower.* label namespace, so configuration and scoping carry over unchanged.

It must be scoped to test-server and nothing else. httpbin and caddy are pinned deliberately — a moved image is breakage the daily run should catch, not absorb, which is the same reason mockserver is pinned. Watchtower updating those would quietly defeat that. Scope it with WATCHTOWER_LABEL_ENABLE and label only the one service:

  test-server:
    image: ghcr.io/yschimke/okhttp-testbed/test-server:latest
    labels:
      com.centurylinklabs.watchtower.enable: "true"

  watchtower:
    image: nickfedor/watchtower:1.20.3
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      WATCHTOWER_LABEL_ENABLE: "true"      # only labelled containers
      WATCHTOWER_CLEANUP: "true"           # don't accumulate old layers on a small disk
      WATCHTOWER_POLL_INTERVAL: "300"

Things worth knowing before wiring it up:

  • It needs the Docker socket, which is root-equivalent on the host. That is the real cost of this convenience. Keep the host doing nothing else, and don't expose Watchtower's own HTTP API.
  • The image is public, so no registry credentials are involved. Don't add any.
  • Pin Watchtower's own tag. An auto-updater that auto-updates itself is the one container where a bad release takes the recovery mechanism with it.
  • Rollback is manual. If latest ships something broken, the fix is to pin the service to the previous commit tag (every build publishes :<sha> as well) and restart. Worth writing down next to the deployment rather than discovering under pressure.
  • The alternative to all of this is a webhook from the publish job. Watchtower is less to build and has no inbound attack surface; a webhook is faster and needs no Docker socket. Watchtower first.

Pointing the suites at it

Once it exists, the suites need to find it, and to cope with it being down:

  • A single configuration point — TESTBED_SERVER_URL or a Gradle property — defaulting to unset, so a developer with no deployment still runs the Testcontainers path.
  • An Endpoint and Probe entry (Endpoint reachability preflight, and endpoint availability on the status page #7) so a suite skips with a reason when the deployment is unreachable, instead of reporting as OkHttp failing. This is exactly the case that machinery exists for, and our own server is no more entitled to a false red than anyone else's.
  • The Android matrix is the strongest argument for the whole thing: a device or emulator can reach a hosted URL directly, where today it needs adb reverse and a fixture process on the host (see run-ech-test.sh).

Acceptance

  • Host provisioned, with a DNS name and 443 inbound.
  • Compose stack running, restart: unless-stopped, surviving a reboot.
  • GET /info over the deployed name reports the expected listeners and the base URL the client sees.
  • GET /tls reports the negotiated handshake and a populated offered block.
  • Each of tls10tls13 negotiates its version and refuses the others.
  • Certificate story decided and documented — which of the three options above, and why.
  • Watchtower (the maintained fork, pinned) scoped by label, verified: push to main, confirm the container is recreated within the poll interval and /health recovers.
  • Rollback documented: pin to :<sha>, restart.
  • Deployment URL wired into the suites behind a property, with a preflight probe (Endpoint reachability preflight, and endpoint availability on the status page #7).
  • Something notices when it is down that isn't a failing test run.

Follows #28. Related: #8 (self-hosted fixtures), #7 (preflight), #21 (IPv6).

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions