Scert/backend/internal/cli/postgres_backup.go

167 lines
6.1 KiB
Go

package cli
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/config"
)
func (tools *postgresTools) backup(ctx context.Context, connection config.DatabaseConfig, output string) (result error) {
dsn, env, err := postgresToolConnection(connection)
if err != nil {
return err
}
file, err := os.OpenFile(output, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return errors.New("创建 PostgreSQL 备份文件失败,目标不得已存在")
}
defer func() {
_ = file.Close()
if result != nil {
// 只清理本次独占创建的不完整文件,不触碰既有备份。
_ = os.Remove(output)
}
}()
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
defer cancel()
args := []string{"--format=custom", "--no-password", "--lock-wait-timeout=30s", "--dbname=" + dsn}
if err := tools.run(ctx, tools.dumpPath, args, env, file); err != nil {
return errors.Join(errors.New("创建 PostgreSQL 一致性快照失败"), err)
}
if err := file.Sync(); err != nil {
return errors.New("同步 PostgreSQL 备份文件失败")
}
if err := file.Close(); err != nil {
return errors.New("关闭 PostgreSQL 备份文件失败")
}
return tools.validateArchive(ctx, output)
}
func (tools *postgresTools) validateArchive(ctx context.Context, input string) error {
if !isRegularFile(input) {
return errors.New("PostgreSQL 备份目录缺少有效的 scert.pgdump 文件")
}
file, err := os.Open(input)
if err != nil {
return errors.New("无法读取 PostgreSQL 备份")
}
var magic [5]byte
_, readErr := io.ReadFull(file, magic[:])
_ = file.Close()
if readErr != nil || string(magic[:]) != "PGDMP" {
return errors.New("PostgreSQL 备份必须是 pg_dump 自定义格式归档")
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
defer cancel()
// 完整解码归档而非仅检查目录;不连接数据库,不保留含业务数据的 SQL 输出。
if err := tools.run(ctx, tools.restorePath, []string{"--file=-", "--", input}, postgresToolEnvironment(), io.Discard); err != nil {
return errors.Join(errors.New("PostgreSQL 归档完整性校验失败"), err)
}
return nil
}
var postgresArchiveEntry = regexp.MustCompile(`^\d+;\s+\d+\s+\d+\s+(.+)$`)
func (tools *postgresTools) inventory(ctx context.Context, input string) (map[string]struct{}, error) {
output := &limitedPostgresOutput{remaining: 8 << 20}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if err := tools.run(ctx, tools.restorePath, []string{"--list", "--", input}, postgresToolEnvironment(), output); err != nil {
return nil, errors.Join(errors.New("读取 PostgreSQL 归档对象清单失败"), err)
}
items := make(map[string]struct{})
for _, line := range strings.Split(output.String(), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, ";") {
continue
}
match := postgresArchiveEntry.FindStringSubmatch(line)
if len(match) != 2 {
return nil, errors.New("PostgreSQL 归档对象清单格式无效")
}
if _, duplicate := items[match[1]]; duplicate {
return nil, errors.New("PostgreSQL 归档对象清单存在重复条目")
}
items[match[1]] = struct{}{}
}
if len(items) == 0 {
return nil, errors.New("PostgreSQL 归档对象清单为空,拒绝无法验证的恢复")
}
return items, nil
}
func (tools *postgresTools) validateRestoreInventory(ctx context.Context, input, snapshot string) error {
source, err := tools.inventory(ctx, input)
if err != nil {
return err
}
target, err := tools.inventory(ctx, snapshot)
if err != nil {
return err
}
// pg_restore --clean 只删除归档列出的对象。严格相等保证导入及反向回滚
// 都不遗留额外对象;跨迁移版本或空库灾难恢复需管理员使用原生流程。
if len(source) != len(target) {
return errors.New("PostgreSQL 备份与当前数据库对象清单不一致,拒绝自动恢复;请在隔离数据库使用原生工具演练迁移或灾难恢复")
}
for item := range source {
if _, exists := target[item]; !exists {
return errors.New("PostgreSQL 备份与当前数据库对象或所有者不一致,拒绝可能残留对象的恢复")
}
}
return nil
}
func (tools *postgresTools) createRestoreSnapshot(ctx context.Context, cfg config.Config, connection config.DatabaseConfig) (string, error) {
root := filepath.Clean(strings.TrimSpace(cfg.Paths.Backup))
if root == "." {
return "", errors.New("PostgreSQL 恢复前快照目录不能为空")
}
if err := os.MkdirAll(root, 0o700); err != nil {
return "", fmt.Errorf("创建 PostgreSQL 恢复前快照目录失败:%w", err)
}
info, err := os.Lstat(root)
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return "", errors.New("PostgreSQL 恢复前快照目录必须为普通目录")
}
directory, err := os.MkdirTemp(root, "scert-postgres-before-restore-")
if err != nil {
return "", errors.New("创建 PostgreSQL 恢复前快照失败")
}
output := filepath.Join(directory, "scert.pgdump")
if err := tools.backup(ctx, connection, output); err != nil {
return "", err
}
if err := syncDirectory(directory); err != nil {
return "", errors.New("同步 PostgreSQL 恢复前快照目录失败,拒绝执行恢复")
}
if err := syncDirectory(root); err != nil {
return "", errors.New("同步 PostgreSQL 快照父目录失败,拒绝执行恢复")
}
// 成功后也保留原数据库快照,便于人工恢复,不自动清理用户备份。
return output, nil
}
func (tools *postgresTools) restore(ctx context.Context, connection config.DatabaseConfig, input string) error {
dsn, env, err := postgresToolConnection(connection)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
defer cancel()
// 保留对象所有者和授权,权限或角色不足时失败,而不是悄悄丢失访问控制。
args := []string{"--clean", "--if-exists", "--single-transaction", "--exit-on-error", "--no-password", "--dbname=" + dsn, "--", input}
if err := tools.run(ctx, tools.restorePath, args, env, io.Discard); err != nil {
return errors.Join(errors.New("PostgreSQL 单事务恢复失败"), err)
}
return nil
}