167 lines
6.1 KiB
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
|
|
}
|