Files
BackupX/server/internal/backup/saphana_runner.go
Awuqing fe803e2296 feat(saphana): refactor backup from SQL export to BACKUP DATA USING FILE
Replace the hdbsql SELECT-based schema DDL export with SAP HANA's official
BACKUP DATA USING FILE for proper data-level backup.

Changes:
- Run: issue BACKUP DATA [FOR <tenant>] USING FILE via hdbsql, package
  resulting backup files into tar archive as artifact
- Restore: extract tar, locate backup prefix, issue RECOVER DATA
  [FOR <tenant>] USING FILE ... CLEAR LOG
- Add helper functions: buildHdbsqlArgs, packageBackupFiles,
  extractTarArchive, findBackupPrefix
- Add 7 unit tests covering backup/restore/error paths
2026-03-24 18:24:12 +08:00

340 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package backup
import (
"archive/tar"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// SAPHANARunner implements the BackupRunner interface for SAP HANA databases.
// It uses hdbsql to issue BACKUP DATA USING FILE commands for proper data-level
// backup (SAP best practice), rather than logical SQL export.
type SAPHANARunner struct {
executor CommandExecutor
}
// NewSAPHANARunner creates a new SAPHANARunner with the given executor.
// If executor is nil, a default OS command executor is used.
func NewSAPHANARunner(executor CommandExecutor) *SAPHANARunner {
if executor == nil {
executor = NewOSCommandExecutor()
}
return &SAPHANARunner{executor: executor}
}
func (r *SAPHANARunner) Type() string {
return "saphana"
}
// Run executes a SAP HANA data-level backup using hdbsql + BACKUP DATA USING FILE.
// The backup files are written to a temporary directory, then packaged into a tar
// archive as the artifact for BackupX to compress/encrypt/upload.
func (r *SAPHANARunner) Run(ctx context.Context, task TaskSpec, writer LogWriter) (*RunResult, error) {
if _, err := r.executor.LookPath("hdbsql"); err != nil {
return nil, fmt.Errorf("未找到 hdbsql 命令 (请确保服务器已安装 SAP HANA Client)")
}
startedAt := task.StartedAt
if startedAt.IsZero() {
startedAt = time.Now().UTC()
}
// Create a temp directory for the tar artifact output.
tempDir, artifactPath, err := createTempArtifact(task.TempDir, task.Name, "tar")
if err != nil {
return nil, err
}
// Create a sub-directory where HANA will write its backup data files.
backupDir := filepath.Join(tempDir, "hana_data")
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return nil, fmt.Errorf("create HANA backup directory: %w", err)
}
dbNames := normalizeDatabaseNames(task.Database.Names)
tenantDB := "SYSTEMDB"
if len(dbNames) > 0 {
tenantDB = dbNames[0]
}
port := task.Database.Port
if port == 0 {
port = 30015
}
writer.WriteLine(fmt.Sprintf("连接到 SAP HANA: %s:%d", task.Database.Host, port))
writer.WriteLine(fmt.Sprintf("备份数据库: %s", tenantDB))
// Build backup prefix — HANA will create files like <prefix>_databackup_<N>_1.
timestamp := startedAt.UTC().Format("20060102_150405")
backupPrefix := filepath.Join(backupDir, fmt.Sprintf("hana_%s_%s", strings.ToLower(tenantDB), timestamp))
// Build `BACKUP DATA USING FILE` SQL.
backupSQL := fmt.Sprintf(`BACKUP DATA USING FILE ('%s')`, backupPrefix)
if strings.ToUpper(tenantDB) != "SYSTEMDB" {
backupSQL = fmt.Sprintf(`BACKUP DATA FOR %s USING FILE ('%s')`, tenantDB, backupPrefix)
}
// Construct hdbsql connection arguments.
args := buildHdbsqlArgs(task.Database.Host, port, task.Database.User, task.Database.Password, tenantDB, backupSQL)
stderrWriter := newLogLineWriter(writer, "hdbsql")
writer.WriteLine("开始执行 SAP HANA BACKUP DATA USING FILE")
if err := r.executor.Run(ctx, "hdbsql", args, CommandOptions{
Stderr: stderrWriter,
}); err != nil {
return nil, fmt.Errorf("run hdbsql BACKUP DATA: %w: %s", err, stderrWriter.collected())
}
writer.WriteLine("SAP HANA BACKUP DATA 命令执行完成,开始打包备份文件")
// Package all generated backup files into a tar archive.
if err := packageBackupFiles(backupDir, artifactPath, writer); err != nil {
return nil, fmt.Errorf("package HANA backup files: %w", err)
}
info, _ := os.Stat(artifactPath)
sizeStr := "未知"
var fileSize int64
if info != nil {
fileSize = info.Size()
sizeStr = formatFileSize(fileSize)
}
writer.WriteLine(fmt.Sprintf("SAP HANA 备份完成(归档大小: %s", sizeStr))
return &RunResult{
ArtifactPath: artifactPath,
FileName: filepath.Base(artifactPath),
TempDir: tempDir,
Size: fileSize,
StorageKey: BuildStorageKey("saphana", startedAt, filepath.Base(artifactPath)),
}, nil
}
// Restore executes a SAP HANA restore using RECOVER DATA USING FILE.
// It extracts the tar archive to get the original backup files, then issues
// the recovery SQL command via hdbsql.
func (r *SAPHANARunner) Restore(ctx context.Context, task TaskSpec, artifactPath string, writer LogWriter) error {
if _, err := r.executor.LookPath("hdbsql"); err != nil {
return fmt.Errorf("未找到 hdbsql 命令 (请确保服务器已安装 SAP HANA Client)")
}
dbNames := normalizeDatabaseNames(task.Database.Names)
tenantDB := "SYSTEMDB"
if len(dbNames) > 0 {
tenantDB = dbNames[0]
}
port := task.Database.Port
if port == 0 {
port = 30015
}
writer.WriteLine(fmt.Sprintf("开始恢复 SAP HANA 数据库: %s", tenantDB))
// Extract the tar archive to a temporary directory.
restoreDir, err := os.MkdirTemp("", "backupx-hana-restore-*")
if err != nil {
return fmt.Errorf("create restore temp dir: %w", err)
}
defer os.RemoveAll(restoreDir)
if err := extractTarArchive(artifactPath, restoreDir); err != nil {
return fmt.Errorf("extract HANA backup tar: %w", err)
}
// Find the backup prefix by locating backup data files.
prefix, err := findBackupPrefix(restoreDir)
if err != nil {
return fmt.Errorf("find backup prefix: %w", err)
}
writer.WriteLine(fmt.Sprintf("找到备份前缀: %s", filepath.Base(prefix)))
// Build RECOVER DATA SQL.
recoverSQL := fmt.Sprintf(`RECOVER DATA USING FILE ('%s') CLEAR LOG`, prefix)
if strings.ToUpper(tenantDB) != "SYSTEMDB" {
recoverSQL = fmt.Sprintf(`RECOVER DATA FOR %s USING FILE ('%s') CLEAR LOG`, tenantDB, prefix)
}
args := buildHdbsqlArgs(task.Database.Host, port, task.Database.User, task.Database.Password, tenantDB, recoverSQL)
stderrWriter := newLogLineWriter(writer, "hdbsql")
if err := r.executor.Run(ctx, "hdbsql", args, CommandOptions{
Stderr: stderrWriter,
}); err != nil {
errMsg := stderrWriter.collected()
return fmt.Errorf("run hdbsql RECOVER DATA: %w: %s", err, strings.TrimSpace(errMsg))
}
writer.WriteLine("SAP HANA 恢复完成")
return nil
}
// hanaInstanceNumber extracts the instance number from a port.
// SAP HANA ports follow the pattern 3<instance>15, e.g., 30015 for instance 00.
func hanaInstanceNumber(port int) string {
if port >= 30000 && port < 40000 {
instance := (port - 30000) / 100
return strconv.Itoa(instance)
}
return "00"
}
// buildHdbsqlArgs constructs the common hdbsql CLI arguments.
func buildHdbsqlArgs(host string, port int, user, password, database, sql string) []string {
return []string{
"-n", fmt.Sprintf("%s:%d", host, port),
"-u", user,
"-p", password,
"-d", database,
"-j", // disable auto-commit
"-A", // disable column alignment
"-xC", // suppress column headers and separator
sql,
}
}
// packageBackupFiles creates a tar archive from all files in the given directory.
func packageBackupFiles(sourceDir, targetPath string, writer LogWriter) error {
file, err := os.Create(targetPath)
if err != nil {
return fmt.Errorf("create tar file: %w", err)
}
defer file.Close()
tw := tar.NewWriter(file)
defer tw.Close()
fileCount := 0
walkErr := filepath.Walk(sourceDir, func(currentPath string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
if currentPath == sourceDir {
return nil
}
relPath, err := filepath.Rel(sourceDir, currentPath)
if err != nil {
return err
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = filepath.ToSlash(relPath)
if err := tw.WriteHeader(header); err != nil {
return err
}
if info.Mode().IsRegular() {
f, err := os.Open(currentPath)
if err != nil {
return err
}
defer f.Close()
if _, err := io.CopyN(tw, f, info.Size()); err != nil && err != io.EOF {
return err
}
fileCount++
}
return nil
})
if walkErr != nil {
return walkErr
}
if fileCount == 0 {
return fmt.Errorf("HANA 备份目录中未找到任何备份文件")
}
writer.WriteLine(fmt.Sprintf("已打包 %d 个备份文件", fileCount))
return nil
}
// extractTarArchive extracts a tar archive to the given directory.
func extractTarArchive(tarPath, targetDir string) error {
f, err := os.Open(filepath.Clean(tarPath))
if err != nil {
return err
}
defer f.Close()
tr := tar.NewReader(f)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("read tar entry: %w", err)
}
targetPath := filepath.Join(targetDir, filepath.FromSlash(filepath.Clean(header.Name)))
// Guard against path traversal.
if !strings.HasPrefix(targetPath, filepath.Clean(targetDir)+string(filepath.Separator)) {
continue
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(targetPath, 0o755); err != nil {
return err
}
case tar.TypeReg, tar.TypeRegA:
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
outFile, err := os.Create(targetPath)
if err != nil {
return err
}
if _, err := io.Copy(outFile, tr); err != nil {
outFile.Close()
return err
}
outFile.Close()
}
}
return nil
}
// findBackupPrefix locates the backup prefix by scanning for HANA backup data files.
// HANA creates files like <prefix>_databackup_0_1, <prefix>_databackup_1_1, etc.
func findBackupPrefix(dir string) (string, error) {
var prefix string
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
name := info.Name()
if idx := strings.Index(name, "_databackup_"); idx > 0 {
prefix = filepath.Join(filepath.Dir(path), name[:idx])
return filepath.SkipAll
}
// Also check for the complete backup file pattern without _databackup_
if strings.HasPrefix(name, "hana_") {
prefix = filepath.Join(filepath.Dir(path), strings.TrimSuffix(name, filepath.Ext(name)))
return filepath.SkipAll
}
return nil
})
if err != nil && err != filepath.SkipAll {
return "", err
}
if prefix == "" {
return "", fmt.Errorf("未在归档中找到 HANA 备份数据文件")
}
return prefix, nil
}