Imported from bizjs/Dockery (
AGENTS.md). Install upstream withnpx skills add bizjs/Dockery. Copyright stays with the author.
CLAUDE.md
Guidance for Claude Code working in this repository.
Authoritative design reference:
docs/dockery-design.md. This file summarizes; the design doc is source of truth.
Repository layout
apps/web-ui/— React 19 + TypeScript SPA (Vite / rolldown-vite, Tailwind v4, shadcn/ui, React Router v7). All browser code lives here.apps/api/— Go 1.25 + Kratos v2 + kratoscarf backend. Single static binary (dockery-api). ent ORM on SQLite (modernc.org/sqlite, no CGO).docker/—Dockerfile(four-stage: ui-builder / api-builder / registry-src / runtime) androotfs/(nginx, supervisord, registryconfig.yml, apiconfig.yamldropped into the container image).docker-compose.dev.yaml— local build+run of the all-in-one image from source; driven bymake dev. Binds host:5001→ container:5000, dev-isolateddockery-dev-datavolume.docker-compose.ghcr.yml— consumer-side deploy: pulls the prebuiltghcr.io/bizjs/dockeryimage instead of building. The README quickstart points here for production.Makefile(repo root) —make dev/dev-logs/dev-down/dev-resetwrap the dev compose stack.docs/dockery-design.md— authoritative design doc (CN).docs/distribution-analysis.md— upstream Distribution Registry behavior reference..github/workflows/build-and-push.yml— builds & pushesghcr.io/<owner>/<repo>onv*tags (multi-arch:linux/amd64,linux/arm64).
Not in repo yet (planned): pnpm workspace root.
Common commands
Frontend (run from apps/web-ui/):
pnpm install
pnpm dev # Vite on :5173; proxies /api /token /v2 → :5001 (make dev stack or make run api)
pnpm build # tsc -b && vite build
pnpm lint
pnpm test # vitest (jsdom)
pnpm ui # shadcn CLI
Backend (run from apps/api/):
make init # go mod download + tool installs
make api # regenerate ent / wire (only after schema edits)
make run # dockery-api -conf ./configs (HTTP on :5001)
go test ./...
# One-shot user management (no HTTP server):
./bin/dockery-api -conf ./configs user list
./bin/dockery-api -conf ./configs user create alice write
./bin/dockery-api -conf ./configs user grant alice 'alice/*,shared/app'
./bin/dockery-api -conf ./configs user passwd alice
./bin/dockery-api -conf ./configs user revoke 42
./bin/dockery-api -conf ./configs user delete alice
Full stack via compose (local build+run; make dev wraps docker compose -f docker-compose.dev.yaml up --build -d):
make dev DOCKERY_ADMIN_PASSWORD=changeme
open http://localhost:5001 # first login: admin / changeme
Docker daemon needs "insecure-registries": ["localhost:5001"] until TLS lands.
Architecture (single container)
Three long-running processes managed by supervisord (PID 1, priorities 10/20/30):
dockery-api(:3001) — SQLite at/data/db/dockery.db, Ed25519 key at/data/config/jwt-private.pem, JWKS at/data/config/jwt-jwks.json. Runs first.registry(distribution 3.1.0,:5001) — polls forjwt-jwks.json+webhook-secret(200 ms × ~150, ~30 s timeout) beforeexec; the startup wrappersed-substitutes__WEBHOOK_SECRET__into a/run/registry-config.ymlcopy so the baked image never ships a known secret. Validates incoming tokens viaauth.token.jwks; POSTspush/pull/deleteevents tohttp://127.0.0.1:3001/api/internal/registry-eventsvia thenotifications.endpointsblock.nginx(:5000→ host:5001) — sole public port. Routes:/→ static UI (/usr/share/nginx/html)/api/*,/token,/healthz,/readyz→:3001/v2/*→:5001
Two auth paths share one permission model:
- Docker CLI:
docker push→ nginx → registry returns 401 withWWW-Authenticate: Bearer realm=…/token→ docker hits/token(Basic Auth) → dockery-api signs an Ed25519 JWT with scopedaccessclaim → registry verifies via JWKS. - Web UI: browser → nginx →
/api/registry/*on dockery-api → (session check) → mints short-lived admin-scoped JWT for itself → forwards to127.0.0.1:5001→ filters catalog by repo patterns before returning.
Catalog cache (repo_meta)
See docs/dockery-design.md §8.6 — that's the source of truth. Short version: the Catalog page reads a denormalized repo_meta table kept in sync by distribution webhooks + a periodic reconciler + the refresh worker in biz/RepoMetaUsecase. HTTP primitives live in internal/util/registryfetch/ and are shared by biz and service/registry's enrichManifestList.
Three layers of cache freshness:
- Webhooks — push/pull/delete from distribution refresh the affected row within milliseconds. Source of truth in steady state.
- Reconciler (30 min cycle) — diffs
/v2/_catalogagainst the cache and (a) enqueues refresh for repos missing from cache, (b) deletes rows whose repo vanished upstream, and (c) enqueues refresh for any row whoserefreshed_atis older than 24h. The 24h sweep is the self-healing path for algorithm changes (e.g.pickRepresentativeTagsemver fix) that would otherwise leave historical rows frozen at the old derived value. Spread across the 48 cycles per day, this costs at most ~1/48th of the cache per cycle. - Post-GC resync — admin-triggered GC always finishes with
resyncCache(ctx)(ReconcileOnce + EnqueueRefresh for every cached repo, audited asregistry.cache.resynced). Use this when you don't want to wait the up-to-24h reconciler window after a code change.
Roles
Three roles in the users table; users.role alone dictates actions (no per-row action list):
| role | registry:catalog:* | repo actions |
|---|---|---|
admin |
yes | all, on all repos |
write |
no | pull + push + delete (see default below) |
view |
no | pull (see default below) |
repo_permissions stores one row per (user_id, glob_pattern). admin bypasses this table. Default when the user has no rows: unrestricted — the role's actions apply to every repo. Admin narrows this by adding patterns; the first pattern switches the user from "all repos" to "only repos matching any pattern". Applies to both the UI catalog filter and the docker CLI token realm.
Frontend structure (apps/web-ui/src/)
- Entry:
main.tsx→router.tsx(React Router v7). Routes:/login·/(Catalog, AuthGuard) ·/tag-list/:image·/admin/users(AuthGuardadminOnly).App.tsxis Vite scaffold — unused, don't start from it. services/registry.service.ts— only entry point for image data; composes manifest + config blob intoImageInfo. Calls/api/registry/*(not/v2/).services/auth.service.ts,services/user.service.ts— thinapi.*wrappers over the Go backend.services/api.ts— fetch wrapper with kratoscarf envelope{code, message, data}unwrap +ApiError.hooks/use-current-user.ts— singletonCurrentUserViewModel;/mebootstrap, login/logout mutate state;AuthGuardandUserMenuobserve it.lib/viewmodel/— Valtio-based OOP state (see itsREADME.md). Each page hasindex.tsx+view-model.ts.
Backend structure (apps/api/internal/)
conf/— yaml config schema (conf.proto+dockery.go).data/+data/ent/— ent client + repo adapters for User / RepoPermission / AuditLog / RepoMeta.biz/— usecases:user,permission,token(JWT signing),keystore(Ed25519 + JWKS),webhook_secret(shared Bearer for distribution notifications),repo_meta(catalog cache + refresh worker),reconciler(periodic cache-vs-registry diff).service/— HTTP handlers:system,auth,user,permission(CRUD forrepo_permissions),registry(UI proxy +/overviewcache-backed endpoint),token(Docker CLI realm),admin(GC / key rotation / audit),webhook(distribution event receiver at/api/internal/registry-events).server/http.go— kratoscarf wiring (ErrorEncoder / CORS / Secure / Recovery / RequestID / Validator / ResponseWrapper).server/routes.go— three-tier grouping: public / session / session+admin.server/middleware.go—RequireSession,RequireAdmin.util/scope/— Docker scope parsing + glob matching + role→actions mapping.cmd/api/main.go+user_cmd.go+wire_gen.go— entry point;usersubcommand dispatches touser_cmd.gowithout starting HTTP.
UI conventions
shadcn/ui in components/ui/ (added via pnpm ui). Tailwind v4 via @tailwindcss/vite (no tailwind.config.js). Path alias @/ → src/. React 19 + babel-plugin-react-compiler on. Bundler is rolldown-vite (pnpm override).
Environment variables
Runtime (container / compose):
DOCKERY_ADMIN_USERNAME(defaultadmin) — first-boot admin account name.DOCKERY_ADMIN_PASSWORD(required on first boot, otherwise api fatals) — first-boot admin password.REGISTRY_AUTH_TOKEN_REALM(defaulthttp://localhost:5001/token) — URL the docker CLI reaches back to for tokens; must match the external URL of the Dockery deployment.REGISTRY_STORAGE_*— passed through to distribution (S3 etc.).DOCKERY_OTEL_ENDPOINT(default unset → telemetry disabled) — OTLP/HTTP endpoint for distribution's built-in tracing, e.g.http://jaeger:4318. Set it to opt in. Dockerfile pinsOTEL_SDK_DISABLED=trueso the default container stays silent (otherwise distribution v3 spamsconnection refusedagainstlocalhost:4318); the registry wrapper flips that when this var is present and exportsOTEL_EXPORTER_OTLP_ENDPOINTto it.
Build-time (Vite, apps/web-ui/.env*): VITE_REGISTRY_URL (falls back to window.location.origin), plus a few legacy VITE_* flags retained for now.
Progress (see design §11 for detail)
- M1 ✅ skeleton + container + kratoscarf
- M2 ✅ keys + tokens + users + CLI + registry token auth
- M3 ✅ UI session + login + admin/users page + UI-driven permission granting
- M3.5 ✅ repo_meta catalog cache (webhooks + reconciler +
/api/registry/overview) — replaces per-repo N+1 fan-out - M4 ⬜ GC / key rotation / audit log writes / README rebrand
Release
Push a v* tag → .github/workflows/build-and-push.yml builds & pushes ghcr.io/<owner>/<repo>:latest + :<semver> (multi-arch). No separate -ui image — Dockery ships as one image.
Changelog (semi-automatic via git-cliff)
cliff.toml drives the generator. The release workflow, after a successful build, runs git cliff --latest --strip header to:
- Create/update the GitHub Release with the current tag's section as the body.
- Splice the same section into
CHANGELOG.mdonmainright above the previous## [x.y.z]heading (plain awk — notgit cliff --prepend, which would write above the# Changelogpreamble) and push the result back with[skip ci]so it doesn't retrigger the build.
Implications for commit style: commit messages are now the source of truth for the changelog. Use conventional-commit prefixes — feat:, fix:, perf:, refactor:, docs: land in sections; chore: / ci: / build: / test: / style: and merge commits are skipped; scopes render as bolded prefixes (**registry**: …). Unconventional messages fall through to an "其他" group so nothing disappears silently. Hand-written entries for 0.1.0–0.3.0 are preserved because the workflow only splices the --latest section.