check / check (push) Failing after 2s
auto is a format in the /v1/image/ path, an encrypted URL's token and the generator page. Once the signature or token is checked, pixa chooses AVIF when Accept names image/avif, else WebP when it names image/webp, else JPEG when the most specific of image/jpeg, image/* and */* allows it or Accept is absent. q=0 refuses a format; a header allowing none of the three answers 406, one that does not parse 400. The signature and token cover auto itself; the cache key and ETag use the chosen format. Answers from then on carry Vary: Accept. Model: opus-5-5
683 lines
47 KiB
Markdown
683 lines
47 KiB
Markdown
# Workflow
|
|
|
|
- branch per issue from `next`
|
|
- do the work in Next Step
|
|
- move Next Step to the top of Completed Steps
|
|
- `TODO.md` merges with git's union merge (`.gitattributes`), which never
|
|
reports a conflict: read the merged entries after every merge or rebase
|
|
- move the top item of Future Steps into Next Step
|
|
- commit (`TODO.md` changes in the same commit as the work)
|
|
- open a PR based on `next`
|
|
- an independent reviewer who did not write the change gates it
|
|
- the manager squash-merges the PR into `next` once review passes
|
|
- `next` stays green and mergeable to `main` at any time; only the owner merges
|
|
`next` into `main`, via the single milestone PR
|
|
- push
|
|
|
|
# Status
|
|
|
|
pre-1.0. No git tags exist. The `1.0.0` milestone is in progress; work lands on
|
|
`next`, and `main` receives only the milestone PR that the owner merges. `next`
|
|
is at the canonical `golangci-lint` v2.12.2 config and is green. Recent work
|
|
extracted the internal/magic, internal/allowlist, internal/httpfetcher, and
|
|
internal/signature packages. The gosec findings from the 2026-07-06 survey are
|
|
resolved. The disk cache is now size-bounded with LRU eviction
|
|
(`cache_max_bytes`), closing the unbounded disk growth DoS vector.
|
|
|
|
# Next Step
|
|
|
|
P2: security: per-IP rate limiting on the image routes
|
|
|
|
# Completed Steps
|
|
|
|
- 2026-10-05 the format `auto` (closes #88): a format in the `/v1/image/` path,
|
|
an encrypted URL's token and the generator page's format choice, chosen for
|
|
each request from `Accept` once the signature or token is checked: AVIF when
|
|
the header names `image/avif`, else WebP when it names `image/webp`, else JPEG
|
|
when the first of `image/jpeg`, `image/*` and `*/*` that it names allows it,
|
|
or when it names nothing; `q=0` refuses a format. AVIF and WebP must be named,
|
|
as clients that cannot show them send the wildcards too. A header that allows
|
|
none of the three answers 406, one that does not parse 400. The signature and
|
|
the token cover `auto` itself; the cache key and `ETag` use the format chosen.
|
|
Answers from the point the format is chosen carry `Vary: Accept`, next to the
|
|
CORS `Vary: Origin`; fixed-format answers do not.
|
|
- 2026-10-05 the markdown is formatted with prettier (closes #100): `script/fmt`
|
|
and `script/fmt-check` run prettier 3.8.1, pinned in `package.json` and
|
|
`yarn.lock`, on `**/*.md` after `gofmt`, with four-space tabs and
|
|
`proseWrap: always` as `.prettierrc` says; `.prettierignore` keeps it off
|
|
`REPO_POLICIES.md`, the copy from `sneak/prompts`, and `vendor/`. Plain
|
|
`script/bootstrap` installs Node and Yarn as the one in `sneak/prompts` does
|
|
and then prettier; `script/bootstrap --cgo` does not, as the `Dockerfile`
|
|
stages that run it format nothing. The HTML templates stay unformatted:
|
|
prettier cannot parse a Go template action inside a tag. The markdown was
|
|
reflowed in a commit of its own.
|
|
- 2026-10-05 lint and tests run as the `lint` and `test` phases of the
|
|
`Dockerfile`, built with `--no-cache` (closes #202): `script/check`,
|
|
`script/cibuild`, `script/docker`, `script/lint`, `script/test`,
|
|
`script/setup` and `script/install-precommit` are now the copies from
|
|
`sneak/prompts` `main`, unchanged. The `lint` phase runs golangci-lint from
|
|
the image `REPO_POLICIES.md` names, with `libvips-dev` from `apt-get`; the
|
|
`test` phase runs the tests with a 90-second timeout; the build stage depends
|
|
on both. `Dockerfile.lint` and the `CHECK_EPOCH` build argument are gone, the
|
|
formatting check runs on the host, and `make docker-versioned` and
|
|
`make docker-test` call the scripts. `script/bootstrap`, `script/fmt`,
|
|
`script/fmt-check`, `script/precommit` and `script/projectname` stay pixa's
|
|
own. `script/bootstrap` installs git, make and Go, refreshing apt's package
|
|
lists before its first apt install; with `--cgo`, which only the `test` phase
|
|
and the build stage pass, it also installs the C compiler and the libvips and
|
|
libheif libraries. The stage that compiles still takes the version from
|
|
`git describe` when no `VERSION` is given, per
|
|
https://git.eeqj.de/sneak/pixa/issues/166, so the copied scripts' comment that
|
|
`.dockerignore` leaves out `.git` does not hold for pixa.
|
|
- 2026-10-04 load test (closes #81): `script/loadtest [duration [clients]]`
|
|
(`make loadtest`, defaults `10s` and `4`), a benchmark that `script/check`
|
|
does not run, measures three scenarios, each against a new pixad container and
|
|
a new upstream host, `cmd/loadtest-origin`: `hit` (one cached image), `miss`
|
|
(a new source image every request) and `herd` (each new source image asked for
|
|
by all clients at once). For each it prints vegeta's report (requests per
|
|
second, latency percentiles, status codes), pixad's peak resident memory and
|
|
the requests that reached the origin. `README.md` says how to run it and read
|
|
it, and keeps 1-5k r/s as a target not yet measured. First measurement, with
|
|
the defaults on a shared 48-CPU machine with other work running: a baseline
|
|
for later changes, not a test of the target. `hit` 1413 r/s, p50 0.7 ms, p95
|
|
8.7 ms, p99 44 ms, peak 53 MiB (4 clients that each wait for their answer, so
|
|
not pixad's limit); `miss` 70 r/s, p50 52 ms, p95 91 ms, p99 122 ms, peak 100
|
|
MiB, one fetch per request; `herd` 74 r/s, p50 52 ms, p95 69 ms, p99 111 ms,
|
|
peak 60 MiB, 188 fetches for 749 requests.
|
|
- 2026-10-04 the CI checkout fetches the tags (closes #208): the checkout step
|
|
in `.gitea/workflows/check.yml` sets `fetch-depth: 0`, as `REPO_POLICIES.md`
|
|
asks of a repo that takes its version from the tags, so a CI build of a tagged
|
|
commit stamps the tag from `git describe` instead of a bare commit.
|
|
- 2026-10-04 `config.yml` stays out of git and the Docker build context (closes
|
|
#212): `.gitignore` now ignores `config.yml`, the config file Getting Started
|
|
creates with the signing key, and `.dockerignore` leaves it out in every
|
|
directory and in any letter case, as it already did `config.yaml` and
|
|
`config.dev.yml`.
|
|
- 2026-10-04 local config files stay out of the Docker build context (closes
|
|
#211): `.dockerignore` now leaves out `config.yaml` and `config.dev.yml` in
|
|
every directory and in any letter case, the local config files `.gitignore`
|
|
keeps out of git because they can hold the signing key.
|
|
`configs/config.example.yml` is still sent. `config.yml`, which Getting
|
|
Started creates, is in neither file:
|
|
https://git.eeqj.de/sneak/pixa/issues/212.
|
|
- 2026-10-04 `cmd/pixad/main.go` is one call into `internal/` (closes #206):
|
|
what it did (the command line and its `--config` flag, setting
|
|
`PIXA_CONFIG_PATH`, ignoring `SIGPIPE`, starting the fx app) is now `Run` in
|
|
`internal/app`, unchanged, and `main` calls it with `Version`, which the build
|
|
still sets through `-X main.Version`. That code had no tests to move.
|
|
- 2026-10-04 `.gitignore` ignores `.claude/` (closes #204): the entry and its
|
|
comment are copied from the canonical `.gitignore` in `sneak/prompts`,
|
|
unanchored so it matches at every depth. `.dockerignore` already has
|
|
`.claude`.
|
|
- 2026-10-04 `.dockerignore` keeps secrets out at every depth (closes #205): the
|
|
file is now the standard one from `sneak/prompts`, whose patterns match in
|
|
every directory and, for environment files and private keys, in any letter
|
|
case, so a nested `.env` or `server.key` no longer reaches the build context.
|
|
pixa still sends `.git` without `.git/config` in place of the standard file's
|
|
`.git` line, and still leaves out `.gitignore`, `/bin` and `/data`.
|
|
- 2026-10-04 `REPO_POLICIES.md` matches the canonical copy again (closes #196):
|
|
it is replaced, unchanged, by `prompts/REPO_POLICIES.md` from `sneak/prompts`
|
|
`main`. The rules it adds that pixa's tree breaks are filed:
|
|
https://git.eeqj.de/sneak/pixa/issues/202 (lint and tests as `Dockerfile`
|
|
phases built with `--no-cache`), https://git.eeqj.de/sneak/pixa/issues/203
|
|
(the workflow's `script/docker-smoke` step),
|
|
https://git.eeqj.de/sneak/pixa/issues/204 (`.claude/` in `.gitignore`),
|
|
https://git.eeqj.de/sneak/pixa/issues/205 (`.dockerignore` patterns at every
|
|
depth), https://git.eeqj.de/sneak/pixa/issues/206 (a thin `cmd/pixad/main.go`)
|
|
and https://git.eeqj.de/sneak/pixa/issues/208 (`fetch-depth: 0` on the CI
|
|
checkout, so the build sees the tags). Its rule that no build stage runs
|
|
`git describe` is not followed: pixa takes the version from the `.git` in the
|
|
build context, per https://git.eeqj.de/sneak/pixa/issues/166, as the copy on
|
|
`sneak/prompts` `next` already says.
|
|
- 2026-10-04 an integration test of the image proxy flow (closes #80):
|
|
`TestImageProxyFlow` in `internal/server` starts the database, handlers and
|
|
middleware from the constructors `pixad` uses, with a fresh state directory,
|
|
and replaces only the upstream origin with a local test server. For a resize
|
|
with a change to JPEG and for `orig`, the first request goes through the
|
|
router, the real fetcher, libvips, the disk cache and SQLite and answers 200
|
|
with the right content type and size and `X-Pixa-Cache: MISS`; the second
|
|
answers `HIT` with the same image and the upstream has had one request; the
|
|
source and the converted image are then in `cache/sources` and
|
|
`cache/variants`, with their rows in `source_content`, `source_metadata` and
|
|
`variant_content`. Two optional fields make this possible, which `pixad` does
|
|
not set and the config file and environment cannot:
|
|
`httpfetcher.Config.DialContext` connects in place of the dialer that refuses
|
|
internal addresses, the URL and redirect checks still running, and
|
|
`handlers.Params.Fetcher` replaces the fetcher the handlers build.
|
|
- 2026-10-04 a URL made on the generator page with a `ttl` is tested to expire
|
|
(closes #199): a new test in `internal/handlers` makes a URL on the generator
|
|
page with a `ttl` of one second, checks that `/v1/e/` serves it at once, waits
|
|
two seconds and checks that it then answers 410. The test waits for real, as
|
|
pixa reads the clock directly when it makes and checks a URL; it waits two
|
|
seconds because the time a URL expires is kept in whole seconds. Test only.
|
|
- 2026-10-04 referer blocklist (closes #90): `referer_blocklist`
|
|
(`PIXA_REFERER_BLOCKLIST`) lists hosts, written and matched as for
|
|
`allowlist_hosts` with the same matcher; an entry of either list that is
|
|
neither a host name (letters, digits, hyphens, underscores and dots, with at
|
|
most one leading dot) nor an IP address, such as one with a port or a `*.`
|
|
wildcard, aborts startup naming the setting and the entry. Both image routes
|
|
refuse a request whose `Referer` names a listed host with 403 and a JSON error
|
|
before the signature, the cache and the upstream fetch, so it fetches nothing
|
|
and is refused whether or not the image is cached. A request with no
|
|
`Referer`, or one that does not parse as a URL with a host, is served, so the
|
|
list is easily got around; `README.md` and `configs/config.example.yml` say
|
|
so. It does not apply to the login and generator pages.
|
|
- 2026-10-04 fewer files in the repository root (closes #97):
|
|
`config.example.yml` moved unchanged to `configs/config.example.yml`, and
|
|
`README.md`, the comments in `internal/config/config.go` and the startup error
|
|
for the placeholder signing key name the new path; `scripts/manual-test.sh`
|
|
and its directory are deleted, as the handler tests in `internal/handlers`
|
|
cover every check it made except two: fetching a real image from the internet,
|
|
and a URL made on the generator page with a `ttl` answering 410 once the `ttl`
|
|
has passed (https://git.eeqj.de/sneak/pixa/issues/199); `CONVENTIONS.md` is
|
|
deleted, as `REPO_POLICIES.md` links the canonical Go HTTP server conventions.
|
|
- 2026-10-04 SQLite writes no longer fail with "database is locked" (closes
|
|
#198): pixa adds `_pragma=busy_timeout(5000)` to every `db_url`, so a write
|
|
that finds another in progress on another connection waits up to five seconds
|
|
for it, and the default `db_url` turns on WAL mode with
|
|
`_pragma=journal_mode(WAL)`. The old default's `_journal_mode=WAL` is not a
|
|
parameter the driver reads, so the database was never in WAL mode.
|
|
- 2026-10-04 `TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup` only
|
|
passes through a periodic pass (closes #189): it slept for three eviction
|
|
intervals before writing its file, and a startup pass still running then could
|
|
adopt the file itself. It now holds the test database's only connection until
|
|
the startup pass waits for it after walking the empty variant directory,
|
|
writes the file and lets the connection go, as
|
|
`TestEvictionRunsOnPeriodicSchedule` does, so only a periodic reconciliation
|
|
pass can adopt the file. Test only.
|
|
- 2026-10-04 logging in, logging out, the URL generator and `/v1/e/` have
|
|
handler tests (closes #77): new tests in `internal/handlers`, with no network,
|
|
check that `GET /` without a login session shows the login form; a wrong key
|
|
shows it again with an error and sets no session cookie; the right key answers
|
|
303 to `/` with a session cookie marked `Secure`, `HttpOnly` and
|
|
`SameSite=Strict`, with which `GET /` shows the generator page; `GET /logout`
|
|
answers 303 to `/` with an empty session cookie sent with `Max-Age=0`;
|
|
`POST /generate` without a login session answers 303 to `/`; `/v1/e/` serves
|
|
the image for a valid token, answers 410 for an expired one and 400 for one
|
|
with a character changed, cut short or made with another signing key; and a
|
|
URL made on the generator page is served by `/v1/e/`. No code changes.
|
|
- 2026-10-04 `TODO.md` merges with git's union merge (closes #190): a root
|
|
`.gitattributes`, copied from `sneak/prompts`, marks it `merge=union`, so two
|
|
branches that each add an entry at the top of Completed Steps merge without a
|
|
conflict and keep both entries. Git now never reports a conflict in `TODO.md`:
|
|
a real one keeps both versions of the lines, and two entries that share an
|
|
identical line can end up one inside the other, which a rebase can do to an
|
|
entry already on `next`. The Workflow above says to read the merged entries
|
|
after every merge or rebase.
|
|
- 2026-10-04 the default `cache_max_bytes` no longer shrinks as the cache fills
|
|
(closes #184): for an omitted key, the cache works out the limit when it
|
|
opens, after the database is open, as 75% of the sum of the free space on the
|
|
filesystem containing `<state_dir>/cache/` and what the cache already holds by
|
|
its own size accounting, at least 500 MiB, so a cache filled to its limit
|
|
keeps that limit across a restart. The computation and its tests moved from
|
|
`internal/config` to `internal/imgcache`; the config only records whether the
|
|
key was set.
|
|
- 2026-10-04 `TestEvictionRunsOnPeriodicSchedule` no longer races the evictor
|
|
(closes #183): it wrote each variant file and then inserted its accounting row
|
|
by hand, and a reconciliation pass between the two adopted the file first, so
|
|
the insert failed. It now writes the files only, while holding the test
|
|
database's only connection so the evictor's startup pass waits after walking
|
|
the empty variant directory; a periodic reconciliation pass then adopts the
|
|
files and the eviction pass after it evicts them. No other test in
|
|
`internal/imgcache` inserts a row by hand after starting the evictor. Test
|
|
only.
|
|
- 2026-10-04 a config file pixa cannot read aborts startup (closes #176): of the
|
|
places pixa looks for its config file on its own, only one where the file does
|
|
not exist is passed over; any other error, such as a directory on the path
|
|
that pixa may not enter, aborts startup naming the file, as a file that does
|
|
not parse already did.
|
|
- 2026-10-04 `.golangci.yml` re-vendored from the canonical copy (closes #57):
|
|
the deprecated `gomodguard` is switched off, so lint runs print no deprecation
|
|
warning; its successor `gomodguard_v2` runs with the shared module block list,
|
|
and `depguard` keeps `net/http/httptest` out of files that are not tests. The
|
|
tree needed no code changes.
|
|
- 2026-10-04 the Content-Security-Policy allows no inline script or style
|
|
(closes #125): `script-src` and `style-src` are `'self'` only. The generator
|
|
page's two inline `onclick` handlers moved into
|
|
`internal/static/generator.js`, attached with `addEventListener`; the bundled
|
|
Tailwind script, which built styles in the browser, is replaced by a small
|
|
hand-written `internal/static/style.css` with only the rules the login and
|
|
generator pages use, the templates carrying a few plain class names in place
|
|
of Tailwind's. No build step. The pages keep their layout, not every pixel of
|
|
it.
|
|
- 2026-10-04 deployment guide and example Caddy config (closes #89):
|
|
"Deployment" in `README.md` says what the reverse proxy in front of pixa must
|
|
do (terminate TLS; pass `Host`, `Origin` and `Referer` on unchanged; set
|
|
`X-Forwarded-For`, with `trusted_proxies` to match; wait at least
|
|
`downstream_timeout`; optionally refuse `/metrics`) and what pixa does itself,
|
|
that the state directory needs a persistent volume and what `cache_max_bytes`
|
|
counts, the health check for a load balancer, what a stop does and its exit
|
|
codes, and what running outside Docker needs; `configs/Caddyfile` is the
|
|
example, checked with `caddy validate`.
|
|
- 2026-10-04 the metrics basic auth, CORS preflight, request logging and metrics
|
|
recording have tests (closes #79): `MetricsAuth` on its own answers 401 with a
|
|
challenge without credentials or with a wrong username or password and lets
|
|
the configured ones through; a preflight request gets `*` for any origin when
|
|
`access_control_allow_origin` is `*` and no `Access-Control-Allow-Origin` from
|
|
another origin than the configured one; a `POST /` carrying the signing key
|
|
leaves no trace of it in the request log line, and the login handler's own log
|
|
lines leave out the submitted key; the metrics middleware on its own records a
|
|
request it served, and the router records nothing while no metrics username is
|
|
set. Not tested: that the router puts the basic auth in front of `/metrics`
|
|
and records requests when a metrics username is set. Only one test per package
|
|
can set up `/metrics`, and in `internal/server` that is
|
|
`TestMaintenanceModeKeepsOtherRoutes`, which needs the owner's approval to
|
|
change; #180 holds it. Tests only; the basic auth library already compares the
|
|
password in constant time.
|
|
- 2026-10-04 the image route's signature check and error answers are tested
|
|
(closes #76): new tests in `internal/handlers`, with no network, check the
|
|
status and JSON error body for a missing, wrong, unpadded, upper-case or
|
|
expired signature on a host not on the allowlist, or a valid one sent for its
|
|
parent domain, a sibling host, a subdomain or the host with another domain
|
|
appended (401), an unparseable path (400), `localhost` as the upstream host
|
|
(403) and an upstream error (502); that an allowlisted host is served without
|
|
a signature, another host only with a valid one; and the answers of
|
|
`/robots.txt` and the health check. No code changes.
|
|
- 2026-10-04 request IDs returned and passed on, and `/v1/e/` revalidates
|
|
(closes #84): pixa's own `RequestID` middleware, in place of chi's, gives each
|
|
request an ID, its own `X-Request-ID` when that is at most 64 letters, digits,
|
|
`-`, `_` or `.` and a random one otherwise, stores it where chi's did and
|
|
sends it back as `X-Request-ID` on every response; the upstream fetch sends
|
|
that ID, and the "upstream fetched", "image converted" and "image served" log
|
|
lines carry it as `request_id`, a fetch shared by several requests carrying
|
|
the first request's; `/v1/e/` sets `ETag`, answers a matching `If-None-Match`
|
|
with 304 and is routed for `HEAD`, the `ETag` and 304 code being
|
|
`notModified`, which `/v1/image/` calls too; its token checks moved unchanged
|
|
into `parseImageEncRequest` to keep `HandleImageEnc` within the line limit; no
|
|
`Vary` is added, as no response depends on a request header except the image
|
|
routes' CORS headers, for which `go-chi/cors` already sends `Vary: Origin`;
|
|
`Vary: Accept` is left to #88.
|
|
- 2026-10-04 routes, encrypted URLs and config file documented (closes #75):
|
|
"Routes" in `README.md` lists every route with its method, purpose, what it
|
|
needs and the status codes it answers with, and says `q` and `fit` are part of
|
|
what is cached; "Encrypted URLs" covers logging in, making one on the
|
|
generator page, how long it lasts and the 410 once it has expired;
|
|
"Configuration" gives the order in which pixa looks for its config file;
|
|
`config.example.yml` lists `db_url` and `env` and gives every key's default;
|
|
`scripts/manual-test.sh` is left to #97.
|
|
- 2026-10-04 shutdown stops cache eviction in progress (closes #102):
|
|
`StartEviction` runs the eviction goroutine with its own context, which
|
|
`StopEviction` cancels, so a pass in progress stops at its next database call,
|
|
file, row or eviction candidate instead of running to completion, and no pass
|
|
starts after it, so a stop logs at most one warning; `StopEviction` takes a
|
|
context and, when that context ends before the goroutine exits, stops waiting
|
|
and returns its error; the handlers' stop hook passes fx's stop context, so an
|
|
eviction still running when fx's stop deadline ends fails the stop and makes
|
|
the exit code 1.
|
|
- 2026-10-04 dead code in `internal/imgcache` is gone (closes #73): `Purge`,
|
|
which only returned an error and which nothing called, is no longer part of
|
|
the `ImageCache` interface or `Service`; the `SignatureValidator`, `Allowlist`
|
|
and `Storage` interfaces, which nothing implemented or used, are deleted.
|
|
Nothing else changes.
|
|
- 2026-10-04 upstream host semaphores and variant `.meta` files no longer
|
|
outlive their use (closes #87): the fetcher counts the fetches holding or
|
|
waiting for a slot of each upstream host's semaphore and removes the host's
|
|
semaphore once none is left, so fetches from many hosts no longer leave one
|
|
semaphore each until restart; `VariantStorage.Delete` removes the variant's
|
|
`.meta` file along with it, a missing `.meta` file not being an error, and
|
|
`DeleteWithMeta`, which eviction called for that, is gone.
|
|
- 2026-10-04 `README.md` matches the code (closes #74): "Storage" names the
|
|
cache directories pixa uses (`cache/sources`, `cache/metadata`,
|
|
`cache/variants`) and how files are named in each, and the comments in
|
|
`001_schema.sql` name the same paths; the routes and the signature section
|
|
list the same output formats, `jpg` and `original` included; the TLS sentence
|
|
names `allow_http` as its exception; "Metrics" says only generic HTTP and Go
|
|
runtime metrics exist, measured and served only when the metrics username and
|
|
password are set.
|
|
- 2026-10-03 shutdown sets the exit code and waits for image processing (closes
|
|
#86): fx alone handles SIGINT and SIGTERM, and the server's own signal handler
|
|
is gone; fx's `Run` in `cmd/pixad` exits with the shutdown's code: 0 for a
|
|
signal, 1 when the HTTP server cannot listen or the app fails to start or to
|
|
stop; the server's stop hook, which fx waits for, stops the HTTP server, waits
|
|
for the images still being processed, both within 5 seconds, then flushes
|
|
Sentry; images still being processed after that are logged with their count
|
|
and make the exit code 1; a Sentry DSN that cannot be used fails startup, so
|
|
the stop hooks of what had already started run, instead of exiting the process
|
|
from a goroutine; the eviction loop is left to #102.
|
|
- 2026-10-03 every `script/cibuild` and `script/docker` run executes the checks
|
|
(closes #101): the `Dockerfile` declares `CHECK_EPOCH` above `make fmt-check`
|
|
and `make lint` in the lint stage and above `make test` in the build stage,
|
|
and each of those steps names it in its command; both scripts pass a new value
|
|
on every run, so Docker runs the checks instead of reusing cached results,
|
|
while the `script/bootstrap` steps stay cached; a plain `docker build .` still
|
|
works, leaves it empty, and reuses the check steps only for an identical build
|
|
context; the `script/cibuild` comment and `README.md` no longer say that any
|
|
successful build implies a green repo.
|
|
- 2026-09-29 share concurrent misses (closes #65): requests that miss the same
|
|
variant at once (the same cache key, so quality and fit included) share one
|
|
upstream fetch or cached source read and one transcode through
|
|
`golang.org/x/sync/singleflight`; the first request's processing ignores its
|
|
cancellation but keeps its deadline, and the others wait for its image or
|
|
error holding no upstream connection or processing slot, and stop waiting when
|
|
their own context ends; the request doing the processing waits for it even
|
|
then, up to its deadline; a request whose context has already ended starts
|
|
nothing; each request counts one miss, and the processing counts its fetch and
|
|
transcode once; a panic while processing is reported to Sentry when
|
|
`sentry_dsn` is set and becomes an error for every waiting request instead of
|
|
stopping pixad; documented in `README.md`.
|
|
- 2026-09-29 only the image routes send CORS headers (closes #98): the CORS
|
|
middleware, with the `access_control_allow_origin` origin, moved from the
|
|
router root onto a `/v1` subrouter holding `/v1/image/` and `/v1/e/`, where it
|
|
still answers a preflight `OPTIONS` request; the login and URL generator
|
|
pages, `/metrics` and the other routes send no `Access-Control-Allow-Origin`;
|
|
documented in `README.md` and `config.example.yml`.
|
|
- 2026-10-02 a plain `docker build .` stamps the tag or short commit, not `dev`
|
|
(closes #166): `.dockerignore` lets `.git` into the build context, without
|
|
`.git/config`; with no `VERSION` build argument the `Dockerfile` takes the
|
|
version from `git describe --tags --always`, and fails the build if the
|
|
context carries `.git` and no version comes out; `ARG VERSION` has no default;
|
|
pixad logs its version, with its name and architecture, as its first log line
|
|
at startup.
|
|
- 2026-09-29 the container makes `/var/lib/pixa` usable by itself (closes #159):
|
|
`deploy/docker-entrypoint.sh` creates the directory if it is missing, gives
|
|
the directory and everything in it to `pixad` when the directory or one of its
|
|
top-level entries belongs to another user or group, sets its mode to `750`,
|
|
then runs the server as `pixad`; data left by an earlier run under another uid
|
|
is taken over this way; "Running under upaas" in `README.md` no longer tells
|
|
the operator to create or chown the host directory.
|
|
- 2026-09-29 variant content types kept in memory (closes #70):
|
|
`Cache.metaCache` holds the content types of up to 10,000 variants in an LRU
|
|
(`github.com/hashicorp/golang-lru/v2`), filled by `StoreVariant` and by
|
|
`GetVariant` after it reads a `.meta` file, where a type `StoreVariant` added
|
|
meanwhile is kept over the one read, and never with the
|
|
`application/octet-stream` served for a variant without one; for a variant it
|
|
holds, `GetVariant` skips the `.meta` read, still opening the variant file and
|
|
taking the size from it; eviction removes the entry before deleting the files,
|
|
and `GetVariant` removes it when the file will not open; the cap is a
|
|
constant, not a setting; the unused `variantMeta` type is gone; `README.md`
|
|
describes it.
|
|
- 2026-09-29 maintenance mode refuses image requests (closes #71): while
|
|
`maintenance_mode` is on, `/v1/image/` and `/v1/e/` answer 503 with a
|
|
`Retry-After` header and the JSON error body, from one middleware in
|
|
`internal/server/routes.go`; the health check stays 200 and reports
|
|
`maintenance_mode`, as the image's Docker `HEALTHCHECK` requests it and upaas
|
|
marks a deploy failed when its container is unhealthy; the login and URL
|
|
generator pages and `/metrics` keep working; documented in `README.md` and
|
|
`config.example.yml`.
|
|
- 2026-09-29 bound concurrent image processing and upstream fetches (closes
|
|
#64): `max_concurrent_processing` (default the number of CPUs pixa can use)
|
|
limits the images decoded and encoded at once, and `upstream_connections`
|
|
(default 64) the connections to all upstream hosts together, on top of
|
|
`upstream_connections_per_host`; a fetch holds its connection until its image
|
|
has been processed, and a request whose source is cached reads it only once it
|
|
has a processing slot; a request that finds either limit reached waits up to
|
|
10 seconds for a free one, then gets 503 `server busy, try again later`;
|
|
libvips runs one worker thread per image with its operation cache off;
|
|
documented in `README.md` and `config.example.yml`.
|
|
- 2026-09-29 Dockerfiles install through `script/bootstrap` (closes #95): the
|
|
`Dockerfile` lint and build stages and `Dockerfile.lint` copy `script/`,
|
|
`go.mod` and `go.sum`, then run `script/bootstrap` in place of their own
|
|
`apk add` lines, so the build dependencies are listed in one place;
|
|
`script/bootstrap` now also installs a C compiler when `gcc` is missing; the
|
|
build uses `-trimpath` and `-s -w` and keeps `CGO_ENABLED=1` for govips;
|
|
`ARG VERSION` sits just above the build, so a new version reruns neither
|
|
`script/bootstrap` nor the tests.
|
|
- 2026-09-29 migrations at the path `REPO_POLICIES.md` sets (closes #96): the
|
|
migration files moved, contents unchanged, from `internal/database/schema/` to
|
|
`internal/db/migrations/` as `000_migration.sql` and `001_schema.sql`; the
|
|
`internal/db/migrations` package embeds them and `internal/database` reads
|
|
them through its `FS()`; the `internal/database` package itself stays; the
|
|
version still comes from the filename prefix, so a database that has recorded
|
|
versions 0 and 1 runs neither again.
|
|
- 2026-09-29 `trusted_proxies` advice and signature padding in `README.md`
|
|
(closes #150): the login-limit paragraph, the `trusted_proxies` entry and
|
|
`config.example.yml` say to set `trusted_proxies` to the address pixa sees for
|
|
requests that come through the proxy, which the request log shows as
|
|
`remoteIP` while it is not trusted; for a proxy on the Docker host that
|
|
connects over `127.0.0.1` that is the Docker network's gateway, not the
|
|
proxy's own address; the signature section says `sig` is base64url with the
|
|
`=` padding kept, and gives the example's `sig` for a stated signing key.
|
|
- 2026-09-29 fixed uid and gid for `pixad` (closes #151): the image creates the
|
|
`pixad` group with gid 65532 and the `pixad` user with uid 65532, instead of
|
|
the first free uid 1000, so a bind-mounted `/var/lib/pixa` given to `pixad` is
|
|
not owned on the host by a person's login account; the first-run step of
|
|
"Running under upaas" in `README.md` names the uid and gid.
|
|
- 2026-09-29 `max-age` never outlives an expiring URL (closes #63): both image
|
|
routes build `Cache-Control` from the request's `Expires`, which an encrypted
|
|
URL's expiry now fills too; `max-age` is one year, or the whole seconds left
|
|
until the `exp` of a `/v1/image/` URL or the expiry of an encrypted URL when
|
|
that is sooner, never negative; an allowlisted host's URL that has an `exp`
|
|
follows it too; `immutable` stays, as freshness now ends at the expiry;
|
|
documented in `README.md`.
|
|
- 2026-09-28 add the four settings `README.md` documented but pixa did not have,
|
|
which aborted startup as unknown keys (closes #61):
|
|
`access_control_allow_origin` (default `*`, the CORS origin),
|
|
`upstream_fetch_timeout` (default `30s`), `upstream_max_response_size`
|
|
(default 50 MiB) and `downstream_timeout` (default `60s`, both the server's
|
|
write timeout and the per-request timeout); each has a `PIXA_` variable;
|
|
durations are positive Go duration strings, the size a whole number of bytes
|
|
up to 1 GiB, the origin `*` or one `http` or `https` origin as `README.md`
|
|
describes it; an invalid value aborts startup naming the key and the value;
|
|
documented in `config.example.yml` and `README.md`.
|
|
- 2026-09-28 cache stats report real numbers (closes #56): `Cache.Stats` counts
|
|
the cached source images and processed variants (`source_content` plus
|
|
`variant_content`) and takes their size from `Cache.UsageBytes`, instead of
|
|
reading `request_cache` and `output_content`, which nothing writes; those two
|
|
tables are left in the schema; a disabled disk cache reports no items and no
|
|
size. A hit is counted even when the request context has ended. A miss is
|
|
counted after it is served or fails, also when the request context has ended
|
|
by then, with the bytes it read from upstream, so `upstream_fetch_count` and
|
|
`upstream_fetch_bytes` move, including for an upstream body that fails partway
|
|
or a fetched source that then fails the magic byte check; `transform_count`
|
|
counts each image the image processor transcodes.
|
|
- 2026-09-28 strip metadata from processed images (closes #82): every output is
|
|
exported with govips' `StripMetadata`, so it carries no EXIF, XMP, IPTC or ICC
|
|
profile; the image is first turned upright with `AutoRotate` (before sizes are
|
|
worked out) and, when it has an ICC profile, converted to sRGB; the `orig`
|
|
format is re-encoded and stripped like any other, as pixa never serves the
|
|
source bytes; there is no setting to keep metadata; documented in `README.md`.
|
|
- 2026-09-28 rate limit the login form (closes #66): `POST /` is limited to 5
|
|
attempts per minute per client address, and an attempt over the limit is
|
|
refused with 429 and a `Retry-After` header; the address is the one
|
|
`internal/clientip` resolves through `trusted_proxies`, an IPv6 client is
|
|
counted by its /64, and an IPv4-mapped address as the IPv4 address it carries;
|
|
the limit is a `RateLimit` middleware in `internal/middleware` on
|
|
`github.com/go-chi/httprate`, which the image routes can reuse; the library
|
|
keeps counts for the current and the previous minute only; documented in
|
|
`README.md`.
|
|
- 2026-09-28 refuse an unparseable `exp` on `/v1/image/` and log swallowed cache
|
|
errors (closes #72): an `exp` in the URL that is not a whole number, an empty
|
|
`exp=` included, is a 400 naming `exp` and the value, instead of being ignored
|
|
and answered with 401 as if the URL had no `exp`; only an `exp` missing from
|
|
the URL is unchanged; `README.md` says so where it documents `exp`. A failed
|
|
variant `.meta` write, source metadata JSON write, `Stats` count query, stats
|
|
counter update, negative cache write or expired negative cache delete is now
|
|
logged at `warn` with the path or key and the error, and stays non-fatal.
|
|
- 2026-09-28 refuse an empty `fit` on `/v1/image/` (closes #139): a `fit` in the
|
|
URL with an empty value (`fit=`) is a 400 naming `fit`, instead of being
|
|
served as `cover` and verified against a signature made for `cover`; only a
|
|
`fit` missing from the URL is still `cover`; any other value still goes
|
|
through the existing fit-mode check; `README.md` says so where it documents
|
|
`fit`.
|
|
- 2026-09-28 refuse an invalid `q` on `/v1/image/` (closes #134): a `q` that is
|
|
not a whole number from 1 to 100, an empty `q` included, is a 400 naming `q`
|
|
and the value, instead of being served at the default 85; the route reads `q`
|
|
with the generator's quality check (`parseFormInt` with `minQuality` and
|
|
`maxQuality`); only a `q` missing from the URL is still 85; a query string
|
|
that cannot be decoded, such as `q=80%`, is a 400 showing it; any query
|
|
parameter given more than once (`q`, `fit`, `sig`, `exp` alike) is a 400
|
|
naming it, so none is read from its first value only; `README.md` states the
|
|
range and both query-string rules.
|
|
- 2026-09-28 unknown `PIXA_` environment variables abort startup (closes #133):
|
|
a variable whose name starts with `PIXA_` but is neither a setting's variable
|
|
nor `PIXA_CONFIG_PATH` aborts startup naming it, as an unknown config key
|
|
does, and `PIXA_PORT` is named with a pointer to `PORT`; the check runs after
|
|
the config file loads, so the variables the file's `env:` section sets are
|
|
checked too; documented in `README.md`.
|
|
- 2026-09-28 start on a fresh upaas volume (closes #129): the image starts as
|
|
root only to give `/var/lib/pixa` to `pixad` when `pixad` does not own it
|
|
(`deploy/docker-entrypoint.sh`), then runs the server as `pixad` through
|
|
`su-exec`, so a root-owned host directory bind-mounted there no longer stops
|
|
the container at startup; `README.md` gains a "Running under upaas" section.
|
|
- 2026-09-28 run all linting in Docker via `Dockerfile.lint` + `script/lint`
|
|
(closes #104): `make lint` calls `script/lint`, the only way the linter is
|
|
run; inside a container (both Dockerfiles set `container=docker`) it runs
|
|
`golangci-lint`, anywhere else it builds the hash-pinned `Dockerfile.lint`,
|
|
whose last step runs `script/lint` again; the `Dockerfile` lint stage runs
|
|
`make lint`; no host or nix-shell `golangci-lint` path remains
|
|
(`script/bootstrap` installs no linter); a per-run `CACHEBUST` build-arg keeps
|
|
the lint step from being served from cache, and a tmpfs mount on that step
|
|
keeps Go's and golangci-lint's caches out of its layer, so a run leaves no
|
|
large build cache behind; `golangci-lint config verify` stays out, as it
|
|
fetches its schema over an unpinned live HTTPS call
|
|
- 2026-09-28 every setting as an environment variable (closes #128, also covers
|
|
#99): each config key can be set by `PIXA_` plus the key in upper case (`.`
|
|
written as `_`), and the port by `PORT`; a variable present in the
|
|
environment, even empty, wins over the config file, which wins over the
|
|
default; the typed getters read the variable first, so every existing check
|
|
applies to it and a bad value aborts startup naming the variable; lists are
|
|
comma-separated, and an empty variable (or `""` in the file) is an empty list;
|
|
the Docker image no longer bakes in `config.docker.yml` or passes `--config`,
|
|
and its `HEALTHCHECK` probes `PORT` (default `8080`); the config file is
|
|
looked for under `/etc/pixa` and `~/.config/pixa` instead of the daemon name
|
|
`pixad`; documented in `README.md` and `config.example.yml`.
|
|
- 2026-09-28 quality and fit in the URL signature (closes #60): the signed data
|
|
is now `host:path:query:width:height:format:expiration:quality:fit`, using
|
|
`85` and `cover` when the URL has no `q` or `fit`, so one signed URL can no
|
|
longer be replayed across other quality and fit values to create unauthorized
|
|
cache entries and transcodes; the known-answer vectors in
|
|
`internal/signature/golden_test.go` and the README signature specification
|
|
describe the new format.
|
|
- 2026-09-28 Docker image healthcheck (closes #111): a `HEALTHCHECK` in the
|
|
runtime stage probing `/.well-known/healthcheck.json` with busybox `wget`;
|
|
`script/docker-smoke` (`make docker-smoke`) builds the image, starts it with a
|
|
throwaway `PIXA_SIGNING_KEY`, and passes only once Docker reports it healthy
|
|
within 30 seconds, removing the container on exit; the Gitea workflow runs it
|
|
after `script/cibuild`.
|
|
- 2026-09-21 trusted-proxy client IP resolution (closes #94): a
|
|
`trusted_proxies` config key taking a list of CIDRs, parsed by the same
|
|
`net/netip` list parser as `blocked_networks` (an invalid entry aborts startup
|
|
naming the key and value; an omitted key defaults to the RFC 1918 private
|
|
ranges, an explicitly empty list trusts no one, and an explicit list replaces
|
|
the default); a new `internal/clientip` package resolves the client address by
|
|
honoring `X-Forwarded-For` only when the direct peer is a trusted proxy,
|
|
walking the chain right-to-left to the rightmost non-proxy entry, so a client
|
|
connecting directly cannot spoof its address; the resolved address is stored
|
|
in the request context by a new middleware and used by the request-logging
|
|
middleware and the login-attempt logs in place of the raw peer address;
|
|
documented in `README.md` and `config.example.yml`.
|
|
- 2026-09-21 blocked networks configuration extending SSRF protection: a
|
|
`blocked_networks` config key taking a list of CIDRs (parsed with `net/netip`,
|
|
an invalid entry aborts startup naming the key and value), added to the
|
|
built-in blocklist rather than replacing it; the built-in ranges extended to
|
|
CGNAT `100.64.0.0/10`, IETF protocol assignments `192.0.0.0/24`, benchmark
|
|
`198.18.0.0/15`, and NAT64 `64:ff9b::/96` (IPv4-mapped forms covered);
|
|
enforcement stays in the dial-time re-resolution so the DNS-rebinding window
|
|
remains closed; documented in `README.md` and `config.example.yml`.
|
|
- 2026-09-21 validate dimensions and fit mode on the encrypted-URL route and the
|
|
token generator (closes #62): `imgcache.ValidateDimension` alone holds the
|
|
`MaxDimension` bound and is used by the path parser, by the new
|
|
`ValidateImageRequest` (which also applies `ValidateFitMode`) and by the
|
|
generator; both the `/v1/image/` and `/v1/e/` routes call
|
|
`ValidateImageRequest`, so an over-limit size or an unknown fit mode is a 400
|
|
rather than an out-of-memory or a 500 from the processor; the URL generator
|
|
answers 400 naming the field for a `width` or `height` that is not a number or
|
|
fails the shared check, a `quality` that is not a number from 1 to 100, a
|
|
`ttl` that is not a number from 0 to the largest number of seconds the expiry
|
|
calculation can hold, or an unknown `fit`; an empty `quality` is 85 and an
|
|
empty `ttl` never expires; the form's width and height inputs stop at 8192
|
|
- 2026-09-21 http.Server hardening (closes #92): added `HTTPReadHeaderTimeout`
|
|
(10s, bounds the slowloris header dribble) and `HTTPIdleTimeout` (120s, bounds
|
|
keep-alive reuse) alongside the existing timeouts and wired them onto the
|
|
server; added a `LimitBody` middleware capping the two form POST bodies
|
|
(`POST /`, `POST /generate`) at `MaxFormBytes` (1 MiB) and returning 413,
|
|
applied ahead of the CSRF middleware so an oversized body is refused as 413
|
|
rather than being read as a missing CSRF token (403); left `WriteTimeout` at
|
|
60s unchanged
|
|
- 2026-08-07 update golangci-lint to v2.12.2 with the canonical `.golangci.yml`
|
|
(v2 schema, `default: all` minus six disabled linters, `lll` 88, tests
|
|
included): bumped the pinned `golangci/golangci-lint:v2.12.2-alpine` image in
|
|
`Dockerfile` and the release-archive sha256 pins in `script/bootstrap`; fixed
|
|
the findings the stricter config surfaced (notably `paralleltest`, `wsl_v5`,
|
|
`goconst`, `lll`, `noinlineerr`, `err113`, `errcheck`, `testpackage` —
|
|
white-box test files renamed to `*_internal_test.go`), including #55's code
|
|
absorbed after it merged, iterating the pinned linter to `0 issues.`; no
|
|
single finding total is substantiable, since golangci-lint's `uniq-by-line`
|
|
reveals new findings on a line as others there are fixed — the documented
|
|
re-measurements were 81 after the #53 merge and 149 after the #55 merge; three
|
|
behavior changes, so not a pure no-op: `Cache.StoreVariant` now takes a
|
|
`context.Context` (`noctx`), so a cancelled request skips its best-effort
|
|
accounting row; `MetadataStorage.Store`'s cleanup defer was dead on `main` and
|
|
leaked `.tmp-*.json` on failure, now fixed with explicit removals; and the
|
|
`signing_key` validation error text gained `value too short: `; the eviction
|
|
loop's uncancellable context is deferred to #102 under a
|
|
`//nolint:contextcheck`; three `//nolint:tagliatelle` directives keep the
|
|
snake_case JSON wire/disk formats unchanged; `make check` green
|
|
- 2026-08-07 implement cache size management and eviction (closes #51): new
|
|
`cache_max_bytes` config key validated by the startup framework (explicit
|
|
values used exactly with no floor, `0` disables the disk cache entirely,
|
|
omitted defaults to max(75% of free space on the filesystem containing
|
|
`<state_dir>/cache/`, 500 MiB), logged at startup); processed variants are now
|
|
tracked in the database (a new `variant_content` table and an LRU timestamp on
|
|
`source_content`) so total usage is two SUMs, never a directory scan on the
|
|
hot path; a background goroutine evicts globally least-recently-used entries
|
|
(variants and source blobs merged) to the limit, woken by a periodic ticker
|
|
and by write-pressure notifications from stores; a source blob and ALL of its
|
|
`source_metadata` references are deleted in one transaction before the file is
|
|
unlinked, so multi-referenced blobs are never removed while referenced and
|
|
rows never point at deleted files; a startup and periodic reconciliation pass
|
|
adopts untracked variant files, drops rows for missing files, removes
|
|
unreachable source blobs, and sweeps stale temp files
|
|
- 2026-08-07 validate configuration on startup, fail fast on bad config (closes
|
|
#52): a config value that is set but unparseable or invalid aborts startup
|
|
naming the key and value (defaults apply only to omitted keys), unknown config
|
|
keys abort startup, a malformed config file aborts instead of being skipped,
|
|
and `state_dir` is verified creatable and writable before the listener binds
|
|
- 2026-08-07 manual test pass of the auth and encrypted URL flows against a
|
|
locally built and running `pixad` (built from `main` at `6573b9d`, port 18099,
|
|
local throwaway config); all six checks passed, plus all nine tests in
|
|
`scripts/manual-test.sh` (closes #49):
|
|
- [x] visit `/` and see the login form: HTTP 200, `Pixa - Login` page with
|
|
`name="key"` password form
|
|
- [x] wrong key shows an error: POST `/` with `key=wrong-key` returned HTTP
|
|
200 login page containing "Invalid signing key"
|
|
- [x] correct signing key shows the generator form: POST `/` returned HTTP
|
|
303 to `/` with
|
|
`Set-Cookie: pixa_session=...; HttpOnly; Secure; SameSite=Strict`; GET
|
|
`/` with that cookie rendered `Pixa - URL Generator` with the
|
|
`/generate` form and logout link
|
|
- [x] a generated encrypted URL serves the image: POST `/generate`
|
|
(ttl=3600) produced a `/v1/e/<token>/img.jpeg` URL that returned HTTP
|
|
200, `Content-Type: image/jpeg`, an 800x600 baseline JPEG of 61706
|
|
bytes
|
|
- [x] an expired URL (short TTL) returns 410: a ttl=1 URL fetched after 3 s
|
|
returned HTTP 410 Gone with
|
|
`{"error":"URL has expired","status":410,...}`
|
|
- [x] logout redirects back to login: GET `/logout` returned HTTP 303 to `/`
|
|
with `Set-Cookie: pixa_session=; Max-Age=0`; subsequent GET `/`
|
|
rendered the login form again
|
|
- 2026-08-07 fix the two remaining gosec findings (G124 in internal/session):
|
|
session cookies now always carry Secure/HttpOnly/SameSite=Strict on both the
|
|
set and clear paths; `make check` green (closes #47)
|
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
|
shims, README Entrypoints section
|
|
- 2026-04-07 extract magic byte detection into internal/magic (#42)
|
|
- 2026-03-25 extract allowlist package from internal/imgcache (#41)
|
|
- 2026-03-25 move schema_migrations table creation into 000.sql (#36)
|
|
- 2026-03-20 enforce and document exact-match-only signature verification (#40)
|
|
- 2026-03-20 bound imageprocessor.Process input read to prevent unbounded memory
|
|
use (#37); consolidate appname into an internal/globals constant (#34)
|
|
- 2026-03-18 parse version prefix from migration filenames (#33)
|
|
- 2026-03-15 QA audit fixes for 1.0/MVP readiness (#25)
|
|
- 2026-03-02 split Dockerfile with pre-built golangci-lint stage for faster CI
|
|
(#23)
|
|
- 2026-02-25 repo policy compliance: CI workflow, hash-pinned images,
|
|
golangci-lint and gosec fixes of that date (#14); arm64 Docker build fix (#16)
|
|
- 2026-01-08 WebP and AVIF encoding support via govips (both former P0 image
|
|
processing items, now done)
|
|
|
|
# Future Steps
|
|
|
|
- P2: security
|
|
- per-origin rate limiting
|
|
- P2: HTTP response handling
|
|
- Last-Modified headers
|
|
- P2: configuration
|
|
- YAML config file support
|
|
- P2: operational
|
|
- optional Sentry error reporting
|
|
- comprehensive request logging
|
|
- Prometheus performance metrics
|
|
- measure the 1k to 5k req/s target with `script/loadtest` on a machine not
|
|
shared with other work
|