From 8990710ee145809eb3a937d3b4eb578adbdeb3fe Mon Sep 17 00:00:00 2001 From: xuans Date: Thu, 17 Sep 2026 10:32:45 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85PostgreSQL=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E5=A4=87=E4=BB=BD=E6=81=A2=E5=A4=8D=E6=BC=94=E7=BB=83=E4=B8=8E?= =?UTF-8?q?=E9=9A=94=E7=A6=BB=E9=9B=86=E6=88=90=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cli/postgres_integration_helpers_test.go | 220 +++++++++++++++ .../internal/cli/postgres_integration_test.go | 256 ++++++++++++++++++ doc/PostgreSQL备份恢复说明.md | 28 +- doc/首次IP初始化开发记录.md | 16 +- 4 files changed, 518 insertions(+), 2 deletions(-) create mode 100644 backend/internal/cli/postgres_integration_helpers_test.go create mode 100644 backend/internal/cli/postgres_integration_test.go diff --git a/backend/internal/cli/postgres_integration_helpers_test.go b/backend/internal/cli/postgres_integration_helpers_test.go new file mode 100644 index 0000000..689146b --- /dev/null +++ b/backend/internal/cli/postgres_integration_helpers_test.go @@ -0,0 +1,220 @@ +package cli + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/pem" + "errors" + "math/big" + "net" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5" +) + +type realPostgres struct { + root string + bin string + cli string + port string + ca string + password string + admin *pgx.Conn +} + +// 只由显式环境变量启用;自行创建集群,不接受现有数据库连接串。 +func startRealPostgres(t *testing.T) *realPostgres { + t.Helper() + bin := os.Getenv("SCERT_POSTGRES_TEST_BIN") + if bin == "" { + t.Skip("未启用 PostgreSQL 真实集成测试") + } + root := os.Getenv("SCERT_POSTGRES_TEST_ROOT") + cli := os.Getenv("SCERT_POSTGRES_TEST_CLI") + if !filepath.IsAbs(root) || !filepath.IsAbs(bin) || !filepath.IsAbs(cli) || !isRegularFile(cli) { + t.Fatal("真实测试要求工具、最新编译的 Scert 和隔离产物目录使用绝对路径") + } + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + root, err := os.MkdirTemp(root, "scert-postgres-integration-") + if err != nil { + t.Fatal(err) + } + f := &realPostgres{root: root, bin: bin, cli: cli, password: realPostgresPassword(t)} + t.Logf("隔离集群及演练产物保留于:%s", root) + for _, name := range []string{"initdb", "pg_ctl", "pg_dump", "pg_restore"} { + if !isRegularFile(f.tool(name)) { + t.Fatalf("缺少官方工具:%s", name) + } + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + passwordFile := filepath.Join(root, "bootstrap-password.txt") + realPostgresWrite(t, passwordFile, []byte(f.password+"\n")) + data := filepath.Join(root, "cluster") + f.control(t, "initdb", "-D", data, "-U", "postgres", "--pwfile="+passwordFile, "--auth=scram-sha-256", "--encoding=UTF8", "--no-locale") + f.ca = filepath.Join(data, "server.crt") + realPostgresTLS(t, f.ca, filepath.Join(data, "server.key")) + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + f.port = strconv.Itoa(listener.Addr().(*net.TCPAddr).Port) + if err := listener.Close(); err != nil { + t.Fatal(err) + } + realPostgresWrite(t, filepath.Join(data, "postgresql.conf"), []byte("listen_addresses = '127.0.0.1'\nport = "+f.port+"\nssl = on\nssl_cert_file = 'server.crt'\nssl_key_file = 'server.key'\npassword_encryption = 'scram-sha-256'\nunix_socket_directories = ''\n")) + realPostgresWrite(t, filepath.Join(data, "pg_hba.conf"), []byte("hostssl all all 127.0.0.1/32 scram-sha-256\nhost all all 0.0.0.0/0 reject\nhost all all ::0/0 reject\n")) + // 在启动前注册停止动作,启动超时也尝试关闭自己的集群;不删除任何产物。 + t.Cleanup(func() { + if f.admin != nil { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = f.admin.Close(ctx) + } + if !isRegularFile(filepath.Join(data, "postmaster.pid")) { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, f.tool("pg_ctl"), "-D", data, "-m", "fast", "-w", "-t", "20", "stop") + cmd.Env = postgresToolEnvironment() + cmd.WaitDelay = 2 * time.Second + if err := cmd.Run(); err != nil { + t.Errorf("停止隔离 PostgreSQL 失败,请检查保留目录:%v", err) + } + }) + f.control(t, "pg_ctl", "-D", data, "-l", filepath.Join(root, "postgres.log"), "-w", "-t", "30", "start") + f.admin = f.connect(t, "postgres", "postgres") + realPostgresExec(t, f.admin, "CREATE ROLE scert_owner LOGIN PASSWORD '"+f.password+"'") + realPostgresExec(t, f.admin, "CREATE ROLE scert_reader") + return f +} + +func (f *realPostgres) tool(name string) string { + if runtime.GOOS == "windows" { + name += ".exe" + } + return filepath.Join(f.bin, name) +} + +func (f *realPostgres) control(t *testing.T, name string, args ...string) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, f.tool(name), args...) + cmd.Env = postgresToolEnvironment() + // 后台服务会继承句柄,直接使用空设备,不能让其持有 Go 的输出复制管道。 + null, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + defer null.Close() + cmd.Stdout, cmd.Stderr = null, null + cmd.WaitDelay = 2 * time.Second + if err := cmd.Run(); err != nil { + t.Fatalf("隔离集群工具 %s 失败,请检查保留目录:%v", name, err) + } +} + +func (f *realPostgres) runCLI(t *testing.T, configuration string, args ...string) (string, error) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, f.cli, append([]string{"--config", configuration}, args...)...) + cmd.Env = postgresToolEnvironment() + cmd.WaitDelay = 2 * time.Second + output, err := cmd.CombinedOutput() + if strings.Contains(string(output), f.password) { + t.Fatal("Scert 命令输出泄漏了测试密码") + } + if err != nil { + return string(output), errors.New(string(output)) + } + return string(output), nil +} + +func (f *realPostgres) dsn(database, username string) string { + u := &url.URL{Scheme: "postgres", Host: net.JoinHostPort("127.0.0.1", f.port), Path: "/" + database} + u.RawQuery = url.Values{"sslmode": {"verify-full"}, "sslrootcert": {f.ca}, "connect_timeout": {"5"}}.Encode() + if username != "" { + u.User = url.UserPassword(username, f.password) + } + return u.String() +} + +func (f *realPostgres) connect(t *testing.T, database, username string) *pgx.Conn { + t.Helper() + conn, err := pgx.Connect(t.Context(), f.dsn(database, username)) + if err != nil { + t.Fatal("隔离 PostgreSQL 严格 TLS 连接失败") + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = conn.Close(ctx) + }) + return conn +} + +func realPostgresExec(t *testing.T, conn *pgx.Conn, query string, args ...any) { + t.Helper() + if _, err := conn.Exec(t.Context(), query, args...); err != nil { + t.Fatal("隔离 PostgreSQL 测试数据操作失败") + } +} + +func realPostgresWrite(t *testing.T, path string, content []byte) { + t.Helper() + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } +} + +func realPostgresPassword(t *testing.T) string { + t.Helper() + var value [24]byte + if _, err := rand.Read(value[:]); err != nil { + t.Fatal(err) + } + return hex.EncodeToString(value[:]) +} + +func realPostgresTLS(t *testing.T, certificatePath, keyPath string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Scert integration"}, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IsCA: true, BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + privateKey, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + realPostgresWrite(t, certificatePath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) + realPostgresWrite(t, keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateKey})) +} diff --git a/backend/internal/cli/postgres_integration_test.go b/backend/internal/cli/postgres_integration_test.go new file mode 100644 index 0000000..566e904 --- /dev/null +++ b/backend/internal/cli/postgres_integration_test.go @@ -0,0 +1,256 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "io" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/database" + "git.pcux.cn/xuans/Scert/backend/internal/secretstore" +) + +func TestPostgresRealBackupRestore(t *testing.T) { + f := startRealPostgres(t) + for _, scenario := range []string{"success", "certificate", "cancel", "permission", "inventory", "authentication", "tls"} { + t.Run(scenario, func(t *testing.T) { + dbName := "scert_" + scenario + realPostgresExec(t, f.admin, "CREATE DATABASE "+dbName+" OWNER scert_owner") + conn := f.connect(t, dbName, "scert_owner") + var tls bool + if err := conn.QueryRow(t.Context(), "SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()").Scan(&tls); err != nil || !tls { + t.Fatal("真实数据库连接必须启用 TLS") + } + root := filepath.Join(f.root, scenario) + cfg := config.Default(root) + cfg.Paths = config.PathsConfig{ + Install: root, Config: filepath.Join(root, "conf"), Data: filepath.Join(root, "data"), + Certificate: filepath.Join(root, "ssl"), Log: filepath.Join(root, "log"), Backup: filepath.Join(root, "snapshots"), + } + cfg.Database = config.DatabaseConfig{Driver: "postgres", DSN: f.dsn(dbName, ""), CredentialRef: "secret://database/maintenance", MaxOpenConns: 4, MaxIdleConns: 2} + current := filepath.Join(cfg.Paths.Config, "scert.json") + if err := cfg.Save(current); err != nil { + t.Fatal(err) + } + store, err := secretstore.Open(cfg.Paths.Config) + if err != nil { + t.Fatal(err) + } + if err := store.Put(t.Context(), cfg.Database.CredentialRef, map[string]string{"username": "scert_owner", "password": f.password}); err != nil { + t.Fatal(err) + } + connection, err := resolveMaintenanceDatabase(t.Context(), cfg.Database, cfg.Paths.Config) + if err != nil { + t.Fatal(err) + } + db, err := database.Open(t.Context(), connection) + if err != nil { + t.Fatal("真实数据库迁移连接失败") + } + err = database.Migrate(t.Context(), db.DB, "postgres") + _ = db.Close() + if err != nil { + t.Fatal(err) + } + realPostgresExec(t, conn, "INSERT INTO settings (key, value) VALUES ('integration', 'backup')") + realPostgresExec(t, conn, "INSERT INTO audit_logs (action, resource_type, resource_id, status, message) VALUES ('backup', 'test', '1', 'ok', 'backup')") + realPostgresExec(t, conn, "GRANT SELECT ON settings TO scert_reader") + if scenario == "permission" { + admin := f.connect(t, dbName, "postgres") + realPostgresExec(t, admin, "CREATE TABLE protected_record (value TEXT)") + realPostgresExec(t, admin, "INSERT INTO protected_record VALUES ('preserved')") + realPostgresExec(t, admin, "GRANT SELECT ON protected_record TO scert_owner") + } + if err := os.MkdirAll(cfg.Paths.Certificate, 0o700); err != nil { + t.Fatal(err) + } + certificate := filepath.Join(cfg.Paths.Certificate, "integration.pem") + certData, err := os.ReadFile(f.ca) + if err != nil { + t.Fatal(err) + } + realPostgresWrite(t, certificate, certData) + previous := configPath + configPath = current + t.Cleanup(func() { configPath = previous }) + input := filepath.Join(root, "backup") + if _, err := f.runCLI(t, current, "backup", "--output", input); err != nil { + t.Fatal(err) + } + if err := validateBackupManifest(input); err != nil { + t.Fatal(err) + } + if scenario == "authentication" || scenario == "tls" { + if scenario == "authentication" { + if err := store.Put(t.Context(), cfg.Database.CredentialRef, map[string]string{"username": "scert_owner", "password": "incorrect-password"}); err != nil { + t.Fatal(err) + } + } else { + // 使用不信任服务端的独立 CA,验证不能静默降级 TLS。 + wrongCA := filepath.Join(root, "untrusted.crt") + realPostgresTLS(t, wrongCA, filepath.Join(root, "untrusted.key")) + cfg.Database.DSN = postgresIntegrationCA(t, cfg.Database.DSN, wrongCA) + if err := cfg.Save(current); err != nil { + t.Fatal(err) + } + } + output := filepath.Join(root, "rejected-backup") + if _, err := f.runCLI(t, current, "backup", "--output", output); err == nil || strings.Contains(err.Error(), f.password) { + t.Fatal("认证或 TLS 错误必须拒绝且不得泄漏密码") + } + if _, err := os.Stat(output); !os.IsNotExist(err) { + t.Fatal("认证或 TLS 失败不得发布备份") + } + return + } + realPostgresExec(t, conn, "UPDATE settings SET value = 'current' WHERE key = 'integration'") + realPostgresExec(t, conn, "INSERT INTO audit_logs (action, resource_type, resource_id, status, message) VALUES ('current', 'test', '2', 'ok', 'current')") + cfg.Server.Port++ + if err := cfg.Save(current); err != nil { + t.Fatal(err) + } + if err := store.Put(t.Context(), "secret://integration/marker", map[string]string{"value": "current"}); err != nil { + t.Fatal(err) + } + realPostgresWrite(t, certificate, []byte("current-certificate")) + if scenario == "certificate" { + if err := os.Rename(cfg.Paths.Certificate, cfg.Paths.Certificate+"-preserved"); err != nil { + t.Fatal(err) + } + realPostgresWrite(t, cfg.Paths.Certificate, []byte("blocked-target")) + } + if scenario == "inventory" { + realPostgresExec(t, conn, "CREATE TABLE extra_record (value TEXT)") + } + before := make(map[string][]byte) + for _, name := range []string{"scert.json", "master.key", "secrets.json"} { + before[name], err = os.ReadFile(filepath.Join(cfg.Paths.Config, name)) + if err != nil { + t.Fatal(err) + } + } + var output bytes.Buffer + command := newRestoreCommand() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if scenario == "cancel" { + // 真正导入完成后确定性取消,反向回滚仍执行官方工具和真实数据库。 + command = newRestoreCommandWithPostgresTools(func(ctx context.Context) (*postgresTools, error) { + tools, err := loadPostgresTools(ctx) + if err != nil { + return nil, err + } + tools.run = func(ctx context.Context, path string, args, env []string, output io.Writer) error { + err := executePostgresTool(ctx, path, args, env, output) + if err == nil && slices.Contains(args, "--single-transaction") && args[len(args)-1] == filepath.Join(input, "scert.pgdump") { + cancel() + } + return err + } + return tools, nil + }) + } + command.SetArgs([]string{"--input", input}) + command.SetContext(ctx) + command.SetOut(&output) + command.SetErr(&output) + if scenario == "cancel" { + err = command.Execute() + } else { + var text string + text, err = f.runCLI(t, current, "restore", "--input", input) + output.WriteString(text) + } + want, count := "backup", 1 + if scenario == "success" { + if err != nil || !strings.Contains(output.String(), "恢复已完成") { + t.Fatalf("真实恢复失败:%v", err) + } + for _, name := range []string{"scert.json", "master.key", "secrets.json"} { + assertRealPostgresFile(t, filepath.Join(cfg.Paths.Config, name), filepath.Join(input, name)) + } + assertRealPostgresFile(t, certificate, filepath.Join(input, "certificates", "integration.pem")) + } else { + want, count = "current", 2 + if err == nil || strings.Contains(output.String(), "恢复已完成") || strings.Contains(err.Error(), f.password) { + t.Fatal("受控失败不得误报成功或泄漏密码") + } + if scenario == "permission" && !strings.Contains(err.Error(), "数据库自动回滚失败") { + t.Fatal("恢复及反向导入权限不足时必须提示人工处理") + } + if scenario == "cancel" && !errors.Is(err, context.Canceled) { + t.Fatal("取消场景必须到达真实导入后的取消检查") + } + if scenario == "certificate" && !strings.Contains(err.Error(), "恢复敏感配置或证书失败") { + t.Fatal("证书失败场景必须到达数据库导入后的资源恢复阶段") + } + if scenario == "inventory" && !strings.Contains(err.Error(), "对象清单不一致") { + t.Fatal("额外对象必须由归档清单检查拦截") + } + for name, expected := range before { + actual, readErr := os.ReadFile(filepath.Join(cfg.Paths.Config, name)) + if readErr != nil || !bytes.Equal(actual, expected) { + t.Fatalf("失败恢复改变了当前文件:%s", name) + } + } + } + var value, owner string + var rows, next int + var allowed bool + if err := conn.QueryRow(t.Context(), "SELECT value FROM settings WHERE key = 'integration'").Scan(&value); err != nil || value != want { + t.Fatal("真实数据库内容不符合恢复或回滚结果") + } + if err := conn.QueryRow(t.Context(), "SELECT count(*) FROM audit_logs").Scan(&rows); err != nil || rows != count { + t.Fatal("真实审计记录未正确恢复") + } + if err := conn.QueryRow(t.Context(), "SELECT nextval(pg_get_serial_sequence('audit_logs', 'id'))").Scan(&next); err != nil || next != count+1 { + t.Fatal("真实自增序列未正确恢复") + } + if err := conn.QueryRow(t.Context(), "SELECT tableowner FROM pg_tables WHERE schemaname = 'public' AND tablename = 'settings'").Scan(&owner); err != nil || owner != "scert_owner" { + t.Fatal("对象所有者未保留") + } + if err := conn.QueryRow(t.Context(), "SELECT has_table_privilege('scert_reader', 'settings', 'SELECT')").Scan(&allowed); err != nil || !allowed { + t.Fatal("对象授权未保留") + } + if scenario == "permission" { + if err := conn.QueryRow(t.Context(), "SELECT value FROM protected_record").Scan(&value); err != nil || value != "preserved" { + t.Fatal("权限失败不能改变受保护表") + } + } + snapshots, err := filepath.Glob(filepath.Join(cfg.Paths.Backup, "scert-postgres-before-restore-*", "scert.pgdump")) + if err != nil || len(snapshots) != 1 { + t.Fatal("必须保留恢复前真实快照") + } + t.Log("真实 CLI、迁移、业务数据、序列、所有者、授权及恢复保护检查通过") + }) + } +} + +func assertRealPostgresFile(t *testing.T, actual, expected string) { + t.Helper() + left, leftErr := os.ReadFile(actual) + right, rightErr := os.ReadFile(expected) + if leftErr != nil || rightErr != nil || !bytes.Equal(left, right) { + t.Fatal("真实恢复后的文件与备份不一致") + } +} + +// 保持测试辅助函数与运行时连接校验使用相同的结构化 URL 语义。 +func postgresIntegrationCA(t *testing.T, dsn, ca string) string { + t.Helper() + parsed, err := url.Parse(dsn) + if err != nil { + t.Fatal("测试连接解析失败") + } + query := parsed.Query() + query.Set("sslrootcert", ca) + parsed.RawQuery = query.Encode() + return parsed.String() +} diff --git a/doc/PostgreSQL备份恢复说明.md b/doc/PostgreSQL备份恢复说明.md index 3e6d006..5eb5b20 100644 --- a/doc/PostgreSQL备份恢复说明.md +++ b/doc/PostgreSQL备份恢复说明.md @@ -56,7 +56,33 @@ scert --config /scert/config/scert.json restore --input /scert/backup/manual-202 - 已有工具协议替身测试,覆盖完整备份、配置/凭据/证书联动、导入与证书失败、取消、回滚失败、对象差异、归档解码失败、既有文件保护等分支。 - 实际测试子进程验证了执行失败脱敏和超时终止;该子进程不是 PostgreSQL 官方工具。 -- 尚未完成真实 PostgreSQL 服务上的备份、恢复、权限及业务恢复演练,也未完成空库灾难恢复开发。不得仅凭替身测试、Linux 构建或前端测试宣称生产验收通过。 +- 2026-09-17 已在 Windows 隔离 PostgreSQL 16.15 集群完成真实演练:最新编译的 Scert、官方工具、全部数据库迁移、完整备份恢复、权限不足、证书恢复失败、取消后的回滚、对象清单差异、错误密码及不可信 CA 场景通过。连接使用回环地址、SCRAM 和严格 TLS,未连接生产库。 +- 成功与回滚场景核对了业务数据、自增序列、对象所有者、授权、配置、主密钥及加密凭据;成功恢复还逐字节核对证书文件。权限不足时原数据保持不变,但正向和反向导入都缺权限,仍明确提示人工处理,不误报自动回滚成功。 +- 七个真实场景分别完成一轮普通演练及三轮 `-race` 重复演练,全部通过;结束后确认无残留数据库进程。竞态检测覆盖 Go 测试及进程内取消路径,不能检测 PostgreSQL 或非竞态编译 CLI 二进制的内部竞态。 +- 尚未完成 Linux 实机、浏览器及远程部署验收,也未完成空库灾难恢复开发;Windows 隔离演练不能替代这些验收。 + +## 隔离实机测试入口 + +真实集成测试为 `TestPostgresRealBackupRestore`,默认跳过。必须显式指定官方工具目录、最新编译的 Scert 可执行文件与隔离产物根目录;不接受业务数据库连接串,不安装系统服务,不修改系统 PATH。 + +Windows PowerShell 示例(在 `backend` 目录执行,工具路径需按实际安装位置调整): + +```powershell +go build -p 1 -o ../.runtime/scert-postgres-integration.exe ./cmd/scert +$env:SCERT_POSTGRES_TEST_BIN = 'D:\Desktop\SSL\.runtime\postgres-tools\pgsql\bin' +$env:SCERT_POSTGRES_TEST_CLI = 'D:\Desktop\SSL\.runtime\scert-postgres-integration.exe' +$env:SCERT_POSTGRES_TEST_ROOT = 'D:\Desktop\SSL\.runtime\postgres-integration' +go test -p 1 -count=1 -timeout=5m -run '^TestPostgresRealBackupRestore$' -v ./internal/cli +``` + +- 每轮创建独立集群,各场景使用全新数据库并运行 Scert 全部迁移,避免复用残留数据。不会清空任何既有业务库。 +- 仅监听 `127.0.0.1` 动态端口,启用 SCRAM 密码认证和 `sslmode=verify-full`,生成仅用于本轮测试的证书。 +- 普通备份及恢复场景执行指定的 Scert 可执行文件;取消场景在进程内确定性注入取消时机,但仍执行真实 `pg_dump`、`pg_restore` 和数据库操作。 +- 覆盖成功恢复、证书恢复失败回滚、导入后取消回滚、权限不足、对象清单不一致、密码错误和不可信 CA。 +- 检查配置、主密钥、加密凭据、证书文件、业务数据、自增序列、所有者、授权及恢复前快照,而不仅检查命令退出码。 +- 正常完成或断言失败后停止自己的集群,保留集群与演练产物。整个测试进程被强制终止时,清理回调可能无法运行,须检查该轮目录中的 `postmaster.pid` 和进程状态。 +- 产物包含测试密码、私钥及数据库备份,只能放在可信账号控制的隔离目录,不得提交到 Git;Windows 需由目录 ACL 控制访问,文件模式不是独立的 ACL 保证。 +- 该入口不等于 Linux、浏览器、远程部署、空库灾难恢复或跨版本恢复已经验收。 ## 官方语义参考 diff --git a/doc/首次IP初始化开发记录.md b/doc/首次IP初始化开发记录.md index 63de6dc..8f8e632 100644 --- a/doc/首次IP初始化开发记录.md +++ b/doc/首次IP初始化开发记录.md @@ -145,4 +145,18 @@ PostgreSQL 连接串日志辅助函数改为整体隐藏,解析失败不返回 ## 下一步 -优先完成 PostgreSQL 官方工具与真实数据库的备份恢复演练,并继续设计空库灾难恢复和跨结构版本恢复;完成非特权防火墙策略及 Linux 实机验证。在允许启动服务和执行发布脚本的环境中完成浏览器、实际证书指纹核对与 Linux 发布验收。真实数据库和 DNS/ACME 测试必须使用专属测试数据与已授权域名,并明确测试范围,不能清空生产数据库或触碰禁止修改的域名。 +PostgreSQL 官方工具与真实数据库的 Windows 隔离演练已于 2026-09-17 完成,见下节。下一步继续设计空库灾难恢复和跨结构版本恢复;完成非特权防火墙策略及 Linux 实机验证。在允许启动服务和执行发布脚本的环境中完成浏览器、实际证书指纹核对与 Linux 发布验收。真实数据库和 DNS/ACME 测试必须使用专属测试数据与已授权域名,并明确测试范围,不能清空生产数据库或触碰禁止修改的域名。 + +## PostgreSQL 真实演练续接(2026-09-17) + +- `[✓]` 按最新附件接手中断的官方工具下载,核对本地及远程基线 `3749367`;修改前创建并推送检查点 `515e346`。 +- `[✓]` 从 EDB 官方 HTTPS 地址断点续传 PostgreSQL 16.15 Windows 二进制包,文件大小为 333048048 字节;SHA-256 为 `5E8AFFFE67DAF949AEEB03B74951F1EC2324E1888F73FBD036AB0E567AB004D9`。哈希仅作本次文件记录,不等于官方独立签名校验。 +- `[✓]` 新增显式启用的真实集成测试及辅助代码,未设置环境变量时跳过,不连接任意外部数据库。每轮自行创建回环地址集群,各场景使用全新数据库及六版实际迁移。 +- `[✓]` 使用 SCRAM、严格 TLS 和普通数据库所有者执行最新编译的 Scert;七个场景通过:完整恢复、证书失败回滚、导入后取消回滚、权限不足、对象差异、错误密码、不可信 CA。 +- `[✓]` 对比配置、主密钥、加密凭据、证书内容,查询业务数据、自增序列、所有者及授权,并检查恢复前快照保留。 +- `[✓]` 首次实测发现测试启动器的输出管道被后台进程继承,造成 `WaitDelay` 超时;改为直接连接空设备后重跑通过。未发现必须修改生产备份恢复逻辑的缺陷。 +- `[✓]` 后端全量普通测试、全量竞态检测、静态检查、依赖校验通过;CLI 普通竞态回归重复三轮通过。七个真实场景完成一轮普通演练及三轮竞态重复演练,全部通过。 +- `[✓]` 核对五个隔离目录(包括首次启动器失败的一轮)均无运行标记,系统中无残留 `postgres.exe`;完整解压任务已结束,所有历史及本轮产物保留。 +- `[!]` Linux 测试二进制只完成交叉编译,尚未执行。没有浏览器或远程部署验收,空库灾难恢复和跨版本恢复仍不支持。 + +本轮编译产物为 `.runtime/scert-postgres-integration-20260917.exe`;演练目录位于 `.runtime/postgres-integration-20260917/`。测试集群结束后停止,产物保留,不提交测试密码、私钥、数据库目录或二进制。未删除历史补丁、脚本和发布产物,未操作生产数据库、远程服务器、真实 DNS 或 `pcux.cn` 证书。