# Plan: Go rewrite — M1 kickoff (skeleton + tooling) Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md) and the progress tracker [2026-05-03-2349-go-backend-rewrite-progress.md](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](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](.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](Makefile) — add Go targets, rename `web` → `web-py`, keep `web` as transitional alias to `web-py` until M8. - [.gitignore](.gitignore) — add `bin/` and `go/.cache/` (if any). - [.gitea/workflows/build.yaml](.gitea/workflows/build.yaml) — add `go-build` job that builds and pushes `-go` image. - [CHANGELOG.md](CHANGELOG.md) — top-of-file entry per CLAUDE.md convention. - [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](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. 1. **Skeleton (M1.1)** — `mkdir -p go/{cmd/fuj,internal/{config,logging,web/middleware},build}` and `cd go && go mod init fuj-management/go`. Pin `go 1.26` in `go.mod`. 2. **Config + logger (M1.8, M1.9)** — write `internal/config/config.go` mirroring [scripts/config.py](scripts/config.py): exported constants for `AttendanceSheetID`, `PaymentsSheetID`, `JuniorSheetGID`, env-driven `CredentialsPath`, `BankAccount`, `CacheTTL`, `CacheAPICheckTTL`, `LogLevel`, `FioAPIToken`. Write `internal/logging/logger.go` with a `New() *slog.Logger` honoring `LOG_LEVEL` (`DEBUG|INFO|WARN|ERROR`). 3. **Web middleware + handler (M1.3)** — `internal/web/middleware/timer.go` logs `method path status ms` for every request. `internal/web/server.go` exposes `Run(ctx, addr) error`: `http.ServeMux` with `GET /` returning a minimal HTML hello page that includes `version`, `commit`, and `buildDate` (linker-injected via `-X main.version=…`). 4. **Subcommand dispatcher (M1.2)** — `cmd/fuj/main.go`: - Package-level `var version, commit, buildDate string` for `-ldflags -X` injection. - `os.Args[1]` switch over `server | version | fees | reconcile | sync | infer | help`. M1 implements `server` and `version`; the rest print `: not implemented yet (lands in M2/M4)` and exit 2. - Each subcommand parses its own `flag.NewFlagSet`. `server` flags: `--addr` (default `:8080`). 5. **Lint config (M1.6)** — `go/.golangci.yml` enabling `govet`, `staticcheck`, `errcheck`, `gofumpt`, `unused`. Run `golangci-lint run ./...` to confirm clean. 6. **Makefile (M1.4, M1.5)** — add: ```make 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 :8080 ``` Rename existing `web:` target to `web-py:` and add `web: web-py` as alias. 7. **Dockerfile + CI (M1.7)** — `go/build/Dockerfile`: ```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](.gitea/workflows/build.yaml), add a parallel job: ```yaml 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 ``` 8. **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/config` mirrors [scripts/config.py](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_time` helper that supplies `render_time.total` to templates. Allowlisted as volatile in the future parity diff (M5). - Constants `AttendanceSheetID`, `PaymentsSheetID`, `JuniorSheetGID` are copied verbatim from [scripts/config.py](scripts/config.py); they don't get used until M4 but live in `internal/config` from day one. ## Verification Run from repo root after all changes are in place: ```bash # 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 and `cmd/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**: `-go` proposed. Alternative: separate image repository (`fuj-management-go:`). The suffix keeps things in one registry path; speak up if separate repos are preferred. - **Distroless variant**: `nonroot` chosen for least privilege. If the existing Python container runs as root and the user expects parity, switch to `gcr.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](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](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) — task tracker; tick M1.1–M1.10 here - [Makefile](Makefile) — current target structure (renaming `web` → `web-py`) - [scripts/config.py](scripts/config.py) — source of truth for env vars / IDs that `internal/config` mirrors - [build/Dockerfile](build/Dockerfile) — Python container (unchanged); the new Go Dockerfile lives at `go/build/Dockerfile` - [.gitea/workflows/build.yaml](.gitea/workflows/build.yaml) — extended with parallel `build-go` job