docs: P3 backup+observability spec + implementation plan (design decisions D1/D2/D3 defaulted after unanswered consult)
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
# P3 备份 + 可观测 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** crearte-server 0.12.0(slog 结构化日志 + 请求日志 + 手写 `/metrics` + 限流权衡记录)与 crearte-deploy 0.6.0(`backup.sh` / `restore-drill.sh` / BACKUP-RUNBOOK / CI dry-run),端到端验证后合主。前端零改动。
|
||||
|
||||
**Architecture:** server 侧新增 `internal/observability` 包(mutex Registry + gin 中间件 + exposition 函数),config 增 `LOG_FORMAT`/`LOG_LEVEL`,26 处 `log.Printf` 机械迁移到 slog;deploy 侧结构零改动,备份靠 `compose exec/run` 一次性容器(db 镜像自带 pg_dump、minio 镜像自带 mc)。见 spec `docs/specs/2026-09-30-backup-observability-design.md`(§2 三个选型 D1/D2/D3、§4 脚本七步、§5 验收四条)。
|
||||
|
||||
**Tech Stack:** Go 1.24(gin 1.11 / pgx 5.8 / stdlib log-slog;**禁新增第三方依赖**)、bash + docker compose + mc、GitHub Actions(deploy 仓 validate.yml 已有 compose config 步骤可仿)。
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 工作目录:`crearte-monorepo/crearte-server/src`(Go 源码根)与 `crearte-monorepo/crearte-deploy`。
|
||||
- 分支:server 与 deploy 各从 `master` 切 `feat/backup-observability`;不碰 crearte。**master 上禁直接提交**(两仓 AGENTS.md);合回一律 `--no-ff`。
|
||||
- **Go 命令一律走容器**(AGENTS.md 本地约定,proxy.golang.org 不可达):
|
||||
`docker run --rm -v <repo>/crearte-server:/work -v crearte_gomod:/go/pkg/mod -v crearte_gocache:/gocache -e GOCACHE=/gocache -e GOPROXY=https://goproxy.cn,direct -e GOSUMDB=sum.golang.google.cn -w /work/src golang:1.24-alpine sh -c "<cmd>"`(`<repo>`=/root/.openclaw/workspace/coder/crearte-monorepo;首次冷 1–3 分钟用 background+poll)。
|
||||
- slog 迁移**机械映射**(spec §3.1):`log.Printf("X: k=%s e=%v", …)` → `slog.Error("X", "k", …, "error", err)`;原短语/键名不变;`error=` 一律 `*error` 值入 attr;不改任何调用语义。
|
||||
- `/metrics` 手写 exposition(D1):文本格式 v0.0.4,`# HELP`/`# TYPE` 行齐全,指标名与 label 集合严格按 spec §3.2;`/healthz` 请求既不记日志也不计指标。
|
||||
- 备份脚本:只删 `^\d{8}T\d{6}Z$` 形态目录;`restore-drill.sh` 只连 `db-test`(127.0.0.1:5434→内部 5432,debug profile),绝不指向 prod;凭据从 `.env` 读,不落命令行。
|
||||
- CHANGELOG:server `docs/CHANGELOG.md` 追加 `## [0.12.0] - 2026-09-30`(顶版 0.11.1 之上)、deploy 追加 `## [0.6.0] - 2026-09-30`(顶版 0.5.1 之上);英文行+中文行连续,条目间空行。
|
||||
- 完成定义:Task 4 六腿全绿(容器四连 / TEST_DATABASE_URL 集成层 / observability 新单测 / 实栈 prod 冒烟抓 /metrics+JSON 日志 / backup+restore-drill 真跑 / full-loop e2e 不回归),Task 5 合主推送。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: server — slog 基座(config 解析 + 26 处迁移 + 请求日志中间件)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/internal/config/config.go`(+ `LogFormat`/`LogLevel` 字段与解析)
|
||||
- Create: `src/internal/observability/logging.go`(slog 初始化 helper + `RequestLogger` 中间件)
|
||||
- Create: `src/internal/observability/logging_test.go`
|
||||
- Modify: `src/internal/api/router.go`(挂 `RequestLogger`;`gin.Recovery` 保留)
|
||||
- Modify: `src/cmd/serve.go`(启动时初始化 slog;启动行转 slog)
|
||||
- Modify: handler/service/api 其余 10 文件(机械迁移清单:`internal/handler/{games,admin,uploads,submissions,auth,bundlekey,reactions}.go`、`internal/service/{content,cleanup}.go`、`internal/api/{admin,auth}.go`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `observability.InitLogging(format, level string) error`;`observability.RequestLogger() gin.HandlerFunc`;`Config.LogFormat` / `Config.LogLevel`
|
||||
- 测试基建约定:新单测写临时文件 + `slog.New(slog.NewTextHandler(file, …))` 式注入,或直接断言 `InitLogging` 后 `slog.Default()` 输出——用 `httptest`+gin engine 打请求断言行内容。
|
||||
|
||||
- [ ] **Step 1: 写失败的单测** `observability/logging_test.go`:
|
||||
1. `InitLogging("json","warn")` 后以 `slog.Info("x")` 不输出、`slog.Warn` 输出且 `jq` 可解析(用 `bytes.Buffer` 接管 handler 输出断言,别起子进程)。
|
||||
2. `InitLogging("bogus",…)` / `InitLogging(…,"bogus")` 返回 error。
|
||||
3. `RequestLogger`:gin engine(含 `/healthz` 与一个业务路由 + 一个不存在路径)经 httptest 打四类请求 → 断言:业务请求有 `msg=http_request method=… route=/api/games… status=… duration_ms=… ip=…` 行;`/healthz` 无行;未匹配路由 `route=nomatch`;`status>=500` 级别为 ERROR(`level=ERROR`/`"level":"ERROR"`)。
|
||||
Run(容器): `go test ./internal/observability/ -run 'Logging|RequestLogger' -v` → Expected: FAIL(包不存在 → `go vet` 或 build 错误即红)。
|
||||
|
||||
- [ ] **Step 2: 实现** `config.go` 增 `LogFormat`/`LogLevel`(`LOG_FORMAT` 默认 `text`、`LOG_LEVEL` 默认 `info`,非法值 `fmt.Errorf` 报错——仿 `parseAllowedOrigins` 的返回 err 风格,`Load()` 里接线)。
|
||||
- [ ] **Step 3: 实现** `observability/logging.go`:`InitLogging` 组装 `slog.NewTextHandler`/`NewJSONHandler(os.Stderr, &HandlerOptions{Level:…})` 并 `slog.SetDefault`;`RequestLogger` 按 Interfaces 断言实现(`c.FullPath()` 空→`nomatch`;`path=="/healthz"` 直接 `c.Next()` 不记录)。
|
||||
- [ ] **Step 4: router.go** 在 CORS 之后 `engine.Use(observability.RequestLogger())`(Recovery 之前挂,保证 panic 路径也有行;`gin.Recovery()` 不动)。
|
||||
- [ ] **Step 5: 迁移 26 处 log.Printf**(`grep -rn 'log\.' src --include='*.go' | grep -v _test` 清零 stdlib `log` import;serve.go 启动行 → `slog.Info("crearte-server listening", "port", cfg.Port, "storage", storageEnabled)`)。
|
||||
- [ ] **Step 6: 容器四连** `gofmt -l . && go vet ./... && go build ./... && go test ./...` → 全绿;Step 1 新测试 PASS。
|
||||
- [ ] **Step 7: 提交** `git add -A && git commit -m "feat(observability): slog structured logging + per-request log middleware (LOG_FORMAT/LOG_LEVEL)"`(server 仓 feat/backup-observability)。
|
||||
|
||||
### Task 2: server — /metrics + 权衡记录 + CHANGELOG
|
||||
|
||||
**Files:**
|
||||
- Create: `src/internal/observability/metrics.go` + `metrics_test.go`
|
||||
- Modify: `src/internal/observability/logging.go`(计时点并入指标记录——或独立 `Metrics` 中间件,二选一,实现者定,测试覆盖为准)
|
||||
- Modify: `src/internal/api/router.go`(`RouteMetrics = "/metrics"` 注册;`Deps` 增 `Metrics http.Handler` 或 `Pool *pgxpool.Pool` 可选字段——**注意现有 router_test 构造 Deps 的地方要兼容零值**)
|
||||
- Modify: `docs/README.md`(「已知权衡:限流单实例」节)、`docs/CHANGELOG.md`(0.12.0)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `observability.NewRegistry()`;`registry.Observe(method, route string, status int, seconds float64)`;`registry.Expose() string`(或 `WriteTo(io.Writer)`);`GET /metrics` 文本。
|
||||
|
||||
- [ ] **Step 1: 写失败的单测** `metrics_test.go`:httptest 经真 router(无池 Deps)打 `GET /api/games`×2、`POST /login` 404×1 → `GET /metrics` 断言:`# TYPE crearte_http_requests_total counter` 行、`crearte_http_requests_total{method="GET",route="/api/games",status="200"} 2`、`nomatch` 桶有行、`_duration_seconds_sum`+`_count` 成对、`go_goroutines` 存在、pgpool 族**不出现**(nil pool 省略)。再断言 `/metrics` 自身与 `/healthz` 不计入(先取计数、抓一轮 scrape、计数不变)。
|
||||
- [ ] **Step 2: 实现** metrics.go(`sync.Mutex` + map key `method|route|status`; Exposition 按指标名字典序输出,label 值本场景集合安全无需转义但注明);router 注册 `/metrics`(Content-Type: `text/plain; version=0.0.4`);pgxpool gauge 从 `Deps.Pool` 可选注入(serve.go 传池;测试传 nil)。
|
||||
- [ ] **Step 3: 容器四连** 同 Task 1 Step 6 → 全绿。
|
||||
- [ ] **Step 4: README 权衡节**(spec §3.3 三点:N× 限额/重启清零/扩缩前置条件)+ CHANGELOG 0.12.0(Added:metrics+请求日志;Changed:slog 迁移与 LOG_FORMAT/LOG_LEVEL;英文行+中文行)。
|
||||
- [ ] **Step 5: 提交** `git commit -m "feat(observability): hand-rolled /metrics exposition with http+go+pgpool families; document single-instance rate-limit tradeoff"`。
|
||||
|
||||
### Task 3: deploy — 备份脚本 + 恢复演练 + runbook + CI(0.6.0)
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/backup.sh`、`scripts/restore-drill.sh`、`docs/BACKUP-RUNBOOK.md`
|
||||
- Modify: `.env.example`(BACKUP_DIR/BACKUP_KEEP_DAYS 注释+键)、`README.md`(prod 节链 runbook)、`.github/workflows/validate.yml`(追加 dry-run 步骤)、`docs/CHANGELOG.md`(0.6.0)
|
||||
- 分支:deploy 仓 `feat/backup-observability`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: compose prod 服务名 `db-prod`/`minio-prod`、`.env` 变量 `POSTGRES_PASSWORD`/`MINIO_ROOT_USER`/`MINIO_ROOT_PASSWORD`/`MINIO_BUCKET`、debug profile `db-test`(宿主 127.0.0.1:${PG_TEST_PORT:-5432})
|
||||
- Produces: `backup.sh [--dry-run]`(退出码 0/非 0;stdout 计划/结果)、`restore-drill.sh`(PASS/FAIL 字面输出);产物布局 `$BACKUP_DIR/prod/<UTC 时间戳>/{database.dump,objects/,manifest.txt}` + `prod/latest` 软链。
|
||||
|
||||
- [ ] **Step 1: backup.sh**(bash `set -euo pipefail`,逐步实现 spec §4 七步;`compose()` 封装 `docker compose -f docker-compose.yml --profile prod`;dump 用 `exec -T db-prod pg_dump -U crearte -d crearte -Fc` 重定向到目标文件;mirror 用 `run --no-deps --rm -T minio-prod mc alias set local ... && mc mirror --overwrite --remove local/$MINIO_BUCKET $TARGET`——**注意 minio 镜像无 sh 之外的 coreutils 假设,先 `docker run --rm --entrypoint sh pgsty/minio:latest -c 'mc version'` 探明再写**;manifest 逐表 count 用循环 `psql -At -c "SELECT tablename FROM pg_tables WHERE schemaname='public'"` 拼表名到单条 `SELECT count(*) FROM x UNION ALL …`;修剪 `ls -1 | grep -E '^[0-9]{8}T[0-9]{6}Z$' | sort | head -n -$KEEP_DAYS` 删除;`latest` 用 `ln -sfn`)。
|
||||
- [ ] **Step 2: dry-run 腿** `--dry-run`:`set -a; . ./.env; set +a` 校验四变量非空 + 打印将执行的命令序列(不碰 docker)。本机先手工验证两态(缺 env 文件→红;.env 齐全→绿)。
|
||||
- [ ] **Step 3: restore-drill.sh**:读 `latest/manifest.txt` → `compose --profile debug` 起 db-test(`docker compose up -d db-test` 若未跑)→ `run --rm` 一次性 postgres:17-alpine 客户端 `pg_restore -h db-test -U crearte -d crearte --clean --if-exists`(drop/recreate 仅限该库)→ 重算逐表行数与 manifest diff → `RESTORE-DRILL: PASS|FAIL` 末行输出。
|
||||
- [ ] **Step 4: runbook**(定时约定 crontab 一行 + systemd timer 备选;两条恢复路径;`mc mirror` 反推对象恢复;3-2-1 与 rclone 可选钩子;已知风险节——同盘、非快照一致性、manifest 漂移,抄 spec §4 末段展开成操作语言)。
|
||||
- [ ] **Step 5: validate.yml 追加步骤**:现有 compose config 步骤后 `cp .env.example .env && printf 'POSTGRES_PASSWORD=…\nMINIO_ROOT_USER=…\nMINIO_ROOT_PASSWORD=…\n' >> .env && bash scripts/backup.sh --dry-run`(CI 无 docker daemon 场景 dry-run 不得触碰 docker 子命令——Step 1 实现顺序保证校验先于 docker 检查)。
|
||||
- [ ] **Step 6: 本机绿** `bash -n scripts/*.sh`、`cd .. && docker compose --profile prod config -q`、workflow 语法对照现有 validate.yml;CHANGELOG 0.6.0 双语;README 链接。
|
||||
- [ ] **Step 7: 提交** `git commit -m "feat(backup): pg_dump+mc-mirror backup with retention + restore drill against db-test; runbook; CI dry-run"`。
|
||||
|
||||
### Task 4: 本机全量验证(控制者亲跑,background+poll)
|
||||
|
||||
- [ ] 4.1 server 容器四连(Task 2 后终态复跑一遍,含全部新旧单测)。
|
||||
- [ ] 4.2 server 集成层:debug profile 起 db-test → `TEST_DATABASE_URL=postgres://crearte:crearte@127.0.0.1:5432/crearte?sslmode=disable go test ./internal/api/ -run Integration -count=1` 容器内跑(网络 `--network host` 或复用 compose 网),期望全 PASS 0 skip(P2 同款守卫)。
|
||||
- [ ] 4.3 prod 实栈冒烟:`compose --profile prod up -d --build` → 灌一轮真实流量(curl /api/games、login、404 路径、`GET /metrics`×2)→ `exec api-prod wget -qO- localhost:8080/metrics` 断言计数吻合;`logs api-prod` 断言 JSON 行(`LOG_FORMAT=json` 需在 .env 或 compose env 生效——验证 compose 是否透传,**若 api-env anchor 无此变量则本任务给 compose 增 `LOG_FORMAT: ${LOG_FORMAT:-text}`、`LOG_LEVEL: ${LOG_LEVEL:-info}` 两行透传**,属 deploy 侧合理补充,记入报告与 CHANGELOG 0.6.0);full-loop:`GO=1 REQUIRED=1 bash scripts/e2e-stack.sh`(端口冲突先停 prod 栈或错峰)。
|
||||
- [ ] 4.4 备份演练:`bash scripts/backup.sh` 全绿 → 产物三件套+latest 链在场;连跑第二次 + `BACKUP_KEEP_DAYS=1` 修剪断言;`bash scripts/restore-drill.sh` → PASS。
|
||||
- [ ] 4.5 收尾:prod 栈 down、4173/4174 释放、台账。
|
||||
|
||||
### Task 5: 合主推送 + wrapper 收尾(控制者)
|
||||
|
||||
- [ ] 5.1 server:`checkout master && pull --ff-only && merge --no-ff feat/backup-observability -m "Merge branch 'feat/backup-observability' — slog + /metrics (P3)" && push && branch -d`。
|
||||
- [ ] 5.2 deploy:同款,merge 信息 `— backup/restore scripts + runbook (P3)`。
|
||||
- [ ] 5.3 wrapper:ROADMAP P3 行 → 完成(双 hash+日期);索引表 P3 spec/plan 行加(已执行);CHANGELOG 0.2.6 双语;commit+push。
|
||||
|
||||
## Self-Review 记录
|
||||
|
||||
- spec §3.1/§3.2/§3.3 → Task 1/2/2-Step4;§4 七步 → Task 3 Step 1-5;§5 四条验收 → Task 4.1-4.4;D1/D2/D3 无未落地项。
|
||||
- 已知不确定点(实现者探明回填):pgsty/minio 镜像内 mc 与 sh 可用性(Task 3 Step 1 先探测);compose 对 LOG_FORMAT 透传(Task 4.3 现场定);router Deps 兼容性(Task 2 Step 2 注意既有 router_test 构造点零值安全)。
|
||||
- 占位符扫描:无 TBD;每步含可执行命令或精确文件行为。
|
||||
Reference in New Issue
Block a user