Files
fuj-management/docs/plans/2026-05-04-1115-go-rewrite-m1-kickoff.md
Jan Novak cf0f176d3f feat: Go rewrite M1 — skeleton, tooling, and hello server
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>
2026-05-04 12:05:46 +02:00

234 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<tag>-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.1M1.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 `<cmd>: 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**: `<tag>-go` proposed. 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**: `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.1M1.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