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

10 KiB
Raw Blame History

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

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: 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:

    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:

    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
    
  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 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; 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:

# 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/parityM5.
  • HTML templates, static assets, embed.FSM6.
  • 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