mirror of
https://github.com/yanislav-igonin/micrach
synced 2026-07-27 05:14:17 +03:00
* chore: add agents md * docs: expand repository and setup guides * docs: plan project modernization * docs: add Wave 1 implementation plan * test: characterize legacy behavior * build: update Go and Fiber v2 * fix(gateway): preserve duplicate auth behavior * refactor(db): migrate to pgx v5 * refactor: replace deprecated I/O APIs * build: add local PostgreSQL compose * build: add multi-stage image * ci: replace legacy deployment workflow * fix(deps): update vulnerable Go modules * docs: document modern development workflow
1307 lines
32 KiB
Markdown
1307 lines
32 KiB
Markdown
# Wave 1 Modernization Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Establish a supported Go 1.26 baseline with current Fiber v2 and pgx v5 dependencies, local PostgreSQL infrastructure, build-only CI, a reproducible container image, and focused regression tests without changing application behavior or architecture.
|
|
|
|
**Architecture:** Preserve the existing Fiber controllers, global configuration and repository objects, PostgreSQL schema, server-rendered templates, and filesystem upload layout. Apply compatibility changes in reviewable slices: characterize behavior first, update the HTTP/toolchain dependencies, migrate pgx with explicit context propagation, modernize deprecated APIs, then replace development and CI infrastructure. Fiber v3 remains a separate Wave 2 implementation after this plan is fully green.
|
|
|
|
**Tech Stack:** Go 1.26.5, Fiber v2.52.14, Fiber HTML template v2.1.3, pgx v5.10.0, PostgreSQL 18.4, Docker Compose, GitHub Actions, Dependabot, and `govulncheck` v1.6.0.
|
|
|
|
## Global Constraints
|
|
|
|
- Preserve all routes, templates, HTTP statuses, database schema, upload paths, and minimal-JavaScript behavior.
|
|
- Preserve existing package boundaries; do not redesign handlers or repositories.
|
|
- Do not add database migrations, Redis, a proxy, a queue, or another service.
|
|
- Run the application on the host with `go run .` or `make dev`; Compose runs PostgreSQL only.
|
|
- Keep Fiber on v2 throughout Wave 1.
|
|
- Do not publish container images or deploy from CI.
|
|
- Never use a production or shared PostgreSQL database for tests.
|
|
- Do not commit `.env`, uploads, binaries, credentials, or generated test artifacts.
|
|
|
|
## File Map
|
|
|
|
**Create:**
|
|
|
|
- `config/config_test.go`: configuration default and parsing characterization.
|
|
- `utils/utils_test.go`: post and upload validation boundaries.
|
|
- `controllers/threads_controller_test.go`: DB-free HTTP error-path smoke tests.
|
|
- `repositories/files_repository_test.go`: pgx v5 integer-array conversion.
|
|
- `db/db_test.go`: PostgreSQL undefined-table error classification.
|
|
- `.dockerignore`: exclude local and generated state from image context.
|
|
- `.github/dependabot.yml`: reviewed Go module and Actions updates.
|
|
|
|
**Modify:**
|
|
|
|
- `go.mod`, `go.sum`: Go line and dependency graph.
|
|
- `main.go`: Fiber template v2 import, startup context, and contextual startup calls.
|
|
- `controllers/threads_controller.go`: request context propagation and checked transaction completion.
|
|
- `repositories/posts_repository.go`: pgx v5 imports, contextual methods, and rows lifecycle.
|
|
- `repositories/files_repository.go`: pgx v5 imports, contextual methods, and native integer-array encoding.
|
|
- `repositories/seeds.go`: explicit startup context.
|
|
- `db/db.go`: pgx v5 pool, startup context, rows lifecycle, and SQLSTATE handling.
|
|
- `build/css.go`, `files/get_files_in_folder.go`, `gateway/connect.go`: replace deprecated `ioutil`; close gateway response bodies.
|
|
- `Dockerfile`: multi-stage non-root application image.
|
|
- `docker-compose.yml`: PostgreSQL-only local infrastructure.
|
|
- `.env.example`: safe local defaults matching Compose.
|
|
- `Makefile`: host application and infrastructure commands.
|
|
- `.github/workflows/push.yml`: read-only verification CI.
|
|
- `README.md`, `AGENTS.md`: supported versions and workflows.
|
|
|
|
**Delete:**
|
|
|
|
- `.github/workflows/codesee-arch-diagram.yml`: obsolete CodeSee integration.
|
|
|
|
---
|
|
|
|
### Task 1: Add Behavior Characterization Tests
|
|
|
|
**Files:**
|
|
|
|
- Create: `config/config_test.go`
|
|
- Create: `utils/utils_test.go`
|
|
- Create: `controllers/threads_controller_test.go`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: existing unexported configuration helpers, `utils.ValidatePost`, `controllers.GetThreads`, and `controllers.GetThread`.
|
|
- Produces: regression tests that must pass before and after dependency migration.
|
|
|
|
- [ ] **Step 1: Record the pre-change baseline**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
go test ./...
|
|
go vet ./...
|
|
go build ./...
|
|
```
|
|
|
|
Expected: all commands exit 0; packages report no test files.
|
|
|
|
- [ ] **Step 2: Add configuration characterization tests**
|
|
|
|
Create `config/config_test.go`:
|
|
|
|
```go
|
|
package config
|
|
|
|
import "testing"
|
|
|
|
func TestGetValueOrDefaultBoolean(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value string
|
|
defaultValue bool
|
|
want bool
|
|
}{
|
|
{name: "default true", defaultValue: true, want: true},
|
|
{name: "default false", defaultValue: false, want: false},
|
|
{name: "explicit true", value: "true", want: true},
|
|
{name: "explicit false", value: "false", defaultValue: true, want: false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := getValueOrDefaultBoolean(tt.value, tt.defaultValue); got != tt.want {
|
|
t.Fatalf("getValueOrDefaultBoolean() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetValueOrDefaultInt(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
value string
|
|
defaultValue int
|
|
want int
|
|
}{
|
|
{name: "default", defaultValue: 3000, want: 3000},
|
|
{name: "configured", value: "8080", defaultValue: 3000, want: 8080},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := getValueOrDefaultInt(tt.value, tt.defaultValue); got != tt.want {
|
|
t.Fatalf("getValueOrDefaultInt() = %d, want %d", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDefaultConfigs(t *testing.T) {
|
|
for _, key := range []string{
|
|
"ENV", "PORT", "IS_DB_SEEDED", "IS_RATE_LIMITER_ENABLED",
|
|
"THREADS_MAX_COUNT", "THREAD_BUMP_LIMIT", "IS_CAPTCHA_ACTIVE",
|
|
"GATEWAY_URL", "GATEWAY_API_KEY", "GATEWAY_BOARD_ID",
|
|
"GATEWAY_BOARD_URL", "GATEWAY_BOARD_DESCRIPTION", "POSTGRES_URL",
|
|
} {
|
|
t.Setenv(key, "")
|
|
}
|
|
|
|
app := getAppConfig()
|
|
if app.Env != "release" || app.Port != 3000 || app.IsDbSeeded {
|
|
t.Fatalf("unexpected app defaults: %+v", app)
|
|
}
|
|
if !app.IsRateLimiterEnabled || app.ThreadsMaxCount != 50 || app.ThreadBumpLimit != 500 || !app.IsCaptchaActive {
|
|
t.Fatalf("unexpected feature defaults: %+v", app)
|
|
}
|
|
if db := getDbConfig(); db.Url != "postgresql://localhost/micrach" {
|
|
t.Fatalf("unexpected database default: %+v", db)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Add post and upload validation tests**
|
|
|
|
Create `utils/utils_test.go`:
|
|
|
|
```go
|
|
package utils
|
|
|
|
import (
|
|
"mime/multipart"
|
|
"net/textproto"
|
|
"strings"
|
|
"testing"
|
|
|
|
"micrach/repositories"
|
|
)
|
|
|
|
func fileHeader(contentType string, size int64) *multipart.FileHeader {
|
|
header := make(textproto.MIMEHeader)
|
|
header.Set("Content-Type", contentType)
|
|
return &multipart.FileHeader{Filename: "upload", Header: header, Size: size}
|
|
}
|
|
|
|
func TestValidatePost(t *testing.T) {
|
|
validPNG := fileHeader("image/png", 1)
|
|
validJPEG := fileHeader("image/jpeg", FILE_SIZE_IN_BYTES)
|
|
|
|
tests := []struct {
|
|
name string
|
|
title string
|
|
text string
|
|
files []*multipart.FileHeader
|
|
want string
|
|
}{
|
|
{name: "text only", text: "hello"},
|
|
{name: "file only", files: []*multipart.FileHeader{validPNG}},
|
|
{name: "empty", want: repositories.InvalidTextOrFilesErrorMessage},
|
|
{name: "title over limit", title: strings.Repeat("я", 101), text: "ok", want: repositories.InvalidTitleLengthErrorMessage},
|
|
{name: "text over limit", text: strings.Repeat("я", 1001), want: repositories.InvalidTextLengthErrorMessage},
|
|
{name: "four files", files: []*multipart.FileHeader{validPNG, validPNG, validJPEG, validJPEG}},
|
|
{name: "five files", files: []*multipart.FileHeader{validPNG, validPNG, validPNG, validPNG, validPNG}, want: repositories.InvalidFilesLengthErrorMessage},
|
|
{name: "unsupported content type", files: []*multipart.FileHeader{fileHeader("image/gif", 1)}, want: repositories.InvalidFileExtErrorMessage},
|
|
{name: "size at limit", files: []*multipart.FileHeader{validJPEG}},
|
|
{name: "size over limit", files: []*multipart.FileHeader{fileHeader("image/jpeg", FILE_SIZE_IN_BYTES+1)}, want: repositories.InvalidFileSizeErrorMessage},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := ValidatePost(tt.title, tt.text, tt.files); got != tt.want {
|
|
t.Fatalf("ValidatePost() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Add DB-free Fiber error-path smoke tests**
|
|
|
|
Create `controllers/threads_controller_test.go`:
|
|
|
|
```go
|
|
package controllers
|
|
|
|
import (
|
|
"io"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
type testViews struct{}
|
|
|
|
func (testViews) Load() error { return nil }
|
|
|
|
func (testViews) Render(out io.Writer, name string, _ interface{}, _ ...string) error {
|
|
_, err := io.WriteString(out, name)
|
|
return err
|
|
}
|
|
|
|
func TestGetThreadsRejectsInvalidPage(t *testing.T) {
|
|
app := fiber.New(fiber.Config{Views: testViews{}})
|
|
app.Get("/", GetThreads)
|
|
|
|
resp, err := app.Test(httptest.NewRequest("GET", "/?page=0", nil))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != fiber.StatusNotFound {
|
|
t.Fatalf("status = %d, want %d", resp.StatusCode, fiber.StatusNotFound)
|
|
}
|
|
}
|
|
|
|
func TestGetThreadRejectsInvalidID(t *testing.T) {
|
|
app := fiber.New(fiber.Config{Views: testViews{}})
|
|
app.Get("/:threadID", GetThread)
|
|
|
|
resp, err := app.Test(httptest.NewRequest("GET", "/invalid", nil))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != fiber.StatusNotFound {
|
|
t.Fatalf("status = %d, want %d", resp.StatusCode, fiber.StatusNotFound)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run the new safety net**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
go fmt ./...
|
|
go test ./...
|
|
go vet ./...
|
|
```
|
|
|
|
Expected: all tests pass and vet exits 0.
|
|
|
|
- [ ] **Step 6: Commit the safety net**
|
|
|
|
```bash
|
|
git add config/config_test.go utils/utils_test.go controllers/threads_controller_test.go
|
|
git commit -m "test: characterize legacy behavior"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Upgrade Go, Fiber v2, and Non-Database Dependencies
|
|
|
|
**Files:**
|
|
|
|
- Modify: `go.mod`
|
|
- Modify: `go.sum`
|
|
- Modify: `main.go`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: characterization tests from Task 1.
|
|
- Produces: Go 1.26 module baseline using Fiber v2.52.14 and HTML template v2.1.3; no route or handler API change.
|
|
|
|
- [ ] **Step 1: Set the module Go line**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
go mod edit -go=1.26.0
|
|
go mod edit -toolchain=go1.26.5
|
|
```
|
|
|
|
Expected: `go.mod` contains:
|
|
|
|
```text
|
|
go 1.26.0
|
|
|
|
toolchain go1.26.5
|
|
```
|
|
|
|
- [ ] **Step 2: Upgrade direct non-database dependencies deliberately**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
go get github.com/gofiber/fiber/v2@v2.52.14
|
|
go get github.com/gofiber/template/html/v2@v2.1.3
|
|
go get github.com/dchest/captcha@v1.1.0
|
|
go get github.com/joho/godotenv@v1.5.1
|
|
go get github.com/disintegration/imaging@v1.6.2
|
|
```
|
|
|
|
Expected: each command exits 0; Fiber remains on major version 2.
|
|
|
|
- [ ] **Step 3: Switch the template import to its current module path**
|
|
|
|
In `main.go`, replace:
|
|
|
|
```go
|
|
"github.com/gofiber/template/html"
|
|
```
|
|
|
|
with:
|
|
|
|
```go
|
|
"github.com/gofiber/template/html/v2"
|
|
```
|
|
|
|
Keep initialization unchanged:
|
|
|
|
```go
|
|
engine := html.New("./templates", ".html")
|
|
```
|
|
|
|
- [ ] **Step 4: Normalize the module graph**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
go mod tidy
|
|
go list -m all
|
|
```
|
|
|
|
Expected direct requirements:
|
|
|
|
```text
|
|
github.com/dchest/captcha v1.1.0
|
|
github.com/disintegration/imaging v1.6.2
|
|
github.com/gofiber/fiber/v2 v2.52.14
|
|
github.com/gofiber/template/html/v2 v2.1.3
|
|
github.com/joho/godotenv v1.5.1
|
|
```
|
|
|
|
The old root `github.com/gofiber/template` requirement must be absent.
|
|
|
|
- [ ] **Step 5: Verify behavior on the new HTTP dependency baseline**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
go test ./...
|
|
go vet ./...
|
|
go build ./...
|
|
```
|
|
|
|
Expected: all commands exit 0.
|
|
|
|
- [ ] **Step 6: Commit the toolchain and Fiber v2 baseline**
|
|
|
|
```bash
|
|
git add go.mod go.sum main.go
|
|
git commit -m "build: update Go and Fiber v2"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Migrate pgx v4 to v5 and Propagate Contexts
|
|
|
|
**Files:**
|
|
|
|
- Create: `repositories/files_repository_test.go`
|
|
- Create: `db/db_test.go`
|
|
- Modify: `go.mod`
|
|
- Modify: `go.sum`
|
|
- Modify: `main.go`
|
|
- Modify: `controllers/threads_controller.go`
|
|
- Modify: `repositories/posts_repository.go`
|
|
- Modify: `repositories/files_repository.go`
|
|
- Modify: `repositories/seeds.go`
|
|
- Modify: `db/db.go`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: `fiber.Ctx.UserContext() context.Context`; startup uses `context.Background()`.
|
|
- Produces: repository and DB operations accepting `context.Context`; pgx v5 transaction types; `toInt32s([]int) []int32` for PostgreSQL `integer[]` parameters.
|
|
|
|
- [ ] **Step 1: Write the integer-array conversion test**
|
|
|
|
Create `repositories/files_repository_test.go`:
|
|
|
|
```go
|
|
package repositories
|
|
|
|
import (
|
|
"reflect"
|
|
"testing"
|
|
)
|
|
|
|
func TestToInt32s(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
in []int
|
|
want []int32
|
|
}{
|
|
{name: "nil", in: nil, want: nil},
|
|
{name: "empty", in: []int{}, want: []int32{}},
|
|
{name: "values", in: []int{1, 2, 2147483647}, want: []int32{1, 2, 2147483647}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := toInt32s(tt.in); !reflect.DeepEqual(got, tt.want) {
|
|
t.Fatalf("toInt32s() = %#v, want %#v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the focused test and confirm the missing helper**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
go test ./repositories -run TestToInt32s -count=1
|
|
```
|
|
|
|
Expected: build failure containing `undefined: toInt32s`.
|
|
|
|
- [ ] **Step 3: Add the minimal array conversion helper**
|
|
|
|
Add to `repositories/files_repository.go`:
|
|
|
|
```go
|
|
func toInt32s(values []int) []int32 {
|
|
if values == nil {
|
|
return nil
|
|
}
|
|
|
|
result := make([]int32, len(values))
|
|
for i, value := range values {
|
|
result[i] = int32(value)
|
|
}
|
|
return result
|
|
}
|
|
```
|
|
|
|
Use it directly in the query:
|
|
|
|
```go
|
|
rows, err := Db.Pool.Query(ctx, sql, toInt32s(postIDs))
|
|
```
|
|
|
|
Do not keep `pgtype.Int4Array` or the standalone `github.com/jackc/pgtype` import.
|
|
|
|
- [ ] **Step 4: Write SQLSTATE classification tests**
|
|
|
|
Create `db/db_test.go`:
|
|
|
|
```go
|
|
package db
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
)
|
|
|
|
func TestIsUndefinedTable(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
want bool
|
|
}{
|
|
{name: "undefined table", err: &pgconn.PgError{Code: "42P01"}, want: true},
|
|
{name: "other postgres error", err: &pgconn.PgError{Code: "23505"}},
|
|
{name: "ordinary error", err: errors.New("boom")},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := isUndefinedTable(tt.err); got != tt.want {
|
|
t.Fatalf("isUndefinedTable() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Upgrade pgx and replace imports**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
go get github.com/jackc/pgx/v5@v5.10.0
|
|
```
|
|
|
|
Replace imports exactly:
|
|
|
|
```go
|
|
"github.com/jackc/pgx/v4"
|
|
"github.com/jackc/pgx/v4/pgxpool"
|
|
```
|
|
|
|
with:
|
|
|
|
```go
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
```
|
|
|
|
Only import `pgconn` in `db/db.go` and `db/db_test.go`.
|
|
|
|
- [ ] **Step 6: Define the contextual DB and repository signatures**
|
|
|
|
Change signatures to this exact set:
|
|
|
|
```go
|
|
// db/db.go
|
|
func Init(ctx context.Context)
|
|
func Migrate(ctx context.Context)
|
|
func runMigration(ctx context.Context, mid int, mname, msql string)
|
|
func getDbMigrations(ctx context.Context) MigrationsMap
|
|
|
|
// repositories/files_repository.go
|
|
func (r *FilesRepository) Create(ctx context.Context, f File) error
|
|
func (r *FilesRepository) GetByPostIDs(ctx context.Context, postIDs []int) (map[int][]File, error)
|
|
func (r *FilesRepository) CreateInTx(ctx context.Context, tx pgx.Tx, f File) (int, error)
|
|
|
|
// repositories/posts_repository.go
|
|
func (r *PostsRepository) Get(ctx context.Context, limit, offset int) ([]Post, error)
|
|
func (r *PostsRepository) GetThreadsCount(ctx context.Context) (int, error)
|
|
func (r *PostsRepository) Create(ctx context.Context, p Post) (int, error)
|
|
func (r *PostsRepository) GetThreadByPostID(ctx context.Context, ID int) ([]Post, error)
|
|
func (r *PostsRepository) GetIfThreadIsArchived(ctx context.Context, ID int) (bool, error)
|
|
func (r *PostsRepository) CreateInTx(ctx context.Context, tx pgx.Tx, p Post) (int, error)
|
|
func (r *PostsRepository) GetOldestThreadUpdatedAt(ctx context.Context) (time.Time, error)
|
|
func (r *PostsRepository) ArchiveThreadsFrom(ctx context.Context, t time.Time) error
|
|
func (r *PostsRepository) GetThreadPostsCount(ctx context.Context, id int) (int, error)
|
|
func (r *PostsRepository) BumpThreadInTx(ctx context.Context, tx pgx.Tx, id int) error
|
|
|
|
// repositories/seeds.go
|
|
func seedDb(ctx context.Context)
|
|
func Seed(ctx context.Context)
|
|
```
|
|
|
|
Replace every `context.TODO()` passed to pgx with the method's `ctx`. Pass `ctx`
|
|
through internal repository calls such as:
|
|
|
|
```go
|
|
filesMap, err := Files.GetByPostIDs(ctx, postIDs)
|
|
```
|
|
|
|
- [ ] **Step 7: Propagate startup and request contexts at call sites**
|
|
|
|
In `main.go`:
|
|
|
|
```go
|
|
ctx := context.Background()
|
|
config.Init()
|
|
db.Init(ctx)
|
|
db.Migrate(ctx)
|
|
defer db.Pool.Close()
|
|
|
|
if config.App.IsDbSeeded {
|
|
repositories.Seed(ctx)
|
|
}
|
|
```
|
|
|
|
At the beginning of every controller that reaches a repository, add:
|
|
|
|
```go
|
|
ctx := c.UserContext()
|
|
```
|
|
|
|
Pass `ctx` as the first argument to every repository call, `db.Pool.Acquire`,
|
|
`conn.Begin`, `tx.Rollback`, and `tx.Commit`.
|
|
|
|
- [ ] **Step 8: Make rows lifecycle and migration error handling correct**
|
|
|
|
After every successful `Query`, add:
|
|
|
|
```go
|
|
defer rows.Close()
|
|
```
|
|
|
|
Remove pre-iteration `rows.Err()` checks. After each `for rows.Next()` loop,
|
|
repository methods returning a slice or map must return the final error:
|
|
|
|
```go
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
```
|
|
|
|
In `getDbMigrations`, which preserves the existing panic-on-startup-error policy,
|
|
use this exact check before returning the map:
|
|
|
|
```go
|
|
if err := rows.Err(); err != nil {
|
|
log.Panicln(err)
|
|
}
|
|
```
|
|
|
|
In `runMigration`, record the migration with `Exec`, not `Query`:
|
|
|
|
```go
|
|
_, err = Pool.Exec(ctx, `INSERT INTO migrations (id, name) VALUES ($1, $2)`, mid, mname)
|
|
if err != nil {
|
|
log.Panicln(err)
|
|
}
|
|
```
|
|
|
|
Replace string matching with SQLSTATE classification:
|
|
|
|
```go
|
|
func isUndefinedTable(err error) bool {
|
|
var pgErr *pgconn.PgError
|
|
return errors.As(err, &pgErr) && pgErr.Code == "42P01"
|
|
}
|
|
```
|
|
|
|
In `getDbMigrations`, treat only that code as an empty migration map:
|
|
|
|
```go
|
|
rows, err := Pool.Query(ctx, `SELECT id, name FROM migrations`)
|
|
if err != nil {
|
|
if isUndefinedTable(err) {
|
|
return make(MigrationsMap)
|
|
}
|
|
log.Panicln(err)
|
|
}
|
|
defer rows.Close()
|
|
```
|
|
|
|
- [ ] **Step 9: Check transaction completion in both write handlers**
|
|
|
|
Import `errors` and `github.com/jackc/pgx/v5` in
|
|
`controllers/threads_controller.go`. Replace each bare rollback defer with:
|
|
|
|
```go
|
|
defer func() {
|
|
rollbackErr := tx.Rollback(ctx)
|
|
if rollbackErr != nil && !errors.Is(rollbackErr, pgx.ErrTxClosed) {
|
|
log.Println("error:", rollbackErr)
|
|
}
|
|
}()
|
|
```
|
|
|
|
Replace each unchecked commit with:
|
|
|
|
```go
|
|
if err := tx.Commit(ctx); err != nil {
|
|
log.Println("error:", err)
|
|
return c.Status(fiber.StatusInternalServerError).Render("pages/500", nil)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 10: Normalize and verify the pgx v5 migration**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
go mod tidy
|
|
go fmt ./...
|
|
go test ./...
|
|
go vet ./...
|
|
go build ./...
|
|
rg 'jackc/(pgx/v4|pgtype)|context\.TODO' --glob '*.go'
|
|
```
|
|
|
|
Expected: build and checks pass; final `rg` returns no matches.
|
|
|
|
- [ ] **Step 11: Commit the database driver migration**
|
|
|
|
```bash
|
|
git add go.mod go.sum main.go controllers/threads_controller.go repositories db
|
|
git commit -m "refactor(db): migrate to pgx v5"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Replace Deprecated I/O APIs
|
|
|
|
**Files:**
|
|
|
|
- Modify: `build/css.go`
|
|
- Modify: `files/get_files_in_folder.go`
|
|
- Modify: `gateway/connect.go`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: existing file paths, CSS rewrite behavior, and gateway registration request.
|
|
- Produces: identical behavior through `os` and `io`, with gateway response bodies closed.
|
|
|
|
- [ ] **Step 1: Replace deprecated filesystem calls**
|
|
|
|
In `build/css.go`, remove `io/ioutil` and use:
|
|
|
|
```go
|
|
files, err := os.ReadDir(dir)
|
|
files, err := os.ReadDir("static/styles")
|
|
htmlTemplate, err := os.ReadFile(htmlTemplatePath)
|
|
err = os.WriteFile(htmlTemplatePath, htmlTemplate, 0644)
|
|
```
|
|
|
|
In `files/get_files_in_folder.go`, remove `io/ioutil` and use:
|
|
|
|
```go
|
|
files, err := os.ReadDir(fullFolderPath)
|
|
file, err := os.ReadFile(fullFilePath)
|
|
```
|
|
|
|
Preserve existing return values, ordering, permissions, and error policy.
|
|
|
|
- [ ] **Step 2: Replace gateway body reading and close the response**
|
|
|
|
In `gateway/connect.go`, replace `io/ioutil` with `io`, then use:
|
|
|
|
```go
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
log.Panicln(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
log.Panicln(err)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Verify deprecated APIs are gone**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
go fmt ./...
|
|
go test ./...
|
|
go vet ./...
|
|
go build ./...
|
|
rg 'io/ioutil|ioutil\.' --glob '*.go'
|
|
```
|
|
|
|
Expected: Go checks pass; final `rg` returns no matches.
|
|
|
|
- [ ] **Step 4: Commit the compatibility cleanup**
|
|
|
|
```bash
|
|
git add build/css.go files/get_files_in_folder.go gateway/connect.go
|
|
git commit -m "refactor: replace deprecated I/O APIs"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Replace Legacy Compose with Local PostgreSQL
|
|
|
|
**Files:**
|
|
|
|
- Modify: `docker-compose.yml`
|
|
- Modify: `.env.example`
|
|
- Modify: `Makefile`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: application `POSTGRES_URL` configuration.
|
|
- Produces: PostgreSQL at `localhost:5432`, database/user/password `micrach`, persistent volume `micrach-postgres`, and host-run development commands.
|
|
|
|
- [ ] **Step 1: Replace Compose with the PostgreSQL-only definition**
|
|
|
|
Replace `docker-compose.yml` with:
|
|
|
|
```yaml
|
|
services:
|
|
db:
|
|
image: postgres:18.4-alpine3.23
|
|
environment:
|
|
POSTGRES_DB: micrach
|
|
POSTGRES_USER: micrach
|
|
POSTGRES_PASSWORD: micrach
|
|
ports:
|
|
- "5432:5432"
|
|
volumes:
|
|
- micrach-postgres:/var/lib/postgresql
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U micrach -d micrach"]
|
|
interval: 2s
|
|
timeout: 5s
|
|
retries: 15
|
|
start_period: 5s
|
|
|
|
volumes:
|
|
micrach-postgres:
|
|
```
|
|
|
|
The `/var/lib/postgresql` mount intentionally follows the PostgreSQL 18 image
|
|
layout.
|
|
|
|
- [ ] **Step 2: Make local environment defaults safe and compatible**
|
|
|
|
Replace `.env.example` with:
|
|
|
|
```dotenv
|
|
ENV=development
|
|
PORT=3000
|
|
IS_DB_SEEDED=false
|
|
IS_RATE_LIMITER_ENABLED=true
|
|
THREADS_MAX_COUNT=50
|
|
POSTGRES_URL=postgres://micrach:micrach@localhost:5432/micrach?pool_max_conns=5
|
|
THREAD_BUMP_LIMIT=500
|
|
IS_CAPTCHA_ACTIVE=true
|
|
GATEWAY_URL=
|
|
GATEWAY_API_KEY=
|
|
GATEWAY_BOARD_ID=
|
|
GATEWAY_BOARD_URL=
|
|
GATEWAY_BOARD_DESCRIPTION=
|
|
```
|
|
|
|
- [ ] **Step 3: Add explicit host and infrastructure commands**
|
|
|
|
Replace `Makefile` with:
|
|
|
|
```make
|
|
.PHONY: run dev infra-up infra-down
|
|
|
|
run:
|
|
go run .
|
|
|
|
dev:
|
|
nodemon --exec go run . --signal SIGTERM
|
|
|
|
infra-up:
|
|
docker compose up -d db
|
|
|
|
infra-down:
|
|
docker compose down
|
|
```
|
|
|
|
- [ ] **Step 4: Validate Compose syntax and database health**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
docker compose config
|
|
docker compose up -d db
|
|
docker compose ps
|
|
```
|
|
|
|
Expected: configuration is valid; `db` transitions to `healthy`.
|
|
|
|
- [ ] **Step 5: Verify clean startup against disposable local data**
|
|
|
|
Run with a copied local environment:
|
|
|
|
```bash
|
|
if test ! -e .env; then cp .env.example .env; fi
|
|
go run .
|
|
```
|
|
|
|
Expected: logs report database and migrations online; application listens on
|
|
port 3000. Stop the process after startup verification. An existing `.env` is
|
|
never overwritten or removed, and `.env` is never staged.
|
|
|
|
- [ ] **Step 6: Commit local infrastructure**
|
|
|
|
```bash
|
|
git add docker-compose.yml .env.example Makefile
|
|
git commit -m "build: add local PostgreSQL compose"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Build a Multi-Stage Non-Root Image
|
|
|
|
**Files:**
|
|
|
|
- Modify: `Dockerfile`
|
|
- Create: `.dockerignore`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: Go module, templates, static assets, migrations, and writable `/app/uploads`.
|
|
- Produces: local `micrach:wave1` Linux image; no registry interaction.
|
|
|
|
- [ ] **Step 1: Replace the Dockerfile**
|
|
|
|
Replace `Dockerfile` with:
|
|
|
|
```dockerfile
|
|
FROM golang:1.26.5-alpine3.24 AS build
|
|
|
|
WORKDIR /src
|
|
COPY go.mod go.sum ./
|
|
RUN go mod download
|
|
COPY . .
|
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/micrach .
|
|
|
|
FROM alpine:3.24.1
|
|
|
|
RUN apk add --no-cache ca-certificates \
|
|
&& addgroup -S micrach \
|
|
&& adduser -S -G micrach micrach \
|
|
&& mkdir -p /app/uploads \
|
|
&& chown -R micrach:micrach /app
|
|
|
|
WORKDIR /app
|
|
COPY --from=build /out/micrach ./micrach
|
|
COPY --chown=micrach:micrach templates/ templates/
|
|
COPY --chown=micrach:micrach static/ static/
|
|
COPY --chown=micrach:micrach migrations/ migrations/
|
|
|
|
USER micrach
|
|
ENTRYPOINT ["./micrach"]
|
|
```
|
|
|
|
- [ ] **Step 2: Exclude local state from build context**
|
|
|
|
Create `.dockerignore`:
|
|
|
|
```text
|
|
.git
|
|
.github
|
|
.env
|
|
.env.*
|
|
!.env.example
|
|
docs
|
|
tests
|
|
uploads
|
|
micrach
|
|
*.out
|
|
*.test
|
|
```
|
|
|
|
- [ ] **Step 3: Build and inspect the image**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
docker build --tag micrach:wave1 .
|
|
docker image inspect micrach:wave1 --format '{{.Config.User}} {{json .Config.Entrypoint}}'
|
|
```
|
|
|
|
Expected inspect output:
|
|
|
|
```text
|
|
micrach ["./micrach"]
|
|
```
|
|
|
|
- [ ] **Step 4: Confirm upload storage is writable by the runtime user**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
docker run --rm --entrypoint sh micrach:wave1 -c 'touch /app/uploads/write-test && test -f /app/uploads/write-test'
|
|
```
|
|
|
|
Expected: exit 0.
|
|
|
|
- [ ] **Step 5: Commit the image build**
|
|
|
|
```bash
|
|
git add Dockerfile .dockerignore
|
|
git commit -m "build: add multi-stage image"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Replace Deployment Automation with Verification CI
|
|
|
|
**Files:**
|
|
|
|
- Modify: `.github/workflows/push.yml`
|
|
- Delete: `.github/workflows/codesee-arch-diagram.yml`
|
|
- Create: `.github/dependabot.yml`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: `go.mod`, `go.sum`, Dockerfile, and repository tests.
|
|
- Produces: read-only checks for pushes and pull requests; reviewed dependency update pull requests.
|
|
|
|
- [ ] **Step 1: Replace the workflow with read-only CI**
|
|
|
|
Replace `.github/workflows/push.yml` with:
|
|
|
|
```yaml
|
|
name: CI
|
|
|
|
on:
|
|
push:
|
|
pull_request:
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
jobs:
|
|
verify:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v6
|
|
|
|
- name: Set up Go
|
|
uses: actions/setup-go@v6
|
|
with:
|
|
go-version: "1.26.5"
|
|
cache: true
|
|
cache-dependency-path: go.sum
|
|
|
|
- name: Verify formatting
|
|
run: |
|
|
files="$(gofmt -l .)"
|
|
test -z "$files" || { printf '%s\n' "$files"; exit 1; }
|
|
|
|
- name: Test
|
|
run: go test ./...
|
|
|
|
- name: Vet
|
|
run: go vet ./...
|
|
|
|
- name: Build
|
|
run: go build ./...
|
|
|
|
- name: Build Linux binary
|
|
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /tmp/micrach-linux-amd64 .
|
|
|
|
- name: Install govulncheck
|
|
run: go install golang.org/x/vuln/cmd/govulncheck@v1.6.0
|
|
|
|
- name: Scan reachable vulnerabilities
|
|
run: govulncheck ./...
|
|
|
|
- name: Build container image
|
|
run: docker build --tag micrach:ci .
|
|
```
|
|
|
|
Do not add registry login, image push, artifact upload, SSH, environment secrets,
|
|
Swarm, or deployment steps.
|
|
|
|
- [ ] **Step 2: Remove CodeSee**
|
|
|
|
Delete:
|
|
|
|
```text
|
|
.github/workflows/codesee-arch-diagram.yml
|
|
```
|
|
|
|
- [ ] **Step 3: Add Dependabot**
|
|
|
|
Create `.github/dependabot.yml`:
|
|
|
|
```yaml
|
|
version: 2
|
|
updates:
|
|
- package-ecosystem: gomod
|
|
directory: /
|
|
schedule:
|
|
interval: weekly
|
|
open-pull-requests-limit: 5
|
|
|
|
- package-ecosystem: github-actions
|
|
directory: /
|
|
schedule:
|
|
interval: weekly
|
|
open-pull-requests-limit: 5
|
|
```
|
|
|
|
- [ ] **Step 4: Validate workflow syntax and execute its commands locally**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
test -z "$(gofmt -l .)"
|
|
go test ./...
|
|
go vet ./...
|
|
go build ./...
|
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /tmp/micrach-linux-amd64 .
|
|
go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./...
|
|
docker build --tag micrach:ci .
|
|
```
|
|
|
|
Expected: every command exits 0. If `govulncheck` reports a reachable
|
|
vulnerability, upgrade or document the exact blocking dependency before this
|
|
task can pass; do not suppress findings generically.
|
|
|
|
- [ ] **Step 5: Commit CI and dependency automation**
|
|
|
|
```bash
|
|
git add .github
|
|
git commit -m "ci: replace legacy deployment workflow"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Align Documentation and Agent Guidance
|
|
|
|
**Files:**
|
|
|
|
- Modify: `README.md`
|
|
- Modify: `AGENTS.md`
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: completed Wave 1 commands and versions.
|
|
- Produces: one accurate setup and verification path for humans and coding agents.
|
|
|
|
- [ ] **Step 1: Update README stack and prerequisites**
|
|
|
|
State these exact baselines:
|
|
|
|
```markdown
|
|
- Go 1.26
|
|
- Fiber v2
|
|
- PostgreSQL 18 for local development
|
|
```
|
|
|
|
Remove claims that CI uses Go 1.17 and that Docker requires a prebuilt binary.
|
|
|
|
- [ ] **Step 2: Replace local setup with the host-run workflow**
|
|
|
|
Document this command sequence:
|
|
|
|
```sh
|
|
cp .env.example .env
|
|
docker compose up -d db
|
|
go run .
|
|
```
|
|
|
|
Document automatic reload separately:
|
|
|
|
```sh
|
|
make dev
|
|
```
|
|
|
|
State that Compose runs PostgreSQL only and that nodemon is required by
|
|
`make dev`.
|
|
|
|
- [ ] **Step 3: Replace container and CI documentation**
|
|
|
|
Document local image verification:
|
|
|
|
```sh
|
|
docker build -t micrach:local .
|
|
```
|
|
|
|
State that CI checks formatting, tests, vet, native and Linux builds,
|
|
reachable vulnerabilities, and Docker image construction. State explicitly
|
|
that CI does not publish or deploy images.
|
|
|
|
- [ ] **Step 4: Update AGENTS guidance**
|
|
|
|
Replace stale Go 1.15/1.17 constraints with Go 1.26. Replace the production
|
|
Docker and Swarm description with:
|
|
|
|
```markdown
|
|
- `Dockerfile` builds the application in a pinned Go builder stage and runs it
|
|
as a non-root user in a small runtime image.
|
|
- `docker-compose.yml` starts local PostgreSQL only; run micrach on the host.
|
|
- CI verifies the project and container build but does not publish or deploy.
|
|
```
|
|
|
|
Keep all existing architecture, upload, migration, testing, and secret-handling
|
|
rules that remain accurate.
|
|
|
|
- [ ] **Step 5: Verify documentation**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
rg -n 'Go 1\.15|Go 1\.17|prebuilt|Swarm|Traefik|docker\.pkg\.github|ghcr\.io' README.md AGENTS.md .github Dockerfile docker-compose.yml
|
|
git diff --check
|
|
```
|
|
|
|
Expected: no stale deployment or toolchain matches; `git diff --check` exits 0.
|
|
|
|
- [ ] **Step 6: Commit documentation**
|
|
|
|
```bash
|
|
git add README.md AGENTS.md
|
|
git commit -m "docs: document modern development workflow"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Run the Wave 1 Acceptance Gate
|
|
|
|
**Files:**
|
|
|
|
- Verify only; modify a file only to fix a concrete failing check within Wave 1 scope.
|
|
|
|
**Interfaces:**
|
|
|
|
- Consumes: all Wave 1 tasks.
|
|
- Produces: fresh evidence that Wave 1 is safe to merge and that Wave 2 may be planned separately.
|
|
|
|
- [ ] **Step 1: Verify repository hygiene**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
git status --short
|
|
git diff --check
|
|
git ls-files .env uploads micrach '*.out' '*.test'
|
|
```
|
|
|
|
Expected: clean worktree after task commits; no forbidden local artifacts are
|
|
tracked.
|
|
|
|
- [ ] **Step 2: Run the full Go gate**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
go fmt ./...
|
|
test -z "$(gofmt -l .)"
|
|
go mod tidy
|
|
git diff --exit-code -- go.mod go.sum
|
|
go test ./...
|
|
go vet ./...
|
|
go build ./...
|
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /tmp/micrach-linux-amd64 .
|
|
go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./...
|
|
```
|
|
|
|
Expected: every command exits 0; module files remain unchanged after tidy;
|
|
no reachable vulnerability remains unhandled.
|
|
|
|
- [ ] **Step 3: Run the container and infrastructure gate**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
docker compose config
|
|
docker compose up -d db
|
|
docker compose ps
|
|
docker build --tag micrach:wave1 .
|
|
docker run --rm --entrypoint sh micrach:wave1 -c 'touch /app/uploads/write-test && test -f /app/uploads/write-test'
|
|
```
|
|
|
|
Expected: PostgreSQL is healthy, the image builds, and the runtime user can
|
|
write uploads.
|
|
|
|
- [ ] **Step 4: Run manual application smoke tests**
|
|
|
|
With `.env` copied from `.env.example`, run `go run .` and verify:
|
|
|
|
```text
|
|
GET / catalog renders
|
|
GET /?page=0 404 renders
|
|
POST / valid thread redirects
|
|
GET /:threadID thread renders
|
|
POST /:threadID valid reply redirects with fragment
|
|
GET /captcha/:captchaID PNG renders when CAPTCHA is enabled
|
|
```
|
|
|
|
Also verify JPEG and PNG upload limits, thumbnail creation, pagination, rate
|
|
limiting, `sage`, bump limit, and archival behavior. Inspect catalog and thread
|
|
pages at narrow and wide viewport widths; Wave 1 must introduce no visual
|
|
change.
|
|
|
|
- [ ] **Step 5: Remove disposable local state**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
docker compose down
|
|
rm -f /tmp/micrach-linux-amd64
|
|
git status --short --branch
|
|
```
|
|
|
|
Expected: no generated or local-only file appears in Git status. Leave any
|
|
pre-existing or locally created ignored `.env` untouched.
|
|
|
|
## Wave 2 Entry Gate
|
|
|
|
Do not implement Fiber v3 in this plan. Start a separate spec-derived plan only
|
|
after Task 9 passes. That follow-up must recheck the current Fiber v3, template
|
|
engine, and Go requirements through Context7 and official release notes, then
|
|
cover handler context signatures, static middleware, form parsing, redirects,
|
|
CAPTCHA, uploads, pagination, rate limiting, `sage`, archival, and responsive
|
|
smoke tests. Architecture cleanup remains excluded.
|