14 KiB
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:InitLogging("json","warn")后以slog.Info("x")不输出、slog.Warn输出且jq可解析(用bytes.Buffer接管 handler 输出断言,别起子进程)。InitLogging("bogus",…)/InitLogging(…,"bogus")返回 error。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清零 stdliblogimport;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 /login404×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 keymethod|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 profiledb-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;每步含可执行命令或精确文件行为。