merge: 集成V2.8 生产硬化与运维闭环

# Conflicts:
#	.gitignore
#	docs/deployment.md
#	docs/roadmap.md
#	package.json
This commit is contained in:
2026-07-08 18:15:07 +08:00
30 changed files with 1895 additions and 43 deletions

View File

@@ -39,3 +39,10 @@ SMTP_HOST=
SMTP_PORT=465 SMTP_PORT=465
SMTP_USER= SMTP_USER=
SMTP_PASS= SMTP_PASS=
# Optional monitoring profile. Do not commit real production passwords.
PROMETHEUS_PORT=9090
PROMETHEUS_RETENTION=15d
GRAFANA_PORT=3002
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=change-me-monitoring-password

View File

@@ -123,26 +123,11 @@ jobs:
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --remove-orphans docker compose --env-file .env.production -f docker-compose.prod.yml up -d --remove-orphans
for attempt in $(seq 1 30); do for attempt in $(seq 1 30); do
if docker compose --env-file .env.production -f docker-compose.prod.yml exec -T web node -e " if docker compose --env-file .env.production -f docker-compose.prod.yml exec -T web node scripts/smoke-test-release.mjs --base-url http://nginx --expected-version "${{ github.sha }}"; then
const expected = process.argv[1];
fetch('http://nginx/api/v1/health/version')
.then(async (response) => {
if (!response.ok) throw new Error('HTTP ' + response.status);
const payload = await response.json();
if (payload.version !== expected) {
throw new Error('Expected ' + expected + ', got ' + payload.version);
}
console.log('Runtime version verified: ' + payload.version);
})
.catch((error) => {
console.error(error.message);
process.exit(1);
});
" "${{ github.sha }}"; then
exit 0 exit 0
fi fi
sleep 2 sleep 2
done done
echo "Runtime version check failed after retries" echo "Release smoke check failed after retries"
exit 1 exit 1

1
.gitignore vendored
View File

@@ -16,3 +16,4 @@ next-env.d.ts
apps/server/data/ apps/server/data/
.worktrees/ .worktrees/
appdata-archive-*.json appdata-archive-*.json
backups/

View File

@@ -57,6 +57,7 @@ COPY --from=builder /app/turbo.json ./turbo.json
COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/packages/shared ./packages/shared COPY --from=builder /app/packages/shared ./packages/shared
COPY --from=builder /app/apps/web ./apps/web COPY --from=builder /app/apps/web ./apps/web
COPY scripts ./scripts
RUN chown -R node:node /app RUN chown -R node:node /app
USER node USER node
EXPOSE 3000 EXPOSE 3000

View File

@@ -0,0 +1,43 @@
# FTB Production Monitoring Baseline
This profile adds a deployable Prometheus/Grafana baseline for production operations. It is intentionally secret-free: no webhook URLs, API keys, SMTP passwords, or real alert receiver credentials are committed.
## Start
```bash
docker compose --env-file .env.production -f docker-compose.prod.yml --profile monitoring up -d
```
Default local ports:
- Prometheus: `http://localhost:9090`
- Grafana: `http://localhost:3002`
- Loki: internal only
Set `GRAFANA_ADMIN_USER` and `GRAFANA_ADMIN_PASSWORD` in `.env.production` before exposing Grafana beyond localhost. Keep real alert receivers in the server environment or an untracked Alertmanager file.
## Coverage
- DB availability: `pg_up` from postgres-exporter.
- Disk pressure: root filesystem availability from node-exporter.
- Slow API requests: Promtail turns `Slow API request` server logs into `ftb_slow_api_log_total`.
- Slow Prisma queries: Promtail turns `Slow Prisma query` server logs into `ftb_slow_prisma_log_total`.
- Job failures: Promtail turns `AppData relation sync failed` and AI call failure logs into `ftb_job_failure_log_total`.
- Xiaobao stale summaries: postgres-exporter custom query exposes `ftb_xiaobao_stale_summary_count` from `xiaobao_risk_summaries`.
## Alerts
Prometheus loads `prometheus/alert-rules.yml`. The rules evaluate locally and are visible in Prometheus/Grafana. To send notifications, add Alertmanager outside git or mount an environment-specific receiver file; do not commit webhook URLs or tokens.
Baseline alert names:
- `FtbPostgresDown`
- `FtbDiskPressure`
- `FtbSlowApiLogBurst`
- `FtbSlowPrismaLogBurst`
- `FtbJobFailureLogBurst`
- `FtbXiaobaoSummaryStale`
## Log Search
Promtail ships Docker logs to Loki with container labels. Grafana provisions both Prometheus and Loki data sources, so on-call checks can move from a firing alert to matching server logs without SSHing into the host.

View File

@@ -0,0 +1,10 @@
modules:
http_2xx:
prober: http
timeout: 5s
http:
valid_http_versions: ['HTTP/1.1', 'HTTP/2.0']
valid_status_codes: []
method: GET
preferred_ip_protocol: ip4

View File

@@ -0,0 +1,78 @@
{
"uid": "ftb-production-overview",
"title": "FTB Production Overview",
"schemaVersion": 39,
"version": 1,
"refresh": "30s",
"tags": ["ftb", "production", "v2.8"],
"time": {
"from": "now-6h",
"to": "now"
},
"panels": [
{
"id": 1,
"type": "stat",
"title": "DB Availability",
"gridPos": { "x": 0, "y": 0, "w": 6, "h": 4 },
"targets": [
{ "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "pg_up", "refId": "A" }
]
},
{
"id": 2,
"type": "timeseries",
"title": "Slow API Logs",
"gridPos": { "x": 6, "y": 0, "w": 6, "h": 4 },
"targets": [
{ "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "increase(ftb_slow_api_log_total[10m])", "refId": "A" }
]
},
{
"id": 3,
"type": "timeseries",
"title": "Slow Prisma Logs",
"gridPos": { "x": 12, "y": 0, "w": 6, "h": 4 },
"targets": [
{ "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "increase(ftb_slow_prisma_log_total[10m])", "refId": "A" }
]
},
{
"id": 4,
"type": "stat",
"title": "Xiaobao Stale Summaries",
"gridPos": { "x": 18, "y": 0, "w": 6, "h": 4 },
"targets": [
{ "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "ftb_xiaobao_stale_summary_count", "refId": "A" }
]
},
{
"id": 5,
"type": "timeseries",
"title": "Job Failure Logs",
"gridPos": { "x": 0, "y": 4, "w": 8, "h": 5 },
"targets": [
{ "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "increase(ftb_job_failure_log_total[10m])", "refId": "A" }
]
},
{
"id": 6,
"type": "stat",
"title": "Root Disk Free %",
"gridPos": { "x": 8, "y": 4, "w": 8, "h": 5 },
"targets": [
{ "datasource": { "type": "prometheus", "uid": "Prometheus" }, "expr": "100 * node_filesystem_avail_bytes{mountpoint=\"/\",fstype!~\"tmpfs|overlay\"} / node_filesystem_size_bytes{mountpoint=\"/\",fstype!~\"tmpfs|overlay\"}", "refId": "A" }
]
},
{
"id": 7,
"type": "logs",
"title": "Server Warning/Error Logs",
"gridPos": { "x": 16, "y": 4, "w": 8, "h": 5 },
"targets": [
{ "datasource": { "type": "loki", "uid": "Loki" }, "expr": "{compose_service=\"server\"} |~ \"warn|error|失败|Slow\"", "refId": "A" }
]
}
]
}

View File

@@ -0,0 +1,12 @@
apiVersion: 1
providers:
- name: FTB Production
orgId: 1
folder: FTB
type: file
disableDeletion: false
updateIntervalSeconds: 30
options:
path: /var/lib/grafana/dashboards

View File

@@ -0,0 +1,16 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
- name: Loki
type: loki
access: proxy
url: http://loki:3100
editable: false

View File

@@ -0,0 +1,32 @@
auth_enabled: false
server:
http_listen_port: 3100
common:
path_prefix: /loki
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2026-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
storage_config:
tsdb_shipper:
active_index_directory: /loki/index
cache_location: /loki/index_cache
filesystem:
directory: /loki/chunks
limits_config:
retention_period: 168h

View File

@@ -0,0 +1,12 @@
ftb_xiaobao:
query: |
SELECT
count(*)::float AS stale_summary_count
FROM xiaobao_risk_summaries
WHERE dirty = true
OR updated_at < now() - interval '6 hours';
metrics:
- stale_summary_count:
usage: GAUGE
description: Xiaobao risk summaries that are dirty or older than 6 hours.

View File

@@ -0,0 +1,62 @@
groups:
- name: ftb-production-alerts
rules:
- alert: FtbPostgresDown
expr: pg_up == 0
for: 2m
labels:
severity: critical
annotations:
summary: PostgreSQL exporter cannot reach the FTB database.
runbook: docs/runbooks/migration-rollback.md
- alert: FtbDiskPressure
expr: |
(
node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay"}
/
node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay"}
) < 0.15
for: 10m
labels:
severity: warning
annotations:
summary: Production host root filesystem has less than 15% free space.
runbook: docs/deployment.md
- alert: FtbSlowApiLogBurst
expr: increase(ftb_slow_api_log_total[10m]) > 5
for: 2m
labels:
severity: warning
annotations:
summary: Slow API request log volume crossed the V2.8 baseline threshold.
runbook: docs/deployment.md
- alert: FtbSlowPrismaLogBurst
expr: increase(ftb_slow_prisma_log_total[10m]) > 3
for: 2m
labels:
severity: warning
annotations:
summary: Slow Prisma query log volume crossed the V2.8 baseline threshold.
runbook: docs/deployment.md
- alert: FtbJobFailureLogBurst
expr: increase(ftb_job_failure_log_total[10m]) > 0
for: 1m
labels:
severity: warning
annotations:
summary: Background or compatibility job failure logs were detected.
runbook: docs/runbooks/xiaobao-background-jobs.md
- alert: FtbXiaobaoSummaryStale
expr: ftb_xiaobao_stale_summary_count > 0
for: 15m
labels:
severity: warning
annotations:
summary: Xiaobao warning summaries are dirty or stale.
runbook: docs/runbooks/xiaobao-background-jobs.md

View File

@@ -0,0 +1,45 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
rule_files:
- /etc/prometheus/alert-rules.yml
scrape_configs:
- job_name: prometheus
static_configs:
- targets: ['prometheus:9090']
- job_name: postgres-exporter
static_configs:
- targets: ['postgres-exporter:9187']
- job_name: node-exporter
static_configs:
- targets: ['node-exporter:9100']
- job_name: cadvisor
static_configs:
- targets: ['cadvisor:8080']
- job_name: promtail
static_configs:
- targets: ['promtail:9080']
- job_name: blackbox-http
metrics_path: /probe
params:
module: [http_2xx]
static_configs:
- targets:
- http://nginx/api/v1/health/version
- http://nginx/api/v1/config/ai
- http://nginx/api/v1/v2.2/requirements?productId=__smoke__&limit=1
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox-exporter:9115

View File

@@ -0,0 +1,53 @@
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 15s
relabel_configs:
- source_labels: ['__meta_docker_container_name']
regex: '/(.*)'
target_label: container
- source_labels: ['__meta_docker_container_label_com_docker_compose_service']
target_label: compose_service
- source_labels: ['__meta_docker_container_label_com_docker_compose_project']
target_label: compose_project
pipeline_stages:
- docker: {}
- match:
selector: '{compose_service="server"} |= "Slow API request"'
stages:
- metrics:
ftb_slow_api_log_total:
type: Counter
description: Slow API request log entries emitted by the NestJS server.
config:
action: inc
- match:
selector: '{compose_service="server"} |= "Slow Prisma query"'
stages:
- metrics:
ftb_slow_prisma_log_total:
type: Counter
description: Slow Prisma query log entries emitted by the NestJS server.
config:
action: inc
- match:
selector: '{compose_service="server"} |~ "AppData relation sync failed|AI 调用失败|AI 风险解读调用失败"'
stages:
- metrics:
ftb_job_failure_log_total:
type: Counter
description: Compatibility sync, AI job, or background task failure logs.
config:
action: inc

View File

@@ -123,7 +123,117 @@ services:
server: server:
condition: service_healthy condition: service_healthy
prometheus:
image: prom/prometheus:v2.53.1
profiles: ['monitoring']
restart: unless-stopped
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=${PROMETHEUS_RETENTION:-15d}'
- '--web.enable-lifecycle'
ports:
- '${PROMETHEUS_PORT:-9090}:9090'
volumes:
- ./deploy/monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./deploy/monitoring/prometheus/alert-rules.yml:/etc/prometheus/alert-rules.yml:ro
- prometheus_data:/prometheus
depends_on:
- postgres-exporter
- node-exporter
- cadvisor
- promtail
- blackbox-exporter
grafana:
image: grafana/grafana:11.1.0
profiles: ['monitoring']
restart: unless-stopped
ports:
- '${GRAFANA_PORT:-3002}:3000'
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-change-me-monitoring-password}
GF_USERS_ALLOW_SIGN_UP: 'false'
volumes:
- grafana_data:/var/lib/grafana
- ./deploy/monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
- ./deploy/monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
depends_on:
- prometheus
- loki
loki:
image: grafana/loki:2.9.8
profiles: ['monitoring']
restart: unless-stopped
command: ['-config.file=/etc/loki/config.yml']
volumes:
- ./deploy/monitoring/loki/config.yml:/etc/loki/config.yml:ro
- loki_data:/loki
promtail:
image: grafana/promtail:2.9.8
profiles: ['monitoring']
restart: unless-stopped
command: ['-config.file=/etc/promtail/config.yml']
volumes:
- ./deploy/monitoring/promtail/config.yml:/etc/promtail/config.yml:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
depends_on:
- loki
postgres-exporter:
image: quay.io/prometheuscommunity/postgres-exporter:v0.15.0
profiles: ['monitoring']
restart: unless-stopped
command:
- '--extend.query-path=/etc/postgres-exporter/postgres-queries.yml'
environment:
DATA_SOURCE_NAME: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-ftb_pm}?sslmode=disable
volumes:
- ./deploy/monitoring/postgres/postgres-queries.yml:/etc/postgres-exporter/postgres-queries.yml:ro
depends_on:
postgres:
condition: service_healthy
node-exporter:
image: prom/node-exporter:v1.8.2
profiles: ['monitoring']
restart: unless-stopped
command:
- '--path.rootfs=/host'
volumes:
- /:/host:ro,rslave
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.49.1
profiles: ['monitoring']
restart: unless-stopped
privileged: true
devices:
- /dev/kmsg:/dev/kmsg
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
blackbox-exporter:
image: prom/blackbox-exporter:v0.25.0
profiles: ['monitoring']
restart: unless-stopped
command:
- '--config.file=/etc/blackbox/config.yml'
volumes:
- ./deploy/monitoring/blackbox/config.yml:/etc/blackbox/config.yml:ro
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:
server_data: server_data:
prometheus_data:
grafana_data:
loki_data:

View File

@@ -175,12 +175,13 @@ cp .env.production.example .env.production
5. 执行 `docker compose --env-file .env.production -f docker-compose.prod.yml pull web server` 拉取本次 SHA 镜像。 5. 执行 `docker compose --env-file .env.production -f docker-compose.prod.yml pull web server` 拉取本次 SHA 镜像。
6. 启动数据库与 Redis执行 `pnpm --filter server db:deploy` 6. 启动数据库与 Redis执行 `pnpm --filter server db:deploy`
7. 执行 `docker compose --env-file .env.production -f docker-compose.prod.yml up -d --remove-orphans` 重启服务。 7. 执行 `docker compose --env-file .env.production -f docker-compose.prod.yml up -d --remove-orphans` 重启服务。
8. 通过 `/api/v1/health/version` 校验运行中的后端版本是否等于本次 commit SHA。 8. 运行发布 smoke test校验 `/api/v1/health/version`、前端首页、产品页、产品 API、V2.2 读路径和 AI 配置端点,并确认运行中的后端版本等于本次 commit SHA。
如果最后一步失败Actions 会红掉,说明“代码已合并”不等于“线上容器已更新”。本地或服务器也可以手工运行: 如果最后一步失败Actions 会红掉,说明“代码已合并”不等于“线上容器已更新”或关键读路径不可用。本地或服务器也可以手工运行:
```bash ```bash
pnpm deploy:check-runtime http://localhost/api/v1/health/version <expected-commit-sha> pnpm deploy:check-runtime http://localhost/api/v1/health/version <expected-commit-sha>
pnpm deploy:smoke -- --base-url http://localhost --expected-version <expected-commit-sha>
``` ```
## Nginx 路由 ## Nginx 路由
@@ -193,6 +194,30 @@ pnpm deploy:check-runtime http://localhost/api/v1/health/version <expected-commi
生产 Compose 默认只监听 HTTP 80。HTTPS 建议优先交给云负载均衡、CDN 或宿主机外层证书管理工具;如果要让本 Compose 内的 Nginx 直接处理 HTTPS可以在后续增加证书 volume 和 443 server block。 生产 Compose 默认只监听 HTTP 80。HTTPS 建议优先交给云负载均衡、CDN 或宿主机外层证书管理工具;如果要让本 Compose 内的 Nginx 直接处理 HTTPS可以在后续增加证书 volume 和 443 server block。
## 监控与告警基线
V2.8 提供可选 `monitoring` profile不影响默认生产启动。启用前先在 `.env.production` 设置 `GRAFANA_ADMIN_PASSWORD`,不要使用示例密码对外暴露 Grafana。
```bash
docker compose --env-file .env.production -f docker-compose.prod.yml --profile monitoring up -d
```
默认入口:
- Prometheus: `http://localhost:9090`
- Grafana: `http://localhost:3002`
配置目录在 `deploy/monitoring/`。基线覆盖:
- `FtbPostgresDown`PostgreSQL 不可用。
- `FtbDiskPressure`:宿主机根分区低于 15% 可用空间。
- `FtbSlowApiLogBurst`:服务端慢 API 日志在 10 分钟内超过阈值。
- `FtbSlowPrismaLogBurst`:慢 Prisma 查询日志在 10 分钟内超过阈值。
- `FtbJobFailureLogBurst`AppData 同步、AI 调用或后台任务失败日志出现。
- `FtbXiaobaoSummaryStale``xiaobao_risk_summaries` 存在 dirty 或超过 6 小时未更新的摘要。
Prometheus 只加载本地规则,不提交真实通知密钥。接入 Slack、企业微信、邮件等通知时把 Alertmanager receiver 放在未跟踪的服务器文件或环境变量中。
## 数据持久化 ## 数据持久化
生产 Compose 使用三个命名 volume 生产 Compose 使用三个命名 volume
@@ -235,6 +260,76 @@ pnpm consistency:v25 -- --url http://localhost/api/v1/consistency
- `warn` 需要记录原因;历史数据没有审计事件属于预期 warning不阻断 V2.5。 - `warn` 需要记录原因;历史数据没有审计事件属于预期 warning不阻断 V2.5。
- 后台页面 `/admin/consistency` 展示同一份报告,需要当前用户具备 `consistency:view` - 后台页面 `/admin/consistency` 展示同一份报告,需要当前用户具备 `consistency:view`
## 备份与恢复
备份分两类PostgreSQL 业务数据和 `server_data` volume 中的运行时配置。真实备份文件默认写到 `backups/`,该目录已加入 `.gitignore`,不要把 dump 或 tar 包提交到仓库。
先演练命令,不写文件:
```bash
pnpm backup:postgres -- --dry-run --env-file .env.production
pnpm backup:server-data -- --dry-run --env-file .env.production
```
执行正式备份:
```bash
pnpm backup:postgres -- --env-file .env.production
pnpm backup:server-data -- --env-file .env.production
```
PostgreSQL 备份使用 `pg_dump --format=custom --no-owner --no-acl`,便于跨环境恢复。`server_data` 备份使用只读 volume mount 和 `tar -czf`,覆盖 AI Provider 配置等后端运行时文件。
恢复数据库必须显式确认覆盖。默认命令会拒绝执行,防止误删现有库:
```bash
pnpm restore:postgres -- --env-file .env.production --input backups/postgres/ftb_pm-postgres-20260708T120000Z.dump
```
确认要把目标数据库重建为 fresh DB 后,再运行:
```bash
pnpm restore:postgres -- \
--env-file .env.production \
--input backups/postgres/ftb_pm-postgres-20260708T120000Z.dump \
--confirm-overwrite
```
恢复前建议先 dry-run 看清将执行的 `psql/dropdb/createdb/pg_restore` 步骤:
```bash
pnpm restore:postgres -- \
--dry-run \
--confirm-overwrite \
--env-file .env.production \
--input backups/postgres/ftb_pm-postgres-20260708T120000Z.dump
```
如果要恢复到临时库做校验,不覆盖当前生产库:
```bash
pnpm restore:postgres -- \
--env-file .env.production \
--input backups/postgres/ftb_pm-postgres-20260708T120000Z.dump \
--target-db ftb_pm_restore_check \
--confirm-overwrite
```
## 运维 Runbooks
生产发布、迁移和异常处置优先使用这些手册:
- `docs/runbooks/migration-rollback.md`:发布失败、迁移失败、数据恢复和镜像回滚。
- `docs/runbooks/appdata-retirement.md`AppData key 分阶段退场、双读核对、归档和回滚。
- `docs/runbooks/xiaobao-background-jobs.md`:小宝摘要 stale 告警、手动刷新和未来后台任务规则。
- `docs/production-readiness.md`:生产发布前后证据清单。
提交前运行 runbook 扫描:
```bash
pnpm docs:check-runbooks
```
## 升级流程 ## 升级流程
```bash ```bash
@@ -248,7 +343,8 @@ docker compose --env-file .env.production -f docker-compose.prod.yml exec server
升级前建议先备份数据库: 升级前建议先备份数据库:
```bash ```bash
docker compose --env-file .env.production -f docker-compose.prod.yml exec postgres pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" > ftb_pm_backup.sql pnpm backup:postgres -- --env-file .env.production
pnpm backup:server-data -- --env-file .env.production
``` ```
## 常见排查 ## 常见排查

View File

@@ -0,0 +1,24 @@
# Production Readiness Checklist
Use this before a production release and again after the release smoke test. Each item requires evidence, not a verbal assertion.
| Area | Gate | Evidence |
| --- | --- | --- |
| Backup and restore | PostgreSQL backup dry-run and server_data backup dry-run are reviewed. | `pnpm backup:postgres -- --dry-run --env-file .env.production` and `pnpm backup:server-data -- --dry-run --env-file .env.production` output saved in release notes. |
| Backup and restore | Restore command refuses overwrite without explicit confirmation. | `pnpm restore:postgres -- --env-file .env.production --input backups/postgres/ftb_pm-postgres-20260708T120000Z.dump` exits non-zero and names `--confirm-overwrite`. |
| Backup and restore | Fresh DB restore rehearsal completed before destructive restore. | Temporary database restore command and row-count comparison from `docs/runbooks/migration-rollback.md`. |
| Smoke tests | Release smoke is wired into GitHub Actions. | `.github/workflows/deploy-production.yml` runs `scripts/smoke-test-release.mjs` with `--expected-version`. |
| Smoke tests | Manual smoke can be run against the target. | `pnpm deploy:smoke -- --base-url http://localhost --expected-version "$APP_VERSION"` output. |
| Monitoring | Monitoring profile renders and can start without committed secrets. | `docker compose --env-file .env.production -f docker-compose.prod.yml --profile monitoring config` exits 0. |
| Monitoring | Required alert rules exist. | `FtbPostgresDown`, `FtbDiskPressure`, `FtbSlowApiLogBurst`, `FtbSlowPrismaLogBurst`, `FtbJobFailureLogBurst`, and `FtbXiaobaoSummaryStale` visible in Prometheus. |
| Audit | Domain write APIs record actor and resource scope where implemented. | API request sample or audit log sample for Product, Requirement, Project, Version, execution entities, and dictionary writes. |
| RBAC | Project and version operations enforce Owner, Admin, Member, Viewer boundaries where enabled. | Permission matrix test result or manual account walkthrough attached to release notes. |
| Consistency | AppData and relation tables are checked for migrated domains. | Count and missing-partition-key queries from `docs/runbooks/appdata-retirement.md`. |
| Performance | Slow API and slow Prisma thresholds are configured. | `API_SLOW_REQUEST_MS` and `PRISMA_SLOW_QUERY_MS` values recorded, plus Grafana slow-log panels checked. |
| Performance | V2.2 hot reads avoid full-table scans. | Requirement pool smoke uses `productId`; query plan or service test evidence attached for high-volume domains. |
| Post-release | Runtime version matches the release SHA. | `/api/v1/health/version` payload or `pnpm deploy:check-runtime` output. |
| Post-release | Product, requirement, workspace, Xiaobao, and AI config read paths respond. | `pnpm deploy:smoke` output attached. |
| Post-release | On-call rollback path is known. | `docs/runbooks/migration-rollback.md` link included in release notes. |
Release owner signs off only after all required evidence is attached to the release notes or incident record.

View File

@@ -1,26 +1,12 @@
# 开发路线图 # 开发路线图
## 当前阶段V2.7 已完成 — 下一阶段 V2.8 生产硬化与运维闭环集成 ## 当前阶段V2.8 已完成 — 统一验证与生产交付收口
V2.7 已在关系表主源方向上补齐企业协作和治理能力:通知、评论与提及、项目成员治理、管理驾驶舱、治理字典、以及统一 RBAC/audit 适配器。V2.7 不新增 AppData 主存储 V2.8 已在既有生产 CI/CD 基线上补齐运维闭环:备份恢复演练、发布 smoke test、监控告警、日志检索、迁移回滚 runbook、AppData 退场 runbook、小宝后台化 runbook 和生产 readiness 证据清单。本阶段不重新设计业务流程,专注把生产发布和故障处置做成可验证、可复盘、可回滚的标准流程
### 当前重点 V2.4 已将高增长和核心业务领域从“AppData 主写 + 关系表同步副本”推进到“领域 CRUD 主写关系表 + AppData 兼容/迁移兜底”。V2.5 已收口后端权限、审计、AppData 禁写和一致性核对。V2.6 已完成大数据性能增强、小宝风险后台化、AI 解读队列和运行时 Ops 看板。V2.7 已补齐企业协作和治理能力,且不新增 AppData 主存储。
1. **协作通知**通知记录、已读状态、NotificationBell并覆盖 assignment / mention / risk_alert / overdue_item 稳定事件类型。 ### V2.5-V2.8 完成范围
2. **通用评论**DevTask/TestCase/Bug/Requirement/VersionPlan 统一评论面板,支持 `@成员名` 和显式成员选择,创建/删除写 audit。
3. **项目成员治理**Owner/Admin/Member/Viewer 服务端强校验,禁止移除最后 Owner角色变更写 audit。
4. **管理驾驶舱**:只读关系表和 summary聚合活跃版本、逾期、阻塞、风险和成员负载。
5. **治理设置**:集中维护 task category、requirement type/platform/source使用中的字典不可硬删支持导入导出。
V2.4 已将高增长和核心业务领域从“AppData 主写 + 关系表同步副本”推进到“领域 CRUD 主写关系表 + AppData 兼容/迁移兜底”。V2.2 快读 API 和 V2.3 AppData 写后同步继续保留,但它们现在是兼容基础设施,不再是已迁移领域的数据新鲜度主链路。
V2.5 的目标是正式收口后端权限、审计、AppData 禁写和一致性核对。AppData 不能直接删除,必须按“禁写 → 双读核对 → 移除 fallback → 只读归档/导出 → 后续删表”的顺序推进。
V2.6 的目标是在关系表主源稳定后完成大数据性能增强、小宝风险后台化、AI 解读队列和运行时 Ops 看板,让高增长热路径、后台任务和风险摘要不再依赖页面打开。
V2.8 的目标是在现有生产部署基线上补齐备份恢复演练、发布 smoke test、监控告警、日志检索、迁移回滚和运维手册形成生产交付稳定版。
### V2.5-V2.7 完成范围
1. **RBAC 收口**:领域 mutation API 已接入服务端权限校验、资源作用域和当前用户上下文。 1. **RBAC 收口**:领域 mutation API 已接入服务端权限校验、资源作用域和当前用户上下文。
2. **审计事件**:领域 mutation 通过 `audit_events` 写 append-only audit event支持后台查询和敏感字段脱敏。 2. **审计事件**:领域 mutation 通过 `audit_events` 写 append-only audit event支持后台查询和敏感字段脱敏。
@@ -37,6 +23,11 @@ V2.8 的目标是在现有生产部署基线上补齐备份恢复演练、发布
13. **项目成员治理**:已补项目成员 Owner/Admin/Member/Viewer 服务端治理,禁止移除最后 Owner角色变更写审计。 13. **项目成员治理**:已补项目成员 Owner/Admin/Member/Viewer 服务端治理,禁止移除最后 Owner角色变更写审计。
14. **管理驾驶舱**:已补只读关系表和 summary 的管理概览,聚合活跃版本、逾期、阻塞、风险和成员负载。 14. **管理驾驶舱**:已补只读关系表和 summary 的管理概览,聚合活跃版本、逾期、阻塞、风险和成员负载。
15. **治理设置**:已补 task category、requirement type/platform/source 等治理字典能力,使用中的字典不可硬删,支持导入导出。 15. **治理设置**:已补 task category、requirement type/platform/source 等治理字典能力,使用中的字典不可硬删,支持导入导出。
16. **备份恢复自动化**:已补 PostgreSQL dump、`server_data` volume 备份、fresh DB restore dry-run 和显式覆盖确认。
17. **发布 smoke test**:已补部署后 runtime version、前端根页、产品页、产品 API、V2.2 读路径和 AI 配置校验。
18. **监控告警基线**:已补 Prometheus/Grafana/Loki/Promtail 可选 profile覆盖慢 API、慢 Prisma、任务失败、小宝摘要 stale、磁盘压力和 DB 可用性。
19. **运维 runbook**已补迁移回滚、AppData 退场、小宝后台化处置步骤、决策点和数据风险。
20. **生产 readiness 清单**:已补 backup/restore、smoke、monitoring、audit、RBAC、consistency、performance 和 post-release verification 证据项。
## V2 分阶段交付链路 ## V2 分阶段交付链路
@@ -58,7 +49,7 @@ V2.8 的目标是在现有生产部署基线上补齐备份恢复演练、发布
- 项目已经不是早期骨架。前端业务功能已覆盖产品、项目、版本详情、需求池、工作台、成员/角色/任务类型、加班、小宝预警和 AI 配置等主要管理端路由。 - 项目已经不是早期骨架。前端业务功能已覆盖产品、项目、版本详情、需求池、工作台、成员/角色/任务类型、加班、小宝预警和 AI 配置等主要管理端路由。
- 版本详情已有需求、调研、产品方案、UI、开发任务、测试用例、Bug、概览等核心 Tab渲染重的路径优先接入关系表快读并保留 AppData fallback。 - 版本详情已有需求、调研、产品方案、UI、开发任务、测试用例、Bug、概览等核心 Tab渲染重的路径优先接入关系表快读并保留 AppData fallback。
- 后端已落地 Product、Project、Version、Requirement、VersionPlan、DevTask、TestCase、Bug、Member、TaskCategory、TaskWorklog、Overtime、WorkActivity 领域 CRUD/write API。 - 后端已落地 Product、Project、Version、Requirement、VersionPlan、DevTask、TestCase、Bug、Member、TaskCategory、TaskWorklog、Overtime、WorkActivity 领域 CRUD/write API。
- Prisma schema 已包含 Product、Project、Version、Requirement、VersionPlan、DevTask、TestCase、Bug、WorkActivity、TaskWorklog、Overtime、Xiaobao、AiLog、AppData 等关系模型;高增长表的分区 migration 已落地。 - Prisma schema 已包含 Product、Project、Version、Requirement、VersionPlan、DevTask、TestCase、Bug、WorkActivity、TaskWorklog、Overtime、Xiaobao、AiLog、AppData、Notification、Comment、ProjectMember、GovernanceDictionary 等关系模型;高增长表的分区 migration 已落地。
- 主写入源已经切到领域 API前端 store 优先调用 `apps/web/lib/domain-api.ts`AppData 只保留兼容读取、历史核对和少量旧配置形状。 - 主写入源已经切到领域 API前端 store 优先调用 `apps/web/lib/domain-api.ts`AppData 只保留兼容读取、历史核对和少量旧配置形状。
- V2.5 后端服务端权限、审计和一致性控制面已启用:领域 mutation 使用 `@ProtectedMutation()`,审计写 `audit_events`,后台查询需要 `audit:view` / `consistency:view` - V2.5 后端服务端权限、审计和一致性控制面已启用:领域 mutation 使用 `@ProtectedMutation()`,审计写 `audit_events`,后台查询需要 `audit:view` / `consistency:view`
- 所有业务 AppData key 已明确冻结或只读归档;`PUT /api/v1/data/:key` 对这些 key 返回 `APP_DATA_WRITE_FROZEN``GET` 留作历史核对与归档。 - 所有业务 AppData key 已明确冻结或只读归档;`PUT /api/v1/data/:key` 对这些 key 返回 `APP_DATA_WRITE_FROZEN``GET` 留作历史核对与归档。
@@ -76,10 +67,16 @@ V2.8 的目标是在现有生产部署基线上补齐备份恢复演练、发布
- V2.7.3 已新增项目成员治理 API 和项目页成员面板,服务端强校验 Owner/Admin/Member/Viewer 边界。 - V2.7.3 已新增项目成员治理 API 和项目页成员面板,服务端强校验 Owner/Admin/Member/Viewer 边界。
- V2.7.4 已新增管理驾驶舱和治理设置,聚合关系表指标并维护治理字典。 - V2.7.4 已新增管理驾驶舱和治理设置,聚合关系表指标并维护治理字典。
- V2.7.5 已新增协作治理 RBAC/audit adapter避免新增模块绕开服务端权限和审计边界。 - V2.7.5 已新增协作治理 RBAC/audit adapter避免新增模块绕开服务端权限和审计边界。
- V2.8 新增运维交付物集中在 `scripts/``.github/workflows/deploy-production.yml``deploy/monitoring/``docs/runbooks/``docs/deployment.md``docs/production-readiness.md`
- AppData 退场、RBAC/审计、性能、小宝后台化、协作治理和生产发布都通过 production readiness 证据项追踪,避免把运维稳定版误当成一次性口头验收。
### 已完成(按时间倒序) ### 已完成(按时间倒序)
**2026-07-08** **2026-07-08**
- V2.8 added PostgreSQL backup, fresh DB restore with explicit overwrite confirmation, and `server_data` volume backup automation.
- V2.8 added release smoke suite and wired GitHub Actions deployment verification to runtime version, frontend root, products, V2.2 read path, and AI config checks.
- V2.8 added optional monitoring profile with Prometheus, Grafana, Loki, Promtail, postgres-exporter, node-exporter, cAdvisor, and blackbox-exporter.
- V2.8 added migration rollback, AppData retirement, Xiaobao background jobs runbooks, and production readiness evidence checklist.
- V2.7.5 added shared collaboration/governance RBAC and audit adapters so notification, comment, project-member, management, and governance modules keep a single permission/audit boundary. - V2.7.5 added shared collaboration/governance RBAC and audit adapters so notification, comment, project-member, management, and governance modules keep a single permission/audit boundary.
- V2.7.4 added management and governance admin pages for relation-backed overview metrics and dictionary governance. - V2.7.4 added management and governance admin pages for relation-backed overview metrics and dictionary governance.
- V2.7.3 added project-member governance APIs and project member panel with Owner/Admin/Member/Viewer safeguards. - V2.7.3 added project-member governance APIs and project member panel with Owner/Admin/Member/Viewer safeguards.
@@ -188,12 +185,12 @@ V2.8 的目标是在现有生产部署基线上补齐备份恢复演练、发布
### 进行中 ### 进行中
- V2.8 生产硬化与运维闭环:备份恢复演练、发布 smoke test、监控告警、日志检索、迁移回滚和运维手册 - V2.8 统一验证与发布收口合并后集中跑类型、测试、Prisma、部署、runbook、备份恢复和 smoke 验证
- 项目详情页 VersionCard 状态胶囊数据联动(部分已完成) - 项目详情页 VersionCard 状态胶囊数据联动(部分已完成)
## V2 — 后端接入 ## V2 — 后端接入
NestJS + Prisma + PostgreSQL 已推进到 V2.7。第一阶段用 `app_data` JSONB 文档表承接现有 store 数据形状避免浏览器清站点数据导致业务数据丢失第二阶段建立分区关系表、V2.2 快读 API 和 V2.3 AppData 写后同步;第三阶段 V2.4 已逐领域启用写 API让前端 store 从 AppData 主写入迁移到领域 CRUD 主写;第四阶段 V2.5 已冻结 AppData 业务写入并收口服务端 RBAC、审计和一致性校验第五阶段 V2.6 已完成大数据性能和小宝后台化;第六阶段 V2.7 已补齐协作治理能力。 NestJS + Prisma + PostgreSQL 已推进到 V2.8。第一阶段用 `app_data` JSONB 文档表承接现有 store 数据形状避免浏览器清站点数据导致业务数据丢失第二阶段建立分区关系表、V2.2 快读 API 和 V2.3 AppData 写后同步;第三阶段 V2.4 已逐领域启用写 API让前端 store 从 AppData 主写入迁移到领域 CRUD 主写;第四阶段 V2.5 已冻结 AppData 业务写入并收口服务端 RBAC、审计和一致性校验第五阶段 V2.6 已完成大数据性能和小宝后台化;第六阶段 V2.7 已补齐协作治理能力;第七阶段 V2.8 已补齐生产运维、备份恢复、监控告警和回滚手册
### 关键任务 ### 关键任务
@@ -306,6 +303,6 @@ V2.5 完成后的保留边界:`GET /api/v1/data/:key` 仍可读历史 JSONX
|------|------| |------|------|
| V1 业务流程打磨 | 进行中 | | V1 业务流程打磨 | 进行中 |
| V1 朋友试用反馈 | 持续中 | | V1 朋友试用反馈 | 持续中 |
| V2 后端接入 | 进行中V2.7 已完成V2.8 生产硬化与运维闭环待集成) | | V2 后端接入 | V2.1-V2.8 已完成,等待统一验证与发布授权 |
| V3 AI 集成 | 等 V2 数据沉淀 | | V3 AI 集成 | 等 V2 数据沉淀 |
| 公开发布 | TBD | | 公开发布 | TBD |

View File

@@ -0,0 +1,72 @@
# AppData Retirement Runbook
Use this when retiring an AppData key after its domain writes have moved to relation-table APIs. The order is fixed: backup, measure, freeze writes, compare, remove fallback, archive.
## Scope Gate
Retire one AppData key family at a time. Good candidates have domain CRUD writes, read APIs, pagination boundaries, audit events, and a rollback path.
## Preparation
```bash
pnpm backup:postgres -- --env-file .env.production
pnpm backup:server-data -- --env-file .env.production
pnpm deploy:smoke -- --base-url http://localhost
```
Record the key family being retired, the owning domain API, and the relation tables that replace it.
## Count And Consistency Checks
Run counts before disabling writes.
```bash
docker compose --env-file .env.production -f docker-compose.prod.yml exec postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "select key, jsonb_array_length(value) as appdata_rows from app_data where key in ('products-overview','requirements','version-plans','dev-tasks','test-cases','bugs','members','task-categories','task-worklogs','overtime') order by key;"
docker compose --env-file .env.production -f docker-compose.prod.yml exec postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "select 'requirements' as table_name, count(*) from requirements union all select 'version_plans', count(*) from version_plans union all select 'dev_tasks', count(*) from dev_tasks union all select 'test_cases', count(*) from test_cases union all select 'bugs', count(*) from bugs;"
```
For partitioned entities, also check missing partition keys.
```bash
docker compose --env-file .env.production -f docker-compose.prod.yml exec postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "select 'requirements_missing_product' as check_name, count(*) from requirements where product_id is null union all select 'dev_tasks_missing_version', count(*) from dev_tasks where version_id is null union all select 'test_cases_missing_version', count(*) from test_cases where version_id is null union all select 'bugs_missing_version', count(*) from bugs where version_id is null;"
```
## Disable AppData Writes
1. Merge the domain-specific frontend store change that stops calling `saveServerData` for the retired key.
2. Keep AppData read fallback for one release while relation reads are verified.
3. Deploy and run smoke tests.
```bash
pnpm deploy:smoke -- --base-url http://localhost --expected-version "$APP_VERSION"
```
## Remove Fallback
Remove AppData read fallback only after one successful release where:
- Domain writes went through relation APIs.
- AppData row counts did not grow for the retired key.
- V2.2 read paths and page workflows returned expected data.
- No `AppData relation sync failed` logs appeared during the observation window.
## Archive
Export retired keys before any later table cleanup.
```bash
docker compose --env-file .env.production -f docker-compose.prod.yml exec postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "copy (select key, value, updated_at from app_data where key in ('dev-tasks','test-cases','bugs')) to stdout with csv header" > backups/postgres/appdata-retired-executions-20260708.csv
```
## Rollback
- If relation writes fail but AppData still has fresh data, roll back to the previous app image that still writes AppData.
- If AppData writes were already disabled and relation writes are bad, restore PostgreSQL from the backup made at the start of this runbook.
- If only read fallback removal caused the issue, roll back app images first and leave the database unchanged.
## Data Risks
- Removing fallback too early can hide valid historical JSON rows that were never mapped into relation tables.
- Re-enabling old AppData writes after relation writes have accepted new edits can overwrite newer relation state through compatibility sync.
- AppData exports can contain business-sensitive text; store backup CSV files in the same restricted location as database dumps.

View File

@@ -0,0 +1,100 @@
# Migration Rollback Runbook
Use this when a production release, Prisma migration, or data migration causes failed smoke tests, missing data, bad query performance, or unsafe writes.
## First Response
1. Freeze new releases and ask product owners to pause bulk edits.
2. Capture current state before changing anything.
```bash
date -u
git rev-parse HEAD
docker compose --env-file .env.production -f docker-compose.prod.yml ps
pnpm backup:postgres -- --env-file .env.production
pnpm backup:server-data -- --env-file .env.production
```
3. Run the read-only release smoke test.
```bash
pnpm deploy:smoke -- --base-url http://localhost --expected-version "$APP_VERSION"
```
4. Check the database and latest server logs.
```bash
docker compose --env-file .env.production -f docker-compose.prod.yml exec postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
docker compose --env-file .env.production -f docker-compose.prod.yml logs --tail=200 server
```
## Decision Points
- App code bad, database healthy: roll back `WEB_IMAGE` and `SERVER_IMAGE` to the previous commit image tags, then restart Compose.
- Migration applied but only additive: roll back app images first; leave schema in place if old code remains compatible.
- Migration changed or removed data: restore a fresh database from the last known-good backup into a temporary database, compare row counts, then decide whether to restore production.
- AppData compatibility issue: keep production database intact, re-enable the previous app image that still reads the AppData fallback, and preserve the failing release backup for analysis.
## App Image Rollback
Set previous image tags in `.env.production`. Use the previous successful GitHub Actions run to identify the image tags.
```bash
export PREVIOUS_WEB_IMAGE=ghcr.io/company/ftb-project-management/web:abc1234
export PREVIOUS_SERVER_IMAGE=ghcr.io/company/ftb-project-management/server:abc1234
sed -i "s|^WEB_IMAGE=.*|WEB_IMAGE=${PREVIOUS_WEB_IMAGE}|" .env.production
sed -i "s|^SERVER_IMAGE=.*|SERVER_IMAGE=${PREVIOUS_SERVER_IMAGE}|" .env.production
docker compose --env-file .env.production -f docker-compose.prod.yml pull web server
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --remove-orphans
pnpm deploy:smoke -- --base-url http://localhost --expected-version abc1234
```
## Fresh Database Restore
Never restore over production until the backup has been rehearsed into a temporary database.
```bash
export BACKUP_FILE=backups/postgres/ftb_pm-postgres-20260708T120000Z.dump
pnpm restore:postgres -- \
--dry-run \
--confirm-overwrite \
--env-file .env.production \
--input "$BACKUP_FILE" \
--target-db ftb_pm_restore_check
pnpm restore:postgres -- \
--confirm-overwrite \
--env-file .env.production \
--input "$BACKUP_FILE" \
--target-db ftb_pm_restore_check
```
Compare critical row counts before touching production.
```bash
docker compose --env-file .env.production -f docker-compose.prod.yml exec postgres psql -U "$POSTGRES_USER" -d ftb_pm_restore_check -c "select 'products' as table_name, count(*) from products union all select 'requirements', count(*) from requirements union all select 'versions', count(*) from versions union all select 'dev_tasks', count(*) from dev_tasks union all select 'test_cases', count(*) from test_cases union all select 'bugs', count(*) from bugs;"
```
If the temporary restore is healthy and production data is unsafe, restore production with explicit overwrite confirmation.
```bash
pnpm restore:postgres -- \
--confirm-overwrite \
--env-file .env.production \
--input "$BACKUP_FILE"
docker compose --env-file .env.production -f docker-compose.prod.yml up -d --remove-orphans
pnpm deploy:smoke -- --base-url http://localhost
```
## Data Risks
- PostgreSQL restore is destructive for the target database because the script terminates connections, drops the target database, recreates it, and runs `pg_restore`.
- Restoring PostgreSQL does not restore `server_data`; keep AI provider config backup files with the same incident bundle.
- AppData and relation tables can diverge during compatibility windows. Before deleting or restoring, preserve both the failing production backup and the known-good backup.
- If users continued editing during the incident, record the time window and decide whether those edits must be replayed manually after restore.
## Closeout
1. Save the failed release SHA, rollback SHA, backup file names, smoke output, and row-count evidence in the incident notes.
2. Keep the failed backup until the next successful release has completed smoke tests and one business-day observation.
3. Add a regression test or runbook correction before re-attempting the migration.

View File

@@ -0,0 +1,55 @@
# Xiaobao Background Jobs Runbook
Current V2.8 production monitoring supports Xiaobao staleness detection. The first production implementation is still page-triggered: opening `/xiaobao-warning` computes risk, saves snapshots, and lets V2.3 sync refresh summaries. A future scheduler must preserve the same idempotent data contract.
## Alert Triage
When `FtbXiaobaoSummaryStale` fires:
```bash
docker compose --env-file .env.production -f docker-compose.prod.yml exec postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "select version_id, dirty, updated_at, recomputed_at, risk_level, risk_score from xiaobao_risk_summaries where dirty = true or updated_at < now() - interval '6 hours' order by updated_at asc limit 20;"
docker compose --env-file .env.production -f docker-compose.prod.yml logs --tail=200 server | grep -E "xiaobao|AppData relation sync failed|Slow Prisma query"
```
Decision points:
- Rows are dirty after active version edits: ask a manager to open `/xiaobao-warning` once, then verify summaries refresh.
- Rows remain dirty and server logs show sync failures: treat as AppData relation sync incident and follow `migration-rollback.md`.
- Rows are stale but no product release is near: keep monitoring and schedule a manual refresh before the next release decision meeting.
- Rows are stale for a release due today: refresh manually and have the release owner review the resulting risk explanation before ship/no-ship decision.
## Manual Refresh Path
1. Log in as a user with `xiaobao.warning:manage`.
2. Open `/xiaobao-warning`.
3. Wait until AI interpretation status is no longer generating for high-risk versions.
4. Re-run the stale-summary query.
5. Run the release smoke test.
```bash
pnpm deploy:smoke -- --base-url http://localhost
```
## Future Scheduler Rules
When a background job is introduced, it must:
- Read unfinished versions by relation-table scope, not by full AppData document scan.
- Use one idempotency key per `versionId + riskSignature + snapshotDate`.
- Write snapshots append-only and upsert summaries by `version_id`.
- Mark failures with structured logs containing `xiaobao background job failed`.
- Retry transient AI failures with backoff and keep rule-based risk output even when AI interpretation fails.
- Never mutate Version, Requirement, DevTask, TestCase, Bug, or Member data.
## Monitoring Expectations
- `FtbXiaobaoSummaryStale` alerts on dirty or older-than-6-hour summaries.
- `FtbJobFailureLogBurst` alerts when sync or future job failure log counters increase.
- Grafana dashboard shows the stale summary count and matching server warning/error logs.
## Data Risks
- Recomputing Xiaobao summaries can change release risk badges and manager decisions; record manual refresh time in release notes.
- AI interpretation cache is explanatory only. Do not restore or delete business entities to fix a bad explanation.
- If stale summaries are caused by relation sync failure, refreshing the page can mask the symptom without fixing the underlying sync path. Preserve logs before restarting services.

View File

@@ -10,8 +10,14 @@
"perf:seed": "node scripts/seed-large-dataset.mjs", "perf:seed": "node scripts/seed-large-dataset.mjs",
"perf:check": "node scripts/perf-check.mjs", "perf:check": "node scripts/perf-check.mjs",
"perf:explain": "node scripts/explain-hot-queries.mjs", "perf:explain": "node scripts/explain-hot-queries.mjs",
"ops:test": "node --test scripts/*.test.mjs",
"deploy:verify": "node scripts/verify-production-deploy.mjs", "deploy:verify": "node scripts/verify-production-deploy.mjs",
"deploy:check-runtime": "node scripts/check-runtime-version.mjs", "deploy:check-runtime": "node scripts/check-runtime-version.mjs",
"deploy:smoke": "node scripts/smoke-test-release.mjs",
"backup:postgres": "node scripts/backup-postgres.mjs",
"restore:postgres": "node scripts/restore-postgres.mjs",
"backup:server-data": "node scripts/backup-server-data.mjs",
"docs:check-runbooks": "node scripts/check-runbook-placeholders.mjs --paths docs/runbooks docs/production-readiness.md",
"appdata:archive:export": "node scripts/export-appdata-archive.mjs", "appdata:archive:export": "node scripts/export-appdata-archive.mjs",
"appdata:archive:verify": "node scripts/verify-appdata-archive.mjs", "appdata:archive:verify": "node scripts/verify-appdata-archive.mjs",
"appdata:archive:test": "node --test scripts/appdata-archive.test.mjs", "appdata:archive:test": "node --test scripts/appdata-archive.test.mjs",

139
scripts/backup-postgres.mjs Normal file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env node
import { createWriteStream, unlinkSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { spawn } from 'node:child_process';
import {
commandToString,
composePrefix,
ensureParentDir,
flag,
option,
parseArgs,
readEnvFile,
timestampSlug,
} from './ops-utils.mjs';
function usage() {
return `Usage: node scripts/backup-postgres.mjs [options]
Options:
--env-file <path> Compose env file (default: .env.production)
--compose-file <path> Compose file (default: docker-compose.prod.yml)
--service <name> Postgres service name (default: postgres)
--user <name> Database user (default: POSTGRES_USER or postgres)
--db <name> Database name (default: POSTGRES_DB or ftb_pm)
--backup-dir <path> Directory for generated backups (default: backups/postgres)
--output <path> Exact output dump path
--dry-run Print the pg_dump command without writing a file
--help Show this help
`;
}
function buildOptions(argv) {
const args = parseArgs(argv);
if (flag(args, 'help')) return { help: true };
const envFile = option(args, 'env-file', '.env.production');
const env = readEnvFile(envFile, { optional: flag(args, 'dry-run') });
const db = option(args, 'db', env.POSTGRES_DB || 'ftb_pm');
const user = option(args, 'user', env.POSTGRES_USER || 'postgres');
const backupDir = option(args, 'backup-dir', 'backups/postgres');
const output = option(
args,
'output',
join(backupDir, `${db}-postgres-${timestampSlug()}.dump`),
);
return {
help: false,
dryRun: flag(args, 'dry-run'),
envFile,
composeFile: option(args, 'compose-file', 'docker-compose.prod.yml'),
service: option(args, 'service', 'postgres'),
db,
user,
output: resolve(output),
};
}
export function buildPgDumpCommand(options) {
return [
...composePrefix(options),
'exec',
'-T',
options.service,
'pg_dump',
'-U',
options.user,
'-d',
options.db,
'--format=custom',
'--no-owner',
'--no-acl',
];
}
async function writeBackup(command, output) {
ensureParentDir(output);
await new Promise((resolveWrite, rejectWrite) => {
const file = createWriteStream(output, { flags: 'wx' });
const child = spawn(command[0], command.slice(1), {
cwd: process.cwd(),
stdio: ['ignore', 'pipe', 'inherit'],
});
let settled = false;
const rejectOnce = (error) => {
if (settled) return;
settled = true;
try {
unlinkSync(output);
} catch {
// Best effort cleanup of a partial dump.
}
rejectWrite(error);
};
child.stdout.pipe(file);
child.on('error', rejectOnce);
file.on('error', rejectOnce);
child.on('close', (code) => {
file.end(() => {
if (settled) return;
settled = true;
if (code === 0) {
resolveWrite();
} else {
rejectOnce(new Error(`pg_dump failed with exit code ${code}`));
}
});
});
});
}
export async function main(argv = process.argv.slice(2)) {
const options = buildOptions(argv);
if (options.help) {
process.stdout.write(usage());
return;
}
const command = buildPgDumpCommand(options);
if (options.dryRun) {
process.stdout.write(`[dry-run] PostgreSQL backup would write: ${options.output}\n`);
process.stdout.write(`${commandToString(command, { stdout: options.output })}\n`);
return;
}
await writeBackup(command, options.output);
process.stdout.write(`PostgreSQL backup written: ${options.output}\n`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
process.stderr.write(`${error.message}\n`);
process.exit(1);
});
}

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env node
import { basename, dirname, join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import {
commandToString,
ensureParentDir,
flag,
option,
parseArgs,
readEnvFile,
runCommand,
timestampSlug,
} from './ops-utils.mjs';
function usage() {
return `Usage: node scripts/backup-server-data.mjs [options]
Backs up the server_data Docker volume that stores runtime AI provider config.
Options:
--env-file <path> Compose env file used to derive COMPOSE_PROJECT_NAME
(default: .env.production)
--volume <name> Explicit Docker volume name
--backup-dir <path> Directory for generated backups (default: backups/server-data)
--output <path> Exact output .tgz path
--image <name> Utility image (default: alpine:3.20)
--dry-run Print the docker run command without writing a file
--help Show this help
`;
}
function buildOptions(argv) {
const args = parseArgs(argv);
if (flag(args, 'help')) return { help: true };
const envFile = option(args, 'env-file', '.env.production');
const env = readEnvFile(envFile, { optional: flag(args, 'dry-run') });
const projectName = env.COMPOSE_PROJECT_NAME || 'ftb_pm';
const volume = option(args, 'volume', `${projectName}_server_data`);
const backupDir = option(args, 'backup-dir', 'backups/server-data');
const output = option(
args,
'output',
join(backupDir, `${volume}-${timestampSlug()}.tgz`),
);
return {
help: false,
dryRun: flag(args, 'dry-run'),
image: option(args, 'image', 'alpine:3.20'),
volume,
output: resolve(output),
};
}
export function buildServerDataBackupCommand(options) {
const outputDir = dirname(options.output);
const outputName = basename(options.output);
return [
'docker',
'run',
'--rm',
'-v',
`${options.volume}:/data:ro`,
'-v',
`${outputDir}:/backup`,
options.image,
'sh',
'-lc',
`tar -czf /backup/${outputName} -C /data .`,
];
}
export async function main(argv = process.argv.slice(2)) {
const options = buildOptions(argv);
if (options.help) {
process.stdout.write(usage());
return;
}
const command = buildServerDataBackupCommand(options);
if (options.dryRun) {
process.stdout.write(`[dry-run] server_data backup would write: ${options.output}\n`);
process.stdout.write(`${commandToString(command)}\n`);
return;
}
ensureParentDir(options.output);
await runCommand(command);
process.stdout.write(`server_data backup written: ${options.output}\n`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
process.stderr.write(`${error.message}\n`);
process.exit(1);
});
}

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env node
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { extname, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { option, parseArgs } from './ops-utils.mjs';
const forbiddenPatterns = [
{ name: 'TBD', pattern: /\bTBD\b/i },
{ name: 'TODO', pattern: /\bTODO\b/i },
{ name: 'fill-in', pattern: /fill in|fill-in/i },
{ name: 'angle-token', pattern: /<[^>\n]+>/ },
{ name: 'Chinese pending marker', pattern: /待定|占位/ },
];
function usage() {
return `Usage: node scripts/check-runbook-placeholders.mjs --paths <path> [path...]
Scans Markdown runbooks for unresolved placeholder markers.
`;
}
function collectMarkdownFiles(path) {
if (!existsSync(path)) {
throw new Error(`Path not found: ${path}`);
}
const stat = statSync(path);
if (stat.isFile()) return extname(path) === '.md' ? [path] : [];
if (!stat.isDirectory()) return [];
const files = [];
for (const entry of readdirSync(path)) {
files.push(...collectMarkdownFiles(join(path, entry)));
}
return files;
}
function requestedPaths(argv) {
const args = parseArgs(argv);
if (args.flags.has('help')) return { help: true, paths: [] };
const first = option(args, 'paths');
const paths = [first, ...args.positionals].filter(Boolean);
if (paths.length === 0) {
throw new Error('Missing --paths <path> [path...]');
}
return { help: false, paths };
}
export function scanFiles(paths) {
const files = paths.flatMap(collectMarkdownFiles);
const findings = [];
for (const file of files) {
const lines = readFileSync(file, 'utf8').split(/\r?\n/);
lines.forEach((line, index) => {
for (const forbidden of forbiddenPatterns) {
if (forbidden.pattern.test(line)) {
findings.push(`${file}:${index + 1} ${forbidden.name}: ${line.trim()}`);
}
}
});
}
return { files, findings };
}
export async function main(argv = process.argv.slice(2)) {
const { help, paths } = requestedPaths(argv);
if (help) {
process.stdout.write(usage());
return;
}
const { files, findings } = scanFiles(paths);
if (findings.length > 0) {
process.stderr.write(`${findings.join('\n')}\n`);
throw new Error(`Runbook placeholder scan failed: ${findings.length} finding(s)`);
}
process.stdout.write(`Runbook placeholder scan passed (${files.length} file(s)).\n`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
process.stderr.write(`${error.message}\n`);
process.exit(1);
});
}

View File

@@ -0,0 +1,152 @@
import { spawnSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
const root = resolve(import.meta.dirname, '..');
function runScript(script, args) {
return spawnSync(process.execPath, [join(root, script), ...args], {
cwd: root,
encoding: 'utf8',
});
}
describe('production ops scripts', () => {
it('prints a pg_dump command in dry-run mode without writing the target file', () => {
const dir = mkdtempSync(join(tmpdir(), 'ftb-pg-backup-'));
const output = join(dir, 'backup.dump');
const result = runScript('scripts/backup-postgres.mjs', [
'--dry-run',
'--env-file',
'.env.production.example',
'--output',
output,
]);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /docker compose/);
assert.match(result.stdout, /pg_dump/);
assert.match(result.stdout, /backup\.dump/);
assert.equal(existsSync(output), false);
});
it('refuses restore by default unless --confirm-overwrite is supplied', () => {
const dir = mkdtempSync(join(tmpdir(), 'ftb-pg-restore-'));
const input = join(dir, 'backup.dump');
writeFileSync(input, 'not-a-real-dump');
const result = runScript('scripts/restore-postgres.mjs', [
'--env-file',
'.env.production.example',
'--input',
input,
]);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /--confirm-overwrite/);
});
it('prints fresh database restore steps in dry-run mode after explicit overwrite confirmation', () => {
const dir = mkdtempSync(join(tmpdir(), 'ftb-pg-restore-dry-'));
const input = join(dir, 'backup.dump');
writeFileSync(input, 'not-a-real-dump');
const result = runScript('scripts/restore-postgres.mjs', [
'--dry-run',
'--confirm-overwrite',
'--env-file',
'.env.production.example',
'--input',
input,
]);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /dropdb/);
assert.match(result.stdout, /createdb/);
assert.match(result.stdout, /pg_restore/);
});
it('prints a server_data volume tar backup command in dry-run mode', () => {
const result = runScript('scripts/backup-server-data.mjs', [
'--dry-run',
'--env-file',
'.env.production.example',
]);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /docker run/);
assert.match(result.stdout, /ftb_pm_server_data/);
assert.match(result.stdout, /tar -czf/);
});
it('prints read-only release smoke checks in dry-run mode', () => {
const result = runScript('scripts/smoke-test-release.mjs', [
'--dry-run',
'--base-url',
'http://localhost',
]);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /\/api\/v1\/health\/version/);
assert.match(result.stdout, /\/products/);
assert.match(result.stdout, /\/api\/v1\/products/);
assert.match(result.stdout, /\/api\/v1\/v2\.2\/requirements\?productId=__smoke__/);
assert.match(result.stdout, /\/api\/v1\/config\/ai/);
});
it('declares the production monitoring baseline without real alert secrets', () => {
const alertRules = readFileSync(
join(root, 'deploy/monitoring/prometheus/alert-rules.yml'),
'utf8',
);
const postgresQueries = readFileSync(
join(root, 'deploy/monitoring/postgres/postgres-queries.yml'),
'utf8',
);
const promtailConfig = readFileSync(
join(root, 'deploy/monitoring/promtail/config.yml'),
'utf8',
);
const dashboard = readFileSync(
join(root, 'deploy/monitoring/grafana/dashboards/ftb-production-overview.json'),
'utf8',
);
for (const alertName of [
'FtbPostgresDown',
'FtbDiskPressure',
'FtbSlowApiLogBurst',
'FtbSlowPrismaLogBurst',
'FtbJobFailureLogBurst',
'FtbXiaobaoSummaryStale',
]) {
assert.match(alertRules, new RegExp(alertName));
}
assert.match(postgresQueries, /xiaobao_risk_summaries/);
assert.match(postgresQueries, /dirty = true/);
assert.match(promtailConfig, /Slow API request/);
assert.match(promtailConfig, /Slow Prisma query/);
assert.match(promtailConfig, /AppData relation sync failed/);
assert.match(dashboard, /FTB Production Overview/);
const combined = `${alertRules}\n${postgresQueries}\n${promtailConfig}\n${dashboard}`;
assert.doesNotMatch(combined, /sk-ant-[A-Za-z0-9]/);
assert.doesNotMatch(combined, /hooks\.slack\.com\/services\//);
});
it('passes the runbook placeholder scan', () => {
const result = runScript('scripts/check-runbook-placeholders.mjs', [
'--paths',
'docs/runbooks',
'docs/production-readiness.md',
]);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /Runbook placeholder scan passed/);
});
});

116
scripts/ops-utils.mjs Normal file
View File

@@ -0,0 +1,116 @@
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { spawn } from 'node:child_process';
export function parseArgs(argv) {
const parsed = { flags: new Set(), values: new Map(), positionals: [] };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--') continue;
if (!arg.startsWith('--')) {
parsed.positionals.push(arg);
continue;
}
const eq = arg.indexOf('=');
if (eq !== -1) {
parsed.values.set(arg.slice(2, eq), arg.slice(eq + 1));
continue;
}
const key = arg.slice(2);
const next = argv[index + 1];
if (next && !next.startsWith('--')) {
parsed.values.set(key, next);
index += 1;
} else {
parsed.flags.add(key);
}
}
return parsed;
}
export function option(args, name, fallback = undefined) {
return args.values.has(name) ? args.values.get(name) : fallback;
}
export function flag(args, name) {
return args.flags.has(name);
}
export function readEnvFile(filePath, { optional = false } = {}) {
if (!existsSync(filePath)) {
if (optional) return {};
throw new Error(`Env file not found: ${filePath}`);
}
const env = {};
const content = readFileSync(filePath, 'utf8');
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
env[key] = value;
}
return env;
}
export function ensureParentDir(filePath) {
mkdirSync(dirname(filePath), { recursive: true });
}
export function timestampSlug(date = new Date()) {
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
}
export function shellQuote(value) {
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) return value;
return `'${value.replace(/'/g, "'\\''")}'`;
}
export function commandToString(command, { stdin, stdout } = {}) {
const rendered = command.map((part) => shellQuote(part)).join(' ');
const withStdin = stdin ? `${rendered} < ${shellQuote(stdin)}` : rendered;
return stdout ? `${withStdin} > ${shellQuote(stdout)}` : withStdin;
}
export function composePrefix({ envFile, composeFile }) {
return ['docker', 'compose', '--env-file', envFile, '-f', composeFile];
}
export function resolvePath(path) {
return resolve(process.cwd(), path);
}
export function runCommand(command, options = {}) {
return new Promise((resolveRun, rejectRun) => {
const child = spawn(command[0], command.slice(1), {
stdio: options.stdio ?? 'inherit',
cwd: options.cwd ?? process.cwd(),
});
child.on('error', rejectRun);
child.on('close', (code) => {
if (code === 0) {
resolveRun();
} else {
rejectRun(new Error(`Command failed (${code}): ${commandToString(command)}`));
}
});
});
}
export function quotePgLiteral(value) {
return `'${value.replace(/'/g, "''")}'`;
}

View File

@@ -0,0 +1,181 @@
#!/usr/bin/env node
import { createReadStream, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { spawn } from 'node:child_process';
import {
commandToString,
composePrefix,
flag,
option,
parseArgs,
quotePgLiteral,
readEnvFile,
runCommand,
} from './ops-utils.mjs';
function usage() {
return `Usage: node scripts/restore-postgres.mjs --input <dump> --confirm-overwrite [options]
This script restores into a freshly recreated PostgreSQL database. It refuses to
drop/recreate a database unless --confirm-overwrite is provided.
Options:
--input <path> pg_dump custom-format dump file
--confirm-overwrite Required safety flag for destructive restore
--env-file <path> Compose env file (default: .env.production)
--compose-file <path> Compose file (default: docker-compose.prod.yml)
--service <name> Postgres service name (default: postgres)
--user <name> Database user (default: POSTGRES_USER or postgres)
--target-db <name> Target database (default: POSTGRES_DB or ftb_pm)
--maintenance-db <name> Maintenance database (default: postgres)
--dry-run Print restore commands without changing data
--help Show this help
`;
}
function buildOptions(argv) {
const args = parseArgs(argv);
if (flag(args, 'help')) return { help: true };
const envFile = option(args, 'env-file', '.env.production');
const env = readEnvFile(envFile, { optional: flag(args, 'dry-run') });
const input = option(args, 'input');
if (!input) {
throw new Error('Missing required --input <dump>');
}
const inputPath = resolve(input);
if (!existsSync(inputPath)) {
throw new Error(`Restore input not found: ${inputPath}`);
}
if (!flag(args, 'confirm-overwrite')) {
throw new Error('Refusing destructive restore. Re-run with --confirm-overwrite.');
}
return {
help: false,
dryRun: flag(args, 'dry-run'),
input: inputPath,
envFile,
composeFile: option(args, 'compose-file', 'docker-compose.prod.yml'),
service: option(args, 'service', 'postgres'),
user: option(args, 'user', env.POSTGRES_USER || 'postgres'),
targetDb: option(args, 'target-db', env.POSTGRES_DB || 'ftb_pm'),
maintenanceDb: option(args, 'maintenance-db', 'postgres'),
};
}
export function buildRestoreCommands(options) {
const prefix = composePrefix(options);
const terminateSql = [
'SELECT pg_terminate_backend(pid)',
'FROM pg_stat_activity',
`WHERE datname = ${quotePgLiteral(options.targetDb)} AND pid <> pg_backend_pid();`,
].join(' ');
return [
[
...prefix,
'exec',
'-T',
options.service,
'psql',
'-U',
options.user,
'-d',
options.maintenanceDb,
'-v',
'ON_ERROR_STOP=1',
'-c',
terminateSql,
],
[
...prefix,
'exec',
'-T',
options.service,
'dropdb',
'--if-exists',
'-U',
options.user,
options.targetDb,
],
[
...prefix,
'exec',
'-T',
options.service,
'createdb',
'-U',
options.user,
options.targetDb,
],
[
...prefix,
'exec',
'-T',
options.service,
'pg_restore',
'-U',
options.user,
'-d',
options.targetDb,
'--no-owner',
'--no-acl',
],
];
}
function runRestore(command, input) {
return new Promise((resolveRun, rejectRun) => {
const child = spawn(command[0], command.slice(1), {
cwd: process.cwd(),
stdio: ['pipe', 'inherit', 'inherit'],
});
createReadStream(input).pipe(child.stdin);
child.on('error', rejectRun);
child.on('close', (code) => {
if (code === 0) {
resolveRun();
} else {
rejectRun(new Error(`pg_restore failed with exit code ${code}`));
}
});
});
}
export async function main(argv = process.argv.slice(2)) {
const options = buildOptions(argv);
if (options.help) {
process.stdout.write(usage());
return;
}
const commands = buildRestoreCommands(options);
if (options.dryRun) {
process.stdout.write(
`[dry-run] PostgreSQL restore would recreate database: ${options.targetDb}\n`,
);
for (const command of commands.slice(0, -1)) {
process.stdout.write(`${commandToString(command)}\n`);
}
process.stdout.write(`${commandToString(commands.at(-1), { stdin: options.input })}\n`);
return;
}
for (const command of commands.slice(0, -1)) {
await runCommand(command);
}
await runRestore(commands.at(-1), options.input);
process.stdout.write(`PostgreSQL restore completed into fresh database: ${options.targetDb}\n`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
process.stderr.write(`${error.message}\n`);
process.exit(1);
});
}

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env node
import { pathToFileURL } from 'node:url';
import { flag, option, parseArgs } from './ops-utils.mjs';
function usage() {
return `Usage: node scripts/smoke-test-release.mjs --base-url <url> [options]
Read-only release smoke checks:
- backend runtime version
- frontend root page
- frontend products route
- products API
- V2.2 requirement read path
- AI config public endpoint
Options:
--base-url <url> Deployment base URL (default: http://127.0.0.1)
--expected-version <sha> Expected /api/v1/health/version payload version
--timeout-ms <number> Per-request timeout (default: 5000)
--dry-run Print planned checks without making requests
--help Show this help
`;
}
function normalizeBaseUrl(value) {
const url = new URL(value || 'http://127.0.0.1');
url.pathname = url.pathname.replace(/\/+$/, '');
url.search = '';
url.hash = '';
return url.toString().replace(/\/$/, '');
}
export function buildSmokeChecks(baseUrl, expectedVersion = '') {
const checks = [
{
name: 'backend runtime version',
path: '/api/v1/health/version',
kind: 'json',
validate(payload) {
if (payload?.service !== 'server' || typeof payload.version !== 'string') {
throw new Error(`unexpected version payload: ${JSON.stringify(payload)}`);
}
if (expectedVersion && payload.version !== expectedVersion) {
throw new Error(`expected version ${expectedVersion}, got ${payload.version}`);
}
},
},
{ name: 'frontend root', path: '/', kind: 'text' },
{ name: 'frontend products route', path: '/products', kind: 'text' },
{
name: 'products API',
path: '/api/v1/products',
kind: 'json',
validate(payload) {
if (!Array.isArray(payload)) {
throw new Error('products API did not return an array');
}
},
},
{
name: 'V2.2 requirement scoped read',
path: '/api/v1/v2.2/requirements?productId=__smoke__&limit=1',
kind: 'json',
validate(payload) {
if (!payload || !Array.isArray(payload.items)) {
throw new Error('V2.2 requirements response missing items array');
}
},
},
{
name: 'AI config public endpoint',
path: '/api/v1/config/ai',
kind: 'json',
validate(payload) {
if (!payload || !Array.isArray(payload.providers)) {
throw new Error('AI config response missing providers array');
}
},
},
];
return checks.map((check) => ({
...check,
url: new URL(check.path, `${baseUrl}/`).toString(),
}));
}
async function fetchWithTimeout(url, timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
async function runCheck(check, timeoutMs) {
const response = await fetchWithTimeout(check.url, timeoutMs);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
if (check.kind === 'json') {
const payload = await response.json();
check.validate?.(payload);
} else {
await response.text();
}
}
export async function main(argv = process.argv.slice(2)) {
const args = parseArgs(argv);
if (flag(args, 'help')) {
process.stdout.write(usage());
return;
}
const baseUrl = normalizeBaseUrl(option(args, 'base-url', 'http://127.0.0.1'));
const expectedVersion = option(args, 'expected-version', '');
const timeoutMs = Number.parseInt(option(args, 'timeout-ms', '5000'), 10);
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new Error('--timeout-ms must be a positive number');
}
const checks = buildSmokeChecks(baseUrl, expectedVersion);
if (flag(args, 'dry-run')) {
process.stdout.write(`[dry-run] Release smoke target: ${baseUrl}\n`);
for (const check of checks) {
process.stdout.write(`GET ${check.url} # ${check.name}\n`);
}
return;
}
process.stdout.write(`Release smoke target: ${baseUrl}\n`);
const failures = [];
for (const check of checks) {
try {
await runCheck(check, timeoutMs);
process.stdout.write(`[pass] ${check.name} ${check.url}\n`);
} catch (error) {
failures.push(`[fail] ${check.name} ${check.url}: ${error.message}`);
process.stderr.write(`${failures.at(-1)}\n`);
}
}
if (failures.length > 0) {
throw new Error(`Release smoke failed: ${failures.length} check(s) failed`);
}
process.stdout.write('Release smoke test passed.\n');
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
process.stderr.write(`${error.message}\n`);
process.exit(1);
});
}

View File

@@ -21,6 +21,7 @@ const checks = [
'NEXT_PUBLIC_API_URL', 'NEXT_PUBLIC_API_URL',
'NEXT_PUBLIC_APP_VERSION', 'NEXT_PUBLIC_APP_VERSION',
'ARG APP_VERSION=unknown', 'ARG APP_VERSION=unknown',
'COPY scripts ./scripts',
'CMD ["pnpm", "--filter", "web", "start"]', 'CMD ["pnpm", "--filter", "web", "start"]',
], ],
}, },
@@ -50,6 +51,11 @@ const checks = [
'WEB_IMAGE', 'WEB_IMAGE',
'APP_VERSION', 'APP_VERSION',
'/api/v1/health/version', '/api/v1/health/version',
'prometheus:',
"profiles: ['monitoring']",
'postgres-exporter:',
'blackbox-exporter:',
'prometheus_data:',
], ],
}, },
{ {
@@ -59,13 +65,42 @@ const checks = [
'appleboy/ssh-action', 'appleboy/ssh-action',
'docker compose --env-file .env.production -f docker-compose.prod.yml pull', 'docker compose --env-file .env.production -f docker-compose.prod.yml pull',
'pnpm --filter server db:deploy', 'pnpm --filter server db:deploy',
'/api/v1/health/version', 'scripts/smoke-test-release.mjs',
'--expected-version',
], ],
}, },
{ {
file: 'scripts/check-runtime-version.mjs', file: 'scripts/check-runtime-version.mjs',
snippets: ['Runtime version verified', '/api/v1/health/version', 'expectedVersion'], snippets: ['Runtime version verified', '/api/v1/health/version', 'expectedVersion'],
}, },
{
file: 'scripts/smoke-test-release.mjs',
snippets: [
'Release smoke test passed',
'/api/v1/v2.2/requirements?productId=__smoke__',
'/api/v1/config/ai',
],
},
{
file: 'scripts/backup-postgres.mjs',
snippets: ['pg_dump', '--format=custom', '--dry-run'],
},
{
file: 'scripts/restore-postgres.mjs',
snippets: ['--confirm-overwrite', 'dropdb', 'pg_restore'],
},
{
file: 'scripts/backup-server-data.mjs',
snippets: ['server_data', 'tar -czf', '--dry-run'],
},
{
file: 'scripts/check-runbook-placeholders.mjs',
snippets: ['Runbook placeholder scan passed', 'Runbook placeholder scan failed', '--paths'],
},
{
file: 'package.json',
snippets: ['docs:check-runbooks', 'docs/runbooks', 'docs/production-readiness.md'],
},
{ {
file: 'docker-compose.local.yml', file: 'docker-compose.local.yml',
snippets: [ snippets: [
@@ -88,6 +123,66 @@ const checks = [
'X-Forwarded-Proto', 'X-Forwarded-Proto',
], ],
}, },
{
file: 'deploy/monitoring/README.md',
snippets: [
'--profile monitoring',
'FtbPostgresDown',
'FtbXiaobaoSummaryStale',
'no webhook URLs',
],
},
{
file: 'deploy/monitoring/prometheus/prometheus.yml',
snippets: ['postgres-exporter:9187', 'promtail:9080', 'blackbox-http'],
},
{
file: 'deploy/monitoring/prometheus/alert-rules.yml',
snippets: [
'FtbPostgresDown',
'FtbSlowApiLogBurst',
'FtbSlowPrismaLogBurst',
'FtbJobFailureLogBurst',
'FtbXiaobaoSummaryStale',
],
},
{
file: 'docs/runbooks/migration-rollback.md',
snippets: ['Fresh Database Restore', 'Data Risks', 'pnpm restore:postgres'],
},
{
file: 'docs/runbooks/appdata-retirement.md',
snippets: ['Disable AppData Writes', 'Remove Fallback', 'Data Risks'],
},
{
file: 'docs/runbooks/xiaobao-background-jobs.md',
snippets: ['FtbXiaobaoSummaryStale', 'Future Scheduler Rules', 'Data Risks'],
},
{
file: 'docs/production-readiness.md',
snippets: [
'Backup and restore',
'Smoke tests',
'Monitoring',
'Audit',
'RBAC',
'Consistency',
'Performance',
'Post-release',
],
},
{
file: 'deploy/monitoring/postgres/postgres-queries.yml',
snippets: ['xiaobao_risk_summaries', 'dirty = true', 'stale_summary_count'],
},
{
file: 'deploy/monitoring/promtail/config.yml',
snippets: ['Slow API request', 'Slow Prisma query', 'AppData relation sync failed'],
},
{
file: 'deploy/monitoring/grafana/dashboards/ftb-production-overview.json',
snippets: ['FTB Production Overview', 'ftb_xiaobao_stale_summary_count', 'Server Warning/Error Logs'],
},
{ {
file: '.env.production.example', file: '.env.production.example',
snippets: [ snippets: [
@@ -118,6 +213,12 @@ const checks = [
'docker-compose.local.yml', 'docker-compose.local.yml',
'本地服务器部署', '本地服务器部署',
'pnpm deploy:verify', 'pnpm deploy:verify',
'pnpm backup:postgres',
'pnpm restore:postgres',
'pnpm backup:server-data',
'docs/runbooks/migration-rollback.md',
'docs/production-readiness.md',
'--profile monitoring',
'pnpm db:migrate', 'pnpm db:migrate',
'NEXT_PUBLIC_API_URL', 'NEXT_PUBLIC_API_URL',
'Nginx', 'Nginx',