diff --git a/.agents/skills/check-docs/SKILL.md b/.agents/skills/check-docs/SKILL.md index 67da2c4..fbdc767 100644 --- a/.agents/skills/check-docs/SKILL.md +++ b/.agents/skills/check-docs/SKILL.md @@ -5,12 +5,17 @@ description: Use before a release, or after changing a command, a flag, a config # Check the documentation -Documentation in this repository has four homes, and they go stale in +Documentation in this repository has five homes, and they go stale in different ways: - `README.md` -- the entry point. What Doppel is, what it does and does not do yet, how to run it. - `docs/` -- the mkdocs site, in three sections (below). +- `DOCKERHUB.md` -- the Docker Hub overview, pushed by the release workflow. + Written for someone who has already chosen the image, so it opens with + `docker run` and never mentions building from source. Check it against + `docs/usage/docker.md`, which covers the same ground at length: a fact that + changed in one and not the other is the failure mode here. - `AGENTS.md` -- addressed to agents. Holds rules and pointers, never a second copy of anything in `docs/`. - Doc comments in the code -- where the reasoning lives. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c698135..86b6156 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -245,6 +245,46 @@ jobs: # here would be a fixed string and would give both architectures the # same binary. + # Docker Hub's page is the one place an image is described to someone who + # never opens the repository, and it stays whatever was last pushed to it + # -- including empty. DOCKERHUB.md is written for that page rather than + # being the README, which opens with an installer the image's reader has + # no use for. + # + # Skipped for a pre-release, on the same reasoning as the moving tags: the + # page would tell everyone to pull an rc. + - name: Prepare the overview for the version being released + if: ${{ !contains(github.event.inputs.tag || github.ref_name, '-') }} + env: + TAG: ${{ github.event.inputs.tag || github.ref_name }} + run: | + set -euo pipefail + version="${TAG#v}" + # Only the copy-paste commands, which name the image. The tags table + # uses bare `1.2.3-alpine` to show the shape and is left alone. + sed -i -E "s|loremdev/doppel:[0-9][^[:space:]]*-alpine|loremdev/doppel:${version}-alpine|g" \ + DOCKERHUB.md + # Drop the file's own note to maintainers. Docker Hub renders + # markdown, and every overview on the hub that shows an image uses + # `![]()` rather than a tag -- so whether it strips HTML or escapes + # it is not something to find out on a live page. Deleting the + # comment makes the question moot. The range form assumes the + # comment does not wrap anything meant to be published, which is + # what the one at the top of DOCKERHUB.md says it is for. + sed -i '//d' DOCKERHUB.md + head -4 DOCKERHUB.md + grep -n 'loremdev/doppel:' DOCKERHUB.md + + - name: Push the overview to Docker Hub + if: ${{ !contains(github.event.inputs.tag || github.ref_name, '-') }} + uses: peter-evans/dockerhub-description@v5 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + repository: loremdev/doppel + readme-filepath: ./DOCKERHUB.md + short-description: "CLI-driven HTTP reverse proxy that forwards, degrades, or mocks a backend" + publish: name: Publish the release needs: build diff --git a/DOCKERHUB.md b/DOCKERHUB.md new file mode 100644 index 0000000..45127aa --- /dev/null +++ b/DOCKERHUB.md @@ -0,0 +1,189 @@ + + +![Doppel](https://raw.githubusercontent.com/lorem-dev/doppel/main/docs/assets/icon-128.png) + +# Doppel + +A CLI-driven HTTP reverse proxy. The name is from "doppelganger": it stands in +front of, or in place of, a real backend, so clients of that backend can be +developed and tested against a realistic, deliberately degraded, or entirely +absent upstream. It forwards traffic to a configured upstream, injects +controlled latency and loss, and replaces selected endpoints with templated +mock responses. + +- **Documentation:** +- **Source:** +- **Licence:** Apache-2.0 + +## Tags + +Published for `linux/amd64` and `linux/arm64`. + +| Tag | Moves? | Points at | +|---|---|---| +| `1.2.3-alpine` | no | exactly that release | +| `1.2-alpine` | yes | the newest patch of 1.2 | +| `1-alpine` | yes | the newest minor of 1 | + +Every tag names its base, the convention the images this one sits beside +use (`postgres:17.6-alpine`, `node:22-alpine`). That leaves the unsuffixed +names free, so a second base could be published later without renaming +anything anyone has already pinned. + +There is deliberately **no `latest`**. A tag that moves is one an unpinned +deployment follows into a release nobody reviewed. Pin the full version in +anything that matters. + +A pre-release (`1.2.3-rc.1`) is published as `1.2.3-rc.1-alpine` only. It never +takes `1.2-alpine` or `1-alpine` with it. + +## Quick start + +The image ships no configuration. That is deliberate: a default that quietly +proxied somewhere unintended would be worse than a container that refuses to +start. + +```bash +docker run --rm \ + -p 8080:8080 -p 8081:8081 \ + -v "$PWD/main.yaml:/etc/doppel/main.yaml:ro" \ + -v doppel-templates:/var/lib/doppel/templates \ + loremdev/doppel:0.1.0-alpine +``` + +`8080` carries proxied traffic, `8081` the admin API. + +A minimal `main.yaml`: + +```yaml +server: + host: "0.0.0.0" + port: 8080 + +admin: + host: "0.0.0.0" + port: 8081 + tokens: [] + access: {} + upload: + limit: 1Mi + +proxies: + - name: backend + type: http + url: "https://example.com/" + resolve: + type: default +``` + +Both listeners must bind `0.0.0.0` rather than `127.0.0.1`, or nothing outside +the container can reach them. + +## The templates volume is not optional + +Templates uploaded through the admin API are written to disk at upload and read +from disk per request. Without a volume they live in the container's writable +layer and go with it -- `docker stop`, `docker run` again, and every uploaded +template is gone. Nothing reports this: the mock that named the file answers +with a render error on the next request, which reads like a broken template +rather than a missing volume. + +```yaml +templates: + dir: /var/lib/doppel/templates +``` + +A relative `./templates` also works: the image's working directory is +`/var/lib/doppel`, so it resolves to the same place. + +## Compose + +```yaml +services: + doppel: + image: loremdev/doppel:0.1.0-alpine + ports: + - "8080:8080" + - "8081:8081" + volumes: + - ./main.yaml:/etc/doppel/main.yaml:ro + - doppel-templates:/var/lib/doppel/templates + environment: + DOPPEL_ADMIN_TOKENS: '{"ci":{"token":"...","group":"admin"}}' + healthcheck: + test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8081/status"] + interval: 5s + timeout: 3s + start_period: 5s + retries: 5 + +volumes: + doppel-templates: +``` + +`/status` needs no token by default and answers only once the runtime is +compiled and both listeners are bound. `-f` matters: without it `curl` exits 0 +on a 500 and the check passes for answering at all rather than for answering +correctly. + +## Tokens + +`DOPPEL_ADMIN_TOKENS` keeps secrets out of the configuration file that gets +mounted in: + +```bash +docker run --rm \ + -e DOPPEL_ADMIN_TOKENS='{"ci":{"token":"...","group":"admin"}}' \ + ... +``` + +## Reloading + +`doppel config reload` talks to a control socket inside the container. Two ways +in: + +```bash +# Through the admin API, if it is enabled and you hold a token. +curl -X POST -H "X-Proxy-Authorization: Bearer $TOKEN" \ + http://localhost:8081/api/v1/config/reload + +# Or the same binary, inside the container. +docker exec doppel config reload --socket /tmp/doppel.sock +``` + +Editing a bind-mounted `main.yaml` on the host changes the file the container +reads, so a reload picks it up without restarting anything. + +## What the image contains + +Alpine, `ca-certificates`, `tini`, and one statically linked binary. It runs as +a non-root user, so a configuration asking for a port below 1024 fails at +bind -- the right answer in a container; publish a low port on the host +instead. + +`ca-certificates` is not optional: without a trust store a proxy cannot verify +an `https` upstream, and every request fails with `UPSTREAM_ERROR`. + +Doppel runs under `tini`, so `docker stop` delivers `SIGTERM` and the graceful +drain runs -- in-flight requests finish, then the process exits. + +## More + +Full documentation, including fault injection, mocks, the admin API, PostgreSQL +storage and observability, is at . The +Docker page there covers this ground in more detail: +. diff --git a/docs/assets/icon-128.png b/docs/assets/icon-128.png new file mode 100644 index 0000000..70ab06a Binary files /dev/null and b/docs/assets/icon-128.png differ