e6472b0ec6
为什么改:发布执行链是"与目标同机"假设写死的(本机 docker.sock、本机 drain.json、 本机 Redis/PG),把目标换成云测试不能只改展示 URL;直接搬整套 worker 又会抢走 本机封存/QA/周报队列并按本机配置写错目标。所以先把"谁消费哪段队列"变成显式角色, 再补齐跨主机搬运与目标侧门禁。 角色(LIBRARY_WORKER_ROLE=full/release-executor/capture)不只影响 handler 列表, 还决定是否拥有 DDL(release-executor 只读校验迁移版本,落后即拒绝启动)、 收拾哪些 library_jobs.kind、是否跑 release 接管扫描、是否起 scheduler/QA/周报/探测。 capture 保留本机原有全部业务;release-executor 只挂 sync/restore/recover/baseline。 快照跨主机:新增内容寻址的 bundle(版本清单先落、blob 按 sha256 命名、清单最后 Link 作提交点),pack/unpack 幂等且拒绝覆盖与同名不同内容;执行器使用前仍逐 blob 重算 sha256,因此传输不可变有两道证据。 目标侧门禁:目标知识库 graph_enabled=true 时拒绝发布(发布只写 PG/ES/OSS,不写 Neo4j;未知也拒绝),只读检测、不改目标配置、不做图谱迁移。 云端入口:新增 drain 感知的 ingress 闸门(建连超时与读超时分离,半关闭单向传播, SIGTERM 排空后写 drain.json 并 exit 0/2)、compose 叠加片段(app 不再发布端口、 frontend 与 adapter 的入口收敛到闸门、指向真实网络 weknora-test-v071-network)、 执行器启动脚本与治理 PG 反向隧道脚本(默认 unix socket,不占 TCP)。 验证:deploy/cloud-test/test_gate.py 13 项全 PASS(含 12 秒静默流、半关闭、2MiB 完整、 排空 exit 0/active_at_close=1、排空超时 exit 2、上游不可达不挂住);go test 相关包全绿; scripts/ci_checks.py 16 项全部 success(EXIT=0,1241 用例通过/0 失败/191 已声明可选集成跳过); 云测试只读预检 17 项 / 6 FAIL,FAIL 项即当前部署缺口(无闸门、app 仍发布端口、 adapter 与 frontend 仍直连 app、云端归档根未建),未部署、未改云端任何状态。
185 lines
5.5 KiB
Go
185 lines
5.5 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
//go:embed migrations/*.sql
|
|
var migrationFS embed.FS
|
|
|
|
type migration struct {
|
|
Version int64
|
|
UpSQL string
|
|
DownSQL string
|
|
}
|
|
|
|
func loadMigrations() ([]migration, error) {
|
|
paths, err := fs.Glob(migrationFS, "migrations/*.up.sql")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("glob migrations: %w", err)
|
|
}
|
|
result := make([]migration, 0, len(paths))
|
|
for _, path := range paths {
|
|
base := strings.TrimPrefix(path, "migrations/")
|
|
versionText, _, ok := strings.Cut(base, "_")
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid migration filename %q", path)
|
|
}
|
|
version, err := strconv.ParseInt(versionText, 10, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse migration version %q: %w", path, err)
|
|
}
|
|
up, err := migrationFS.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read up migration %q: %w", path, err)
|
|
}
|
|
downPath := strings.TrimSuffix(path, ".up.sql") + ".down.sql"
|
|
down, err := migrationFS.ReadFile(downPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read down migration %q: %w", downPath, err)
|
|
}
|
|
result = append(result, migration{Version: version, UpSQL: string(up), DownSQL: string(down)})
|
|
}
|
|
sort.Slice(result, func(i, j int) bool { return result[i].Version < result[j].Version })
|
|
return result, nil
|
|
}
|
|
|
|
const migrationLockID int64 = 7346729101
|
|
|
|
func ApplyMigrations(ctx context.Context, pool *pgxpool.Pool) error {
|
|
if _, err := pool.Exec(ctx, "SELECT pg_advisory_lock($1)", migrationLockID); err != nil {
|
|
return fmt.Errorf("lock migrations: %w", err)
|
|
}
|
|
defer func() { _, _ = pool.Exec(context.Background(), "SELECT pg_advisory_unlock($1)", migrationLockID) }()
|
|
|
|
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version BIGINT PRIMARY KEY,
|
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
)`); err != nil {
|
|
return fmt.Errorf("create migration ledger: %w", err)
|
|
}
|
|
|
|
migrations, err := loadMigrations()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, item := range migrations {
|
|
var applied bool
|
|
if err := pool.QueryRow(ctx, "SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)", item.Version).Scan(&applied); err != nil {
|
|
return fmt.Errorf("check migration %d: %w", item.Version, err)
|
|
}
|
|
if applied {
|
|
continue
|
|
}
|
|
tx, err := pool.BeginTx(ctx, pgx.TxOptions{})
|
|
if err != nil {
|
|
return fmt.Errorf("begin migration %d: %w", item.Version, err)
|
|
}
|
|
if _, err := tx.Exec(ctx, item.UpSQL); err != nil {
|
|
_ = tx.Rollback(ctx)
|
|
return fmt.Errorf("apply migration %d: %w", item.Version, err)
|
|
}
|
|
if _, err := tx.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", item.Version); err != nil {
|
|
_ = tx.Rollback(ctx)
|
|
return fmt.Errorf("record migration %d: %w", item.Version, err)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("commit migration %d: %w", item.Version, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func RollbackLatestMigration(ctx context.Context, pool *pgxpool.Pool) error {
|
|
migrations, err := loadMigrations()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var version int64
|
|
if err := pool.QueryRow(ctx, "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1").Scan(&version); err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("read latest migration: %w", err)
|
|
}
|
|
var selected *migration
|
|
for i := range migrations {
|
|
if migrations[i].Version == version {
|
|
selected = &migrations[i]
|
|
break
|
|
}
|
|
}
|
|
if selected == nil {
|
|
return fmt.Errorf("missing down migration for version %d", version)
|
|
}
|
|
tx, err := pool.BeginTx(ctx, pgx.TxOptions{})
|
|
if err != nil {
|
|
return fmt.Errorf("begin rollback %d: %w", version, err)
|
|
}
|
|
if _, err := tx.Exec(ctx, selected.DownSQL); err != nil {
|
|
_ = tx.Rollback(ctx)
|
|
return fmt.Errorf("rollback migration %d: %w", version, err)
|
|
}
|
|
if _, err := tx.Exec(ctx, "DELETE FROM schema_migrations WHERE version = $1", version); err != nil {
|
|
_ = tx.Rollback(ctx)
|
|
return fmt.Errorf("delete migration ledger %d: %w", version, err)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("commit rollback %d: %w", version, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PendingMigrations 只读地回答"治理库 schema 落后了没有"。
|
|
//
|
|
// 给"不拥有 DDL 的执行器"用(云端发布执行器):启动即校验,落后就拒绝启动,
|
|
// 绝不顺手迁移——治理库结构由控制面那个进程决定,不因为多了一台机器就多一个 DDL 源。
|
|
func PendingMigrations(ctx context.Context, pool *pgxpool.Pool) ([]int64, error) {
|
|
if pool == nil {
|
|
return nil, errors.New("database pool is required")
|
|
}
|
|
migrations, err := loadMigrations()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var ledgerExists bool
|
|
if err := pool.QueryRow(ctx, "SELECT to_regclass('public.schema_migrations') IS NOT NULL").Scan(&ledgerExists); err != nil {
|
|
return nil, fmt.Errorf("check migration ledger: %w", err)
|
|
}
|
|
applied := map[int64]bool{}
|
|
if ledgerExists {
|
|
rows, err := pool.Query(ctx, "SELECT version FROM schema_migrations")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read migration ledger: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var version int64
|
|
if err := rows.Scan(&version); err != nil {
|
|
return nil, err
|
|
}
|
|
applied[version] = true
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
pending := make([]int64, 0, 4)
|
|
for _, item := range migrations {
|
|
if !applied[item.Version] {
|
|
pending = append(pending, item.Version)
|
|
}
|
|
}
|
|
return pending, nil
|
|
}
|