Stand up the Go project alongside the Python backend so both run
independently during migration. `make web-go` builds and serves on :8080;
`make web-py` (alias: `make web`) keeps the Python side on :5001.
- go/: new module `fuj-management/go` (Go 1.26)
- cmd/fuj: stdlib-flag dispatcher; `server` + `version` work,
fees/reconcile/sync/infer stubbed for M2/M4
- internal/config: env loader mirroring scripts/config.py
- internal/logging: slog setup, level taken from config
- internal/web: net/http ServeMux + request-timer middleware
- build/Dockerfile: golang:1.26 → alpine:3 multi-stage image
- .golangci.yml: govet, staticcheck, errcheck, gofumpt, unused
- Makefile: web→web-py alias; go-build/go-test/go-run/go-lint/web-go
- CI: parallel build-go job in .gitea/workflows/build.yaml (<tag>-go image)
- docs/plans/: M1 kickoff plan + progress tracker (M1 complete)
- .claude/settings.json: gofumpt + golangci-lint permissions
Gate: make go-build ✓ make go-lint ✓ make go-test ✓ curl :8080 ✓
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
10 KiB
Plan: Go rewrite — M1 kickoff (skeleton + tooling)
Companion to 2026-05-03-2349-go-backend-rewrite.md and the progress tracker 2026-05-03-2349-go-backend-rewrite-progress.md.
Context
The master plan for a full Go rewrite of the Flask backend is approved
(2026-05-04). No Go code exists yet — this plan executes M1 end-to-end:
a working go/ skeleton, a fuj binary with a server subcommand serving
a hello page on :8080, lint config, Makefile + CI integration, and an
internal/config package mirroring scripts/config.py.
After M1, both backends run side-by-side locally (make web-py on :5001,
make web-go on :8080) — that side-by-side capability is what unblocks
M2's parity testing and every later milestone.
Locked-in decisions
| # | Decision | Choice |
|---|---|---|
| 1 | CLI dispatcher | stdlib flag + os.Args[1] switch (no cobra) |
| 2 | Go module path | fuj-management/go |
| 3 | Go version | 1.26 (latest stable; user toolchain is go1.26.1) |
| 4 | M1 scope | all 10 progress-tracker sub-tasks in one session |
| 5 | Lint | golangci-lint with govet, staticcheck, errcheck, gofumpt, unused |
| 6 | Logging | log/slog text handler, level from LOG_LEVEL env |
| 7 | HTTP | net/http.ServeMux (Go 1.22+ pattern matching) |
| 8 | Container base | golang:1.26 builder → gcr.io/distroless/static:nonroot runtime |
| 9 | CI | extend .gitea/workflows/build.yaml with a go-build job parallel to existing Python build job; tag suffix -go |
Files to create
go/
go.mod # module fuj-management/go, go 1.26
go.sum # empty / generated
.golangci.yml # govet, staticcheck, errcheck, gofumpt, unused
cmd/fuj/main.go # subcommand dispatcher + version vars
internal/
config/config.go # env loader mirroring scripts/config.py
logging/logger.go # slog setup honoring LOG_LEVEL
web/
server.go # `fuj server` handler: ServeMux on :8080, hello page
middleware/timer.go # request-timer middleware (parity with Python `get_render_time`)
build/
Dockerfile # multi-stage golang:1.26 → distroless/static
No embed.FS, no templates, no static assets in M1 — the hello page is
inline HTML in server.go. Templates land in M6.
Files to edit
- Makefile — add Go targets, rename
web→web-py, keepwebas transitional alias toweb-pyuntil M8. - .gitignore — add
bin/andgo/.cache/(if any). - .gitea/workflows/build.yaml — add
go-buildjob that builds and pushes<tag>-goimage. - CHANGELOG.md — top-of-file entry per CLAUDE.md convention.
- docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md — tick M1.1–M1.10 with commit SHAs as they land.
Execution sequence
Order is tight: each step keeps the tree compilable and lint-clean.
-
Skeleton (M1.1) —
mkdir -p go/{cmd/fuj,internal/{config,logging,web/middleware},build}andcd go && go mod init fuj-management/go. Pingo 1.26ingo.mod. -
Config + logger (M1.8, M1.9) — write
internal/config/config.gomirroring scripts/config.py: exported constants forAttendanceSheetID,PaymentsSheetID,JuniorSheetGID, env-drivenCredentialsPath,BankAccount,CacheTTL,CacheAPICheckTTL,LogLevel,FioAPIToken. Writeinternal/logging/logger.gowith aNew() *slog.LoggerhonoringLOG_LEVEL(DEBUG|INFO|WARN|ERROR). -
Web middleware + handler (M1.3) —
internal/web/middleware/timer.gologsmethod path status msfor every request.internal/web/server.goexposesRun(ctx, addr) error:http.ServeMuxwithGET /returning a minimal HTML hello page that includesversion,commit, andbuildDate(linker-injected via-X main.version=…). -
Subcommand dispatcher (M1.2) —
cmd/fuj/main.go:- Package-level
var version, commit, buildDate stringfor-ldflags -Xinjection. os.Args[1]switch overserver | version | fees | reconcile | sync | infer | help. M1 implementsserverandversion; the rest print<cmd>: not implemented yet (lands in M2/M4)and exit 2.- Each subcommand parses its own
flag.NewFlagSet.serverflags:--addr(default:8080).
- Package-level
-
Lint config (M1.6) —
go/.golangci.ymlenablinggovet,staticcheck,errcheck,gofumpt,unused. Rungolangci-lint run ./...to confirm clean. -
Makefile (M1.4, M1.5) — add:
GO_BIN := bin/fuj GO_SRC := go go-build: cd $(GO_SRC) && go build -trimpath \ -ldflags "-X main.version=$$(git describe --tags --always 2>/dev/null || echo dev) \ -X main.commit=$$(git rev-parse --short HEAD) \ -X main.buildDate=$$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ -o ../$(GO_BIN) ./cmd/fuj go-test: cd $(GO_SRC) && go test -race ./... go-run: go-build ./$(GO_BIN) $(ARGS) go-lint: cd $(GO_SRC) && golangci-lint run ./... web-go: go-build ./$(GO_BIN) server --addr :8080Rename existing
web:target toweb-py:and addweb: web-pyas alias. -
Dockerfile + CI (M1.7) —
go/build/Dockerfile:FROM golang:1.26 AS build WORKDIR /src COPY go/go.mod go/go.sum ./ RUN go mod download COPY go/ ./ ARG GIT_TAG=unknown ARG GIT_COMMIT=unknown ARG BUILD_DATE=unknown RUN CGO_ENABLED=0 go build -trimpath \ -ldflags "-s -w -X main.version=${GIT_TAG} -X main.commit=${GIT_COMMIT} -X main.buildDate=${BUILD_DATE}" \ -o /out/fuj ./cmd/fuj FROM gcr.io/distroless/static:nonroot COPY --from=build /out/fuj /usr/local/bin/fuj EXPOSE 8080 USER nonroot:nonroot ENTRYPOINT ["/usr/local/bin/fuj","server"]In .gitea/workflows/build.yaml, add a parallel job:
build-go: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: docker login ... - run: | docker build -f go/build/Dockerfile \ --build-arg GIT_TAG=$TAG \ --build-arg GIT_COMMIT=${{ github.sha }} \ --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \ -t gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG-go . docker push gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG-go -
Smoke verify (M1.10) — see Verification section below; then append a CHANGELOG entry and tick M1 boxes in the progress tracker with commit SHAs.
Reuse / parity with Python side
internal/configmirrors scripts/config.py exactly — same env var names, same defaults. No new env knobs in M1.- Request-timer middleware records elapsed milliseconds; this is the Go-side
equivalent of the Python
get_render_timehelper that suppliesrender_time.totalto templates. Allowlisted as volatile in the future parity diff (M5). - Constants
AttendanceSheetID,PaymentsSheetID,JuniorSheetGIDare copied verbatim from scripts/config.py; they don't get used until M4 but live ininternal/configfrom day one.
Verification
Run from repo root after all changes are in place:
# 1. Builds clean
make go-build && test -x bin/fuj
# 2. Lint clean
make go-lint
# 3. Subcommand dispatcher works
./bin/fuj help
./bin/fuj version # prints version/commit/buildDate
./bin/fuj fees # prints "not implemented yet" and exits 2
# 4. Server runs and hello page is served
make web-go &
GO_PID=$!
sleep 1
curl -sf http://localhost:8080/ | grep -q "fuj"
kill $GO_PID
# 5. Side-by-side: both backends up
make web-py & # :5001
PY_PID=$!
make web-go & # :8080
GO_PID=$!
sleep 2
curl -sf http://localhost:5001/ >/dev/null && echo "py OK"
curl -sf http://localhost:8080/ >/dev/null && echo "go OK"
kill $PY_PID $GO_PID
# 6. Race-free unit tests pass (none yet beyond a smoke test, but harness works)
make go-test
# 7. Docker image builds locally
docker build -f go/build/Dockerfile -t fuj-go:dev .
docker run --rm -p 8080:8080 fuj-go:dev &
sleep 1
curl -sf http://localhost:8080/ >/dev/null && echo "container OK"
docker stop $(docker ps -lq)
All seven steps must succeed. Then update the progress tracker and CHANGELOG.
Out of scope for M1 (deferred to later milestones)
- Domain logic —
czech.Normalize, fees, reconcile, etc. → M2. - Fixture capture and parity tests → M3.
- Sheets/Drive/Fio clients and
internal/io/*→ M4. /api/*JSON routes andcmd/parity→ M5.- HTML templates, static assets,
embed.FS→ M6. - Removing the Python backend → M8.
Open items / forks the user can override at review
- CI tag suffix:
<tag>-goproposed. Alternative: separate image repository (fuj-management-go:<tag>). The suffix keeps things in one registry path; speak up if separate repos are preferred. - Distroless variant:
nonrootchosen for least privilege. If the existing Python container runs as root and the user expects parity, switch togcr.io/distroless/static(root). Doesn't affect M1 functionality. - Hello page content: minimal HTML mentioning
fuj, version, commit, build date, link list to future routes. Speak up if you want a different shape — it gets thrown away in M6 anyway.
Critical files
- docs/plans/2026-05-03-2349-go-backend-rewrite.md — master plan (approved 2026-05-04)
- docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md — task tracker; tick M1.1–M1.10 here
- Makefile — current target structure (renaming
web→web-py) - scripts/config.py — source of truth for env vars / IDs that
internal/configmirrors - build/Dockerfile — Python container (unchanged); the new Go Dockerfile lives at
go/build/Dockerfile - .gitea/workflows/build.yaml — extended with parallel
build-gojob