Files
at-container-registry/docs/DEVELOPMENT.md
T
Evan JarrettandClaude Fable 5.1 0080957a21 remove the runtime test_mode switch; the testmode build tag is the only one
server.test_mode survived the build-tag refactor only to feed five
behavioral branches: the registry's fall-back to the default hold when
the user's hold is unreachable, backfill warning suppression for
external holds, the appview listener close on shutdown, the hold's
relay-crawl skip, and the hold's appview-issuer tolerance. Every one of
them is a "this is a local development build" decision, which is what
the tag already says, and local development has to build with the tag
or nothing resolves. So they read atproto.TestModeBuild now, and the
flag, SetTestMode, IsTestMode, the middleware option, the backfill
constructor parameter, the never-read field on RemoteHoldAuthorizer,
the example and template YAML lines, and the docker-compose env vars
are gone. The registry keeps the fallback as a field seeded from the
constant so the production-path tests can pin it off under the tag.

The 24 SetTestMode calls in tests were dead already: stripping them and
running the affected packages tagged changed nothing.

Tests that resolve a loopback did:web used to t.Fatal naming the tag,
which left a bare `go test ./...` permanently red in five packages.
They now live under `//go:build testmode`: whole-file constraints where
every test needs it, and sibling *_testmode_test.go files holding the
moved tests plus their fixtures where a file mixed. The harness carries
the constraint too, with its package doc in an untagged doc.go so the
package still exists without it. An untagged run compiles those tests
out and passes; make test keeps the tag and runs everything.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwYzaG3Yy7uA8FbZ5qk3tQ
2026-09-11 11:09:44 -05:00

15 KiB

Development Workflow for ATCR

Goal

Run the ATCR services (AppView, Hold, Labeler) locally with hot reload so that Go, template, CSS, and JS changes show up after a fast incremental rebuild instead of a full production image rebuild.

The mechanism is Air (github.com/air-verse/air) running inside a development container. Air watches the mounted source tree and rebuilds the relevant binary on change. Production images are unaffected — they use the multi-stage Dockerfile.appview / Dockerfile.hold / Dockerfile.scanner builds with embedded assets.

How It Works

All UI assets are embedded into the binary via //go:embed in pkg/appview/ui.go (//go:embed public and //go:embed templates/**/*.html). There is no filesystem-vs-embed toggle and no ATCR_DEV_MODE switch — the binary always serves embedded assets. Hot reload therefore works by having Air rebuild the binary whenever a watched file changes, not by reading templates off disk at request time.

When Air rebuilds the AppView binary it runs a pre_cmd of go generate ./pkg/appview/.... The //go:generate directive in pkg/appview/ui.go shells out to npm run build:appview, which regenerates the CSS bundle (pkg/appview/public/css/style.css), the JS bundle (pkg/appview/public/js/bundle.min.js), and the icon sprite (pkg/appview/public/icons.svg) before they are re-embedded into the new binary.

Do not run npm run css:build / npm run js:build manually. The go generate step driven by Air handles asset builds. Editing a source asset (CSS/JS/template) and saving triggers an Air rebuild, which regenerates and re-embeds the assets automatically.

Architecture Flow

┌─────────────────────────────────────────────────────┐
│ Your editor                                          │
│ Edit: *.go, templates/*.html, src/css/*, src/js/*   │
└─────────────────┬───────────────────────────────────┘
                  │ (files saved to disk)
                  ▼
┌─────────────────────────────────────────────────────┐
│ Volume mount (docker-compose.yml)                    │
│   volumes:                                           │
│     - .:/app:z   (entire codebase mounted)           │
└─────────────────┬───────────────────────────────────┘
                  │ (changes appear in container)
                  ▼
┌─────────────────────────────────────────────────────┐
│ Container (mirror.gcr.io/library/golang:1.26.7)     │
│                                                      │
│  ┌──────────────────────────────────────┐           │
│  │ Air (github.com/air-verse/air)        │           │
│  │ poll = true, poll_interval = 500      │           │
│  │ Watches: *.go *.html *.css *.js       │           │
│  │                                       │           │
│  │ On change:                            │           │
│  │   1. pre_cmd: go generate (npm build) │           │
│  │   2. cmd: go build → ./tmp/atcr-*     │           │
│  │   3. restart binary (entrypoint)      │           │
│  └──────────────────────────────────────┘           │
│                 │                                    │
│                 ▼                                    │
│  ATCR AppView (serves embedded assets from           │
│  the freshly built binary)                           │
└─────────────────────────────────────────────────────┘

Polling (poll = true, poll_interval = 500) is required: inotify/fsnotify events do not propagate reliably across Docker bind mounts, so Air polls the mounted tree every 500ms instead.

Files Involved

File Purpose
Dockerfile.dev Single dev image used by all three services. golang:1.26.7-trixie base with Air, Node/npm, and SQLite installed. Source comes from a volume mount, not COPY. Accepts an AIR_CONFIG build arg to select which .air.*.toml to run.
docker-compose.yml The dev compose file (this is the primary compose file — there is no separate docker-compose.dev.yml). Defines atcr-appview, atcr-hold, atcr-labeler, and victorialogs, all on a fixed 172.28.0.0/24 network.
.air.toml AppView Air config (default AIR_CONFIG).
.air.hold.toml Hold Air config (selected via AIR_CONFIG=.air.hold.toml).
.air.labeler.toml Labeler Air config (selected via AIR_CONFIG=.air.labeler.toml).

Dockerfile.dev

# Development image with Air hot reload
FROM mirror.gcr.io/library/golang:1.26.7-trixie

ARG AIR_CONFIG=.air.toml

ENV DEBIAN_FRONTEND=noninteractive
ENV AIR_CONFIG=${AIR_CONFIG}

RUN apt-get update && \
    apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev curl nodejs npm && \
    rm -rf /var/lib/apt/lists/* && \
    go install github.com/air-verse/air@latest

WORKDIR /app

# Copy go.mod first for layer caching
COPY go.mod go.sum ./
RUN go mod download

# For development: source mounted as volume, Air handles builds
CMD ["sh", "-c", "air -c ${AIR_CONFIG}"]

Note the Air install path is github.com/air-verse/air@latest. The old github.com/cosmtrek/air module is archived and must not be used.

.air.toml (AppView)

This is the real file — keep it in sync rather than copying a hand-written version. Load-bearing settings:

root = "."
tmp_dir = "tmp"

[build]
# Use polling for Docker volume mounts (inotify doesn't work across mounts)
poll = true
poll_interval = 500
# Pre-build: generate assets if missing (each string is a shell command)
pre_cmd = ["go generate ./pkg/appview/..."]
# GO_TAGS (set by Dockerfile.dev / `make dev`) appends build tags, e.g. testmode.
cmd = "go build -tags billing${GO_TAGS:+,$GO_TAGS} -buildvcs=false -o ./tmp/atcr-appview ./cmd/appview"
entrypoint = ["./tmp/atcr-appview", "serve", "--config", "config-appview.example.yaml"]
include_ext = ["go", "html", "css", "js"]
exclude_dir = ["bin", "tmp", "vendor", "deploy", "docs", ".git", "dist", "node_modules", "scanner", "pkg/hold", "pkg/labeler"]
exclude_regex = ["_test\\.go$", "cbor_gen\\.go$", "\\.min\\.js$", "public/css/style\\.css$", "public/icons\\.svg$"]
delay = 3000
stop_on_error = true
send_interrupt = true
kill_delay = 3000

Key points that differ from a naive config:

  • poll = true / poll_interval = 500 — needed for Docker bind mounts.
  • pre_cmd runs go generate ./pkg/appview/..., which regenerates and re-embeds CSS/JS/icons before the build.
  • cmd builds with -tags billing (AppView dev runs with billing support) and -buildvcs=false. Air runs the command through sh -c, so ${GO_TAGS:+,$GO_TAGS} appends whatever GO_TAGS holds. docker-compose passes GO_TAGS: testmode as a build arg to Dockerfile.dev, and make dev exports the same, so every dev build is a testmode build: pkg/atproto/indigo_local.go replaces indigo_prod.go, letting a did:web on an IP, localhost, or any port (the hold's did:web:localhost%3A8080, the appview's did:web:127.0.0.1%3A5000, the labeler's did:web:172.28.0.4%3A5002) resolve over plain HTTP, and letting the OAuth client reach a PDS on loopback. Production images never set the tag and cannot be configured to resolve local DIDs at runtime.
  • entrypoint is the full argv for the built binary: it runs serve --config config-appview.example.yaml. (The example config is the dev base config; env vars in docker-compose.yml override it.)
  • The exclude_regex deliberately ignores the generated asset outputs (*.min.js, public/css/style.css, public/icons.svg) so regeneration does not trigger an infinite rebuild loop.

.air.hold.toml and .air.labeler.toml are analogous: they build ./cmd/hold / ./cmd/labeler, generate ./pkg/hold/... (the labeler has no generate step), and exclude the other services' packages from watching.

Configuration via Environment Variables

docker-compose.yml sets a base config file per service via the Air entrypoint (config-appview.example.yaml, config-hold.example.yaml, config-labeler.example.yaml) and overrides specific values with environment variables. Viper maps env var names from the YAML path, prefixed with the service prefix and joined with _.

Real AppView env vars (note these are the Viper-mapped names, not invented shorthand):

Env var Maps to
ATCR_SERVER_ADDR server.addr (listen address, e.g. :5000)
ATCR_SERVER_BASE_URL server.base_url
ATCR_SERVER_MANAGED_HOLDS server.managed_holds — comma-separated DID list; the first entry is the default blob-storage hold. Viper splits on commas.
ATCR_AUTH_CERT_PATH auth.cert_path
ATCR_JETSTREAM_BACKFILL_ENABLED jetstream.backfill_enabled
ATCR_LABELER_DID labeler.did
ATCR_LOG_LEVEL log.level

There is no ATCR_DEV_MODE variable anywhere in the codebase. Likewise ATCR_HTTP_ADDR, ATCR_BASE_URL, ATCR_DEFAULT_HOLD_DID, ATCR_AUTH_KEY_PATH, and ATCR_BACKFILL_ENABLED are not real — use the Viper-mapped names above.

Hold and Labeler use the HOLD_ and LABELER_ prefixes respectively (e.g. HOLD_SERVER_PUBLIC_URL, HOLD_SERVER_APPVIEW_DID, LABELER_LABELER_PUBLIC_URL). See docker-compose.yml for the dev values.

S3/Storj credentials and shared secrets are loaded from an external ../atcr-secrets.env file referenced via env_file: in docker-compose.yml.

Usage

Start the dev environment

docker-compose.yml is the dev compose file, so no -f flag is needed:

# Build and start everything (appview, hold, labeler, victorialogs)
docker compose up --build

# Or in the background
docker compose up -d

# Tail a single service
docker compose logs -f atcr-appview

Services bind to fixed ports on the host:

On a clean start you should see Air bootstrap, run the pre-build generate step, build, and launch the binary, e.g.:

atcr-appview  | watching .
atcr-appview  | !exclude tmp
atcr-appview  | running pre_cmd: go generate ./pkg/appview/...
atcr-appview  | building...
atcr-appview  | running...
atcr-appview  | <appview startup logs: server listening on :5000 ...>

Daily workflow

  • Edit Go code → save → Air rebuilds (go build) and restarts the binary in a few seconds.
  • Edit a template (pkg/appview/templates/**/*.html) → save → Air rebuilds so the new template is re-embedded.
  • Edit CSS source (pkg/appview/src/css/main.css) → save → the pre_cmd go generate regenerates pkg/appview/public/css/style.css via npm run build:appview, then the binary rebuilds.
  • Edit JS source (pkg/appview/src/js/main.js) → save → go generate regenerates pkg/appview/public/js/bundle.min.js, then the binary rebuilds.

Important asset-source vs. generated-output distinctions:

You edit (source) Do NOT edit (generated)
pkg/appview/src/css/main.css pkg/appview/public/css/style.css
pkg/appview/src/js/main.js pkg/appview/public/js/bundle.min.js
icon references in templates pkg/appview/public/icons.svg

Refresh the browser after the rebuild completes.

Stop the dev environment

# Stop containers
docker compose down

# Stop and wipe volumes (fresh DB / PDS / labeler state)
docker compose down -v

Local Development (No Docker)

For a tighter loop you can run a single service on the host. The make dev target runs the AppView under Air using .air.toml:

make dev

make dev ensures Air is installed (go install github.com/air-verse/air@latest), builds the generated assets, and runs air -c .air.toml.

You can also run Air directly, or skip hot reload entirely:

# Air, AppView config (GO_TAGS makes it a testmode build, as `make dev` does)
GO_TAGS=testmode air -c .air.toml

# No hot reload — build and run once
go build -tags billing,testmode -o bin/atcr-appview ./cmd/appview
./bin/atcr-appview serve --config config-appview.example.yaml

Leave testmode off only when the appview talks exclusively to public identities (a real PDS, a hold on a public HTTPS hostname); with it off, any did:web naming an IP, localhost, or a port fails to resolve, exactly as in production. Tests need the tag too: make test sets it, and a bare go test ./... fails fast in the tests that depend on it with a message naming the tag.

Running on the host requires a working toolchain for the build: Go 1.26.7 (see go.work), Node/npm (for the go generate asset step), and SQLite headers. Override config values with the ATCR_* env vars listed above, or edit your local config file.

Production Builds (Unchanged)

Production images use the multi-stage Dockerfiles and embed all assets at compile time:

make docker            # build appview + hold + scanner images
make docker-appview    # just the appview image

These do not involve Air, do not bind-mount source, and serve embedded assets exactly as the dev binary does — the only difference is that the dev container rebuilds on change.

Troubleshooting

Air not rebuilding

docker compose logs atcr-appview
# Confirm Air is running and polling. poll=true is required for bind mounts;
# without it, saved files are never detected.
docker compose restart atcr-appview

Confirm your file type is in include_ext (go, html, css, js) and that you are editing a source file, not a generated output excluded by exclude_regex.

Go build failing

docker compose logs atcr-appview
# Air prints build errors inline and (with stop_on_error=true) holds the old
# binary until the build succeeds again. Fix the error and save.

Volume mount not working

docker compose exec atcr-appview ls -la /app
# Should show your source tree. On macOS/Windows check Docker Desktop file
# sharing for the project directory.

Asset changes not showing

CSS/JS/icon changes only take effect after the go generate pre-build runs and the binary rebuilds. If a save did not trigger a rebuild, you likely edited a generated output file (excluded from watching) instead of its source under pkg/appview/src/.