补充PostgreSQL真实备份恢复演练与隔离集成回归
This commit is contained in:
parent
515e34630a
commit
8990710ee1
220
backend/internal/cli/postgres_integration_helpers_test.go
Normal file
220
backend/internal/cli/postgres_integration_helpers_test.go
Normal file
@ -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}))
|
||||
}
|
||||
256
backend/internal/cli/postgres_integration_test.go
Normal file
256
backend/internal/cli/postgres_integration_test.go
Normal file
@ -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()
|
||||
}
|
||||
@ -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、浏览器、远程部署、空库灾难恢复或跨版本恢复已经验收。
|
||||
|
||||
## 官方语义参考
|
||||
|
||||
|
||||
@ -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` 证书。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user