Files
crearte-monorepo/docs/plans/2026-09-30-backup-observability.md

107 lines
14 KiB
Markdown
Raw Permalink 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.
# 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;每步含可执行命令或精确文件行为。