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

14 KiB
Raw Blame History

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;每步含可执行命令或精确文件行为。