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>
234 lines
10 KiB
Markdown
234 lines
10 KiB
Markdown
# 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.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 `<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.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
|