Compare commits

..
Author SHA1 Message Date
Awuqing b01828e3b4 feat: add Docker deployment support
- Multi-stage Dockerfile (Node build + Go build + Alpine runtime)
- docker-compose.yml with named volume for data persistence
- In-container Nginx reverse proxy (static files + API)
- Entrypoint script for graceful process management
- .dockerignore for optimized build context
- Updated README (zh/en) with Docker quick start and deployment docs

Closes #14
2026-03-30 07:56:15 +08:00
Wu Qing 5cc5b067fd Merge pull request #12 from Awuqing/Awuqing-patch-1
Update SAP HANA tool description in README
2026-03-24 23:39:20 +08:00
Wu Qing 7a67241bc6 Update SAP HANA tool description in README 2026-03-24 22:56:10 +08:00
Wu Qing 3008d86027 Merge pull request #11 from Awuqing/feat/saphana-backup-data
feat(saphana): refactor backup from SQL export to BACKUP DATA USING FILE
2026-03-24 18:31:04 +08:00
Awuqing 29dba71b53 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
Wu Qing ab046be247 Merge pull request #10 from Awuqing/feat/saphana-ftp-support
docs: 更新 README 文档,添加 SAP HANA 和 FTP 支持说明
2026-03-22 11:18:36 +08:00
Awuqing 6118d5e779 docs: 更新 README 文档,添加 SAP HANA 和 FTP 支持说明
- 简介描述新增 FTP/FTPS 存储选项
- 备份类型列表新增 SAP HANA(通过 hdbsql 工具)
- 存储后端表格新增 FTP / FTPS 行
- 架构图 Storage Registry 新增 FTP / FTPS
- 项目结构树新增 backup/saphana 和 storage/ftp
- 技术栈表格新增 jlaffaye/ftp 依赖
- 同步更新中英文双语 README
2026-03-21 16:22:26 +08:00
9 changed files with 789 additions and 77 deletions
+26
View File
@@ -0,0 +1,26 @@
# Dependencies
web/node_modules/
# Build artifacts
server/bin/
web/dist/
# Data & logs
data/
*.db
*.log
# IDE & OS
.idea/
.vscode/
*.swp
*.swo
.DS_Store
# Git
.git/
.github/
# Docker
Dockerfile
docker-compose*.yml
+62
View File
@@ -0,0 +1,62 @@
# ---- Stage 1: Build frontend ----
FROM node:20-alpine AS web-builder
WORKDIR /build/web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
# ---- Stage 2: Build backend ----
FROM golang:1.25-alpine AS server-builder
WORKDIR /build/server
COPY server/go.mod server/go.sum ./
RUN go mod download
COPY server/ ./
RUN go build -trimpath -ldflags="-s -w" -o backupx ./cmd/backupx
# ---- Stage 3: Production image ----
FROM alpine:3.21
RUN apk add --no-cache \
nginx \
tzdata \
ca-certificates \
# Required by mysql/postgresql backup tasks
mysql-client \
postgresql16-client \
&& rm -rf /var/cache/apk/*
# Create app user
RUN addgroup -S backupx && adduser -S -G backupx -h /app backupx
# Copy backend binary
COPY --from=server-builder /build/server/backupx /app/bin/backupx
# Copy frontend static files
COPY --from=web-builder /build/web/dist /app/web
# Copy nginx config
COPY deploy/docker/nginx.conf /etc/nginx/http.d/default.conf
# Copy entrypoint
COPY deploy/docker/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
# Create data directories
RUN mkdir -p /app/data /tmp/backupx && \
chown -R backupx:backupx /app /tmp/backupx
# Nginx needs to write to these dirs
RUN mkdir -p /var/lib/nginx/tmp /var/log/nginx && \
chown -R backupx:backupx /var/lib/nginx /var/log/nginx /run/nginx
WORKDIR /app
EXPOSE 8340
VOLUME ["/app/data"]
ENTRYPOINT ["/app/entrypoint.sh"]
+56 -4
View File
@@ -29,7 +29,7 @@
---
BackupX 是一个面向 **Linux / macOS 服务器**的自托管备份管理平台。通过企业级 Web 控制台,轻松配置目录备份、数据库备份,并将备份文件安全存储到阿里云 OSS、腾讯云 COS、七牛云 Kodo、Google Drive、S3 兼容存储、WebDAV 或本地磁盘。
BackupX 是一个面向 **Linux / macOS 服务器**的自托管备份管理平台。通过企业级 Web 控制台,轻松配置目录备份、数据库备份,并将备份文件安全存储到阿里云 OSS、腾讯云 COS、七牛云 Kodo、Google Drive、S3 兼容存储、WebDAV、FTP/FTPS 或本地磁盘。
支持 **多节点集群管理**,可统一管控分布在不同服务器上的备份任务。
@@ -68,6 +68,7 @@ BackupX 是一个面向 **Linux / macOS 服务器**的自托管备份管理平
- **MySQL** — 通过 `mysqldump` 原生工具
- **SQLite** — 安全文件拷贝
- **PostgreSQL** — 通过 `pg_dump` 原生工具
- **SAP HANA** — 通过 `hdbsql+backint` (支持多租户数据库)
### ☁️ 多云存储后端
| 厂商 | 类型 | 说明 |
@@ -78,6 +79,7 @@ BackupX 是一个面向 **Linux / macOS 服务器**的自托管备份管理平
| 🌍 **S3 Compatible** | `s3` | AWS S3 / MinIO / Cloudflare R2 等 |
| 🌍 **Google Drive** | `google_drive` | 完整 OAuth 2.0 授权流程 |
| 🌍 **WebDAV** | `webdav` | 坚果云 / Nextcloud 等 |
| 🌍 **FTP / FTPS** | `ftp` | 标准 FTP 协议,支持 Explicit TLS 加密 |
| 💾 **本地磁盘** | `local_disk` | 备份到服务器本地目录 |
> 国内云厂商仅需填写 **Region** 和 **AccessKey**,系统自动完成 Endpoint 组装,底层复用 S3 引擎零额外依赖。
@@ -110,10 +112,30 @@ BackupX 是一个面向 **Linux / macOS 服务器**的自托管备份管理平
### 🌐 其他
- 中英文国际化 (i18n)
- 零外部依赖(内嵌 SQLite,单二进制部署)
- Docker / Docker Compose 一键部署
- systemd 服务支持
## Quick Start
### Docker 部署 (推荐)
```bash
# 克隆项目
git clone https://github.com/Awuqing/BackupX.git
cd BackupX
# 一键启动
docker compose up -d
```
如需备份宿主机上的目录,在 `docker-compose.yml` 中挂载对应路径:
```yaml
volumes:
- backupx-data:/app/data
- /path/to/backup/source:/mnt/source:ro
```
### 从源码构建
```bash
@@ -188,6 +210,7 @@ log:
│ │Scheduler │ │ │ S3 Compatible │ ││
│ └──────────┘ │ │ Google Drive │ ││
│ │ │ WebDAV │ ││
│ │ │ FTP / FTPS │ ││
│ ┌──────────┐ │ │ Local Disk │ ││
│ │ Notify │ │ └─────────────────┘ ││
│ │ Module │ └───────────────────────┘│
@@ -211,7 +234,7 @@ log:
|------|------|
| **后端** | Go · Gin · GORM · SQLite · robfig/cron |
| **前端** | React 18 · TypeScript · ArcoDesign · Vite · Zustand · ECharts |
| **存储** | AWS SDK v2 (S3/OSS/COS/Kodo) · Google Drive API v3 · gowebdav |
| **存储** | AWS SDK v2 (S3/OSS/COS/Kodo) · Google Drive API v3 · gowebdav · jlaffaye/ftp |
| **安全** | JWT · bcrypt · AES-256-GCM |
| **日志** | zap + lumberjack (自动轮转) |
@@ -249,7 +272,7 @@ BackupX/
│ ├── internal/
│ │ ├── app/ # 应用组装 (DI)
│ │ ├── apperror/ # 统一错误类型
│ │ ├── backup/ # 备份引擎 (file/mysql/sqlite/pgsql)
│ │ ├── backup/ # 备份引擎 (file/mysql/sqlite/pgsql/saphana)
│ │ │ └── retention/ # 保留策略
│ │ ├── config/ # 配置加载 (viper)
│ │ ├── database/ # 数据库初始化 + 迁移
@@ -272,6 +295,7 @@ BackupX/
│ │ ├── webdav/ # WebDAV 核心
│ │ ├── webdavprovider/ # WebDAV Provider 辅助
│ │ ├── localdisk/ # 本地磁盘
│ │ ├── ftp/ # FTP / FTPS
│ │ └── codec/ # 配置编解码
│ └── pkg/ # 工具包 (compress/crypto/response)
├── web/ # React 前端
@@ -298,11 +322,16 @@ BackupX/
├── deploy/ # 部署配置
│ ├── nginx.conf # Nginx 参考配置
│ ├── backupx.service # systemd 服务单元
── install.sh # 一键安装脚本
── install.sh # 一键安装脚本
│ └── docker/ # Docker 部署配置
│ ├── nginx.conf # 容器内 Nginx 配置
│ └── entrypoint.sh # 容器启动脚本
├── .github/ # GitHub 配置
│ ├── workflows/ci.yml # CI 工作流
│ ├── workflows/release.yml # Release 工作流
│ └── ISSUE_TEMPLATE/ # Issue 模板
├── Dockerfile # Docker 多阶段构建
├── docker-compose.yml # Docker Compose 配置
└── Makefile # 构建命令
```
@@ -367,6 +396,29 @@ sudo ./deploy/install.sh
5. 注册并启动 systemd 服务
6. 配置 Nginx 反向代理(如已安装)
### Docker 部署
```bash
# 使用 docker compose
docker compose up -d
# 或手动构建镜像
docker build -t backupx .
docker run -d --name backupx -p 8340:8340 -v backupx-data:/app/data backupx
```
通过环境变量覆盖配置:
```bash
docker run -d --name backupx \
-p 8340:8340 \
-v backupx-data:/app/data \
-e TZ=Asia/Shanghai \
-e BACKUPX_LOG_LEVEL=debug \
-e BACKUPX_BACKUP_MAX_CONCURRENT=4 \
backupx
```
### 手动部署
```bash
+56 -4
View File
@@ -29,7 +29,7 @@
---
BackupX is a self-hosted backup management platform for **Linux / macOS servers**. Through an enterprise-grade Web console, you can easily configure directory backups, database backups, and securely store backup files to Alibaba Cloud OSS, Tencent Cloud COS, Qiniu Cloud Kodo, Google Drive, S3-compatible storage, WebDAV, or local disk.
BackupX is a self-hosted backup management platform for **Linux / macOS servers**. Through an enterprise-grade Web console, you can easily configure directory backups, database backups, and securely store backup files to Alibaba Cloud OSS, Tencent Cloud COS, Qiniu Cloud Kodo, Google Drive, S3-compatible storage, WebDAV, FTP/FTPS, or local disk.
Supports **multi-node cluster management** for unified control of backup tasks across different servers.
@@ -68,6 +68,7 @@ Supports **multi-node cluster management** for unified control of backup tasks a
- **MySQL** — Via native `mysqldump` tool
- **SQLite** — Safe file copy
- **PostgreSQL** — Via native `pg_dump` tool
- **SAP HANA** — Via native `hdbsql` tool (multi-tenant database support)
### ☁️ Multi-Cloud Storage Backends
| Provider | Type | Description |
@@ -78,6 +79,7 @@ Supports **multi-node cluster management** for unified control of backup tasks a
| 🌍 **S3 Compatible** | `s3` | AWS S3 / MinIO / Cloudflare R2, etc. |
| 🌍 **Google Drive** | `google_drive` | Full OAuth 2.0 flow |
| 🌍 **WebDAV** | `webdav` | Nextcloud / Nutstore, etc. |
| 🌍 **FTP / FTPS** | `ftp` | Standard FTP protocol with Explicit TLS support |
| 💾 **Local Disk** | `local_disk` | Backup to local server directory |
> Chinese cloud providers only require **Region** and **AccessKey** — the system auto-assembles the endpoint. Powered by the S3 engine under the hood with zero extra dependencies.
@@ -110,10 +112,30 @@ Supports **multi-node cluster management** for unified control of backup tasks a
### 🌐 Other
- Chinese & English i18n
- Zero external dependencies (embedded SQLite, single binary deployment)
- Docker / Docker Compose one-click deployment
- systemd service support
## Quick Start
### Docker Deployment (Recommended)
```bash
# Clone the project
git clone https://github.com/Awuqing/BackupX.git
cd BackupX
# Start with one command
docker compose up -d
```
To back up host directories, mount them in `docker-compose.yml`:
```yaml
volumes:
- backupx-data:/app/data
- /path/to/backup/source:/mnt/source:ro
```
### Build from Source
```bash
@@ -189,6 +211,7 @@ log:
│ │Scheduler │ │ │ S3 Compatible │ ││
│ └──────────┘ │ │ Google Drive │ ││
│ │ │ WebDAV │ ││
│ │ │ FTP / FTPS │ ││
│ ┌──────────┐ │ │ Local Disk │ ││
│ │ Notify │ │ └─────────────────┘ ││
│ │ Module │ └───────────────────────┘│
@@ -212,7 +235,7 @@ log:
|-----------|-----------|
| **Backend** | Go · Gin · GORM · SQLite · robfig/cron |
| **Frontend** | React 18 · TypeScript · ArcoDesign · Vite · Zustand · ECharts |
| **Storage** | AWS SDK v2 (S3/OSS/COS/Kodo) · Google Drive API v3 · gowebdav |
| **Storage** | AWS SDK v2 (S3/OSS/COS/Kodo) · Google Drive API v3 · gowebdav · jlaffaye/ftp |
| **Security** | JWT · bcrypt · AES-256-GCM |
| **Logging** | zap + lumberjack (auto-rotation) |
@@ -250,7 +273,7 @@ BackupX/
│ ├── internal/
│ │ ├── app/ # App assembly (DI)
│ │ ├── apperror/ # Unified error types
│ │ ├── backup/ # Backup engine (file/mysql/sqlite/pgsql)
│ │ ├── backup/ # Backup engine (file/mysql/sqlite/pgsql/saphana)
│ │ │ └── retention/ # Retention policy
│ │ ├── config/ # Config loading (viper)
│ │ ├── database/ # Database init + migrations
@@ -273,6 +296,7 @@ BackupX/
│ │ ├── webdav/ # WebDAV core
│ │ ├── webdavprovider/ # WebDAV Provider helper
│ │ ├── localdisk/ # Local disk
│ │ ├── ftp/ # FTP / FTPS
│ │ └── codec/ # Config codec
│ └── pkg/ # Utilities (compress/crypto/response)
├── web/ # React frontend
@@ -299,11 +323,16 @@ BackupX/
├── deploy/ # Deployment configs
│ ├── nginx.conf # Nginx reference config
│ ├── backupx.service # systemd service unit
── install.sh # One-click install script
── install.sh # One-click install script
│ └── docker/ # Docker deployment configs
│ ├── nginx.conf # In-container Nginx config
│ └── entrypoint.sh # Container entrypoint script
├── .github/ # GitHub configuration
│ ├── workflows/ci.yml # CI workflow
│ ├── workflows/release.yml # Release workflow
│ └── ISSUE_TEMPLATE/ # Issue templates
├── Dockerfile # Docker multi-stage build
├── docker-compose.yml # Docker Compose config
└── Makefile # Build commands
```
@@ -368,6 +397,29 @@ The install script will automatically:
5. Register and start the systemd service
6. Configure Nginx reverse proxy (if installed)
### Docker Deployment
```bash
# Using docker compose
docker compose up -d
# Or build and run manually
docker build -t backupx .
docker run -d --name backupx -p 8340:8340 -v backupx-data:/app/data backupx
```
Override configuration via environment variables:
```bash
docker run -d --name backupx \
-p 8340:8340 \
-v backupx-data:/app/data \
-e TZ=Asia/Shanghai \
-e BACKUPX_LOG_LEVEL=debug \
-e BACKUPX_BACKUP_MAX_CONCURRENT=4 \
backupx
```
### Manual Deployment
```bash
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
set -e
# Backend listens on internal port 8341, Nginx exposes 8340
export BACKUPX_SERVER_PORT="${BACKUPX_SERVER_PORT_INTERNAL:-8341}"
# Start Nginx in background
nginx -g "daemon off;" &
NGINX_PID=$!
# Start BackupX backend
/app/bin/backupx &
APP_PID=$!
# Trap signals for graceful shutdown
trap 'kill $APP_PID $NGINX_PID 2>/dev/null; wait $APP_PID $NGINX_PID 2>/dev/null' SIGTERM SIGINT
echo "BackupX started — Nginx :8340 -> Backend :8341"
# Wait for either process to exit
wait -n $APP_PID $NGINX_PID 2>/dev/null || true
kill $APP_PID $NGINX_PID 2>/dev/null || true
wait $APP_PID $NGINX_PID 2>/dev/null || true
+32
View File
@@ -0,0 +1,32 @@
server {
listen 8340;
server_name _;
root /app/web;
index index.html;
# API reverse proxy to backend
location /api/ {
proxy_pass http://127.0.0.1:8341/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Static assets cache
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
+21
View File
@@ -0,0 +1,21 @@
services:
backupx:
build: .
image: backupx:latest
container_name: backupx
restart: unless-stopped
ports:
- "8340:8340"
volumes:
- backupx-data:/app/data
# Mount host directories that need to be backed up (example):
# - /path/to/backup/source:/mnt/source:ro
environment:
- TZ=Asia/Shanghai
# Override any config via BACKUPX_ prefixed env vars:
# - BACKUPX_SERVER_PORT=8340
# - BACKUPX_LOG_LEVEL=info
# - BACKUPX_BACKUP_MAX_CONCURRENT=2
volumes:
backupx-data:
+219 -69
View File
@@ -1,16 +1,20 @@
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 the hdbsql CLI tool to execute SQL-based backup/restore operations.
// 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
}
@@ -28,24 +32,30 @@ func (r *SAPHANARunner) Type() string {
return "saphana"
}
// Run executes a SAP HANA backup using hdbsql.
// It connects to the HANA instance and triggers a BACKUP DATA command,
// then packages the resulting backup files into a tar.gz archive.
// 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)")
}
tempDir, artifactPath, err := createTempArtifact(task.TempDir, task.Name, "sql")
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
}
file, err := os.Create(artifactPath)
if err != nil {
return nil, fmt.Errorf("create SAP HANA dump file: %w", 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)
}
defer file.Close()
dbNames := normalizeDatabaseNames(task.Database.Names)
tenantDB := "SYSTEMDB"
@@ -61,78 +71,56 @@ func (r *SAPHANARunner) Run(ctx context.Context, task TaskSpec, writer LogWriter
writer.WriteLine(fmt.Sprintf("连接到 SAP HANA: %s:%d", task.Database.Host, port))
writer.WriteLine(fmt.Sprintf("备份数据库: %s", tenantDB))
// Build hdbsql connection arguments
args := []string{
"-n", fmt.Sprintf("%s:%d", task.Database.Host, port),
"-u", task.Database.User,
"-p", task.Database.Password,
"-d", tenantDB,
"-j", // disable auto-commit
"-A", // disable column alignment
"-xC", // suppress column headers and separator
// 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)
}
// Export schema using SELECT statements for each table.
// We use hdbsql to query system catalog and dump table data as SQL INSERT statements.
exportSQL := fmt.Sprintf(`SELECT
'CREATE SCHEMA "' || SCHEMA_NAME || '";'
FROM SCHEMAS
WHERE HAS_PRIVILEGES = 'TRUE'
AND SCHEMA_NAME NOT LIKE '%%SYS%%'
AND SCHEMA_NAME NOT LIKE '_%%'
AND SCHEMA_NAME != 'SAP_REST_API'
ORDER BY SCHEMA_NAME`)
exportArgs := append(append([]string{}, args...), exportSQL)
// 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 数据导出")
writer.WriteLine("开始执行 SAP HANA BACKUP DATA USING FILE")
if err := r.executor.Run(ctx, "hdbsql", exportArgs, CommandOptions{
Stdout: file,
if err := r.executor.Run(ctx, "hdbsql", args, CommandOptions{
Stderr: stderrWriter,
}); err != nil {
return nil, fmt.Errorf("run hdbsql export: %w: %s", err, stderrWriter.collected())
return nil, fmt.Errorf("run hdbsql BACKUP DATA: %w: %s", err, stderrWriter.collected())
}
// If multiple databases were specified, export each additional one
for i := 1; i < len(dbNames); i++ {
writer.WriteLine(fmt.Sprintf("导出额外数据库: %s", dbNames[i]))
if _, writeErr := file.WriteString(fmt.Sprintf("\n-- Database: %s\n", dbNames[i])); writeErr != nil {
return nil, fmt.Errorf("write database separator: %w", writeErr)
}
writer.WriteLine("SAP HANA BACKUP DATA 命令执行完成,开始打包备份文件")
additionalArgs := []string{
"-n", fmt.Sprintf("%s:%d", task.Database.Host, port),
"-u", task.Database.User,
"-p", task.Database.Password,
"-d", dbNames[i],
"-j", "-A", "-xC",
exportSQL,
}
if err := r.executor.Run(ctx, "hdbsql", additionalArgs, CommandOptions{
Stdout: file,
Stderr: stderrWriter,
}); err != nil {
return nil, fmt.Errorf("run hdbsql export for %s: %w", dbNames[i], err)
}
// 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, _ := file.Stat()
info, _ := os.Stat(artifactPath)
sizeStr := "未知"
var fileSize int64
if info != nil {
sizeStr = formatFileSize(info.Size())
fileSize = info.Size()
sizeStr = formatFileSize(fileSize)
}
writer.WriteLine(fmt.Sprintf("SAP HANA 导出完成(文件大小: %s", sizeStr))
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 hdbsql to replay the SQL dump file.
// 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)")
@@ -151,27 +139,39 @@ func (r *SAPHANARunner) Restore(ctx context.Context, task TaskSpec, artifactPath
writer.WriteLine(fmt.Sprintf("开始恢复 SAP HANA 数据库: %s", tenantDB))
input, err := os.Open(filepath.Clean(artifactPath))
// Extract the tar archive to a temporary directory.
restoreDir, err := os.MkdirTemp("", "backupx-hana-restore-*")
if err != nil {
return fmt.Errorf("open SAP HANA restore file: %w", err)
return fmt.Errorf("create restore temp dir: %w", err)
}
defer input.Close()
defer os.RemoveAll(restoreDir)
args := []string{
"-n", fmt.Sprintf("%s:%d", task.Database.Host, port),
"-u", task.Database.User,
"-p", task.Database.Password,
"-d", tenantDB,
"-j",
"-I", artifactPath,
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 restore: %w: %s", err, strings.TrimSpace(errMsg))
return fmt.Errorf("run hdbsql RECOVER DATA: %w: %s", err, strings.TrimSpace(errMsg))
}
writer.WriteLine("SAP HANA 恢复完成")
@@ -187,3 +187,153 @@ func hanaInstanceNumber(port int) string {
}
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
}
@@ -0,0 +1,294 @@
package backup
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestSAPHANARunnerRun_BackupDataCommand(t *testing.T) {
var capturedArgs []string
executor := &fakeCommandExecutor{
runFunc: func(name string, args []string, options CommandOptions) error {
capturedArgs = append([]string{}, args...)
// Simulate HANA creating backup data files in the directory from the SQL.
// Parse the backup prefix from the SQL argument (last arg).
sql := args[len(args)-1]
// Extract path from: BACKUP DATA USING FILE ('/path/to/hana_systemdb_...')
startIdx := strings.Index(sql, "('") + 2
endIdx := strings.Index(sql, "')")
if startIdx > 1 && endIdx > startIdx {
prefix := sql[startIdx:endIdx]
dir := filepath.Dir(prefix)
_ = os.MkdirAll(dir, 0o755)
// Create fake backup data files that HANA would produce.
_ = os.WriteFile(prefix+"_databackup_0_1", []byte("fake backup data volume 0"), 0o644)
_ = os.WriteFile(prefix+"_databackup_1_1", []byte("fake backup data volume 1"), 0o644)
}
return nil
},
}
runner := NewSAPHANARunner(executor)
result, err := runner.Run(context.Background(), TaskSpec{
Name: "hana-daily",
Type: "saphana",
Database: DatabaseSpec{
Host: "10.0.0.1",
Port: 30015,
User: "SYSTEM",
Password: "secret",
Names: []string{"SYSTEMDB"},
},
}, NopLogWriter{})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
// Verify hdbsql was called with the correct connection args.
if len(capturedArgs) == 0 {
t.Fatal("expected hdbsql args to be captured")
}
// Check host:port
foundHost := false
for i, arg := range capturedArgs {
if arg == "-n" && i+1 < len(capturedArgs) && capturedArgs[i+1] == "10.0.0.1:30015" {
foundHost = true
}
}
if !foundHost {
t.Fatalf("expected host:port 10.0.0.1:30015 in args, got: %v", capturedArgs)
}
// Verify the SQL contains BACKUP DATA USING FILE.
lastArg := capturedArgs[len(capturedArgs)-1]
if !strings.Contains(lastArg, "BACKUP DATA USING FILE") {
t.Fatalf("expected BACKUP DATA USING FILE in SQL, got: %s", lastArg)
}
// Verify artifact is a tar file.
if !strings.HasSuffix(result.ArtifactPath, ".tar") {
t.Fatalf("expected .tar artifact, got: %s", result.ArtifactPath)
}
// Verify artifact file exists and has content.
info, err := os.Stat(result.ArtifactPath)
if err != nil {
t.Fatalf("artifact file missing: %v", err)
}
if info.Size() == 0 {
t.Fatal("artifact tar file is empty")
}
// Cleanup.
os.RemoveAll(result.TempDir)
}
func TestSAPHANARunnerRun_TenantDatabase(t *testing.T) {
var capturedSQL string
executor := &fakeCommandExecutor{
runFunc: func(name string, args []string, options CommandOptions) error {
capturedSQL = args[len(args)-1]
// Simulate HANA creating backup files.
startIdx := strings.Index(capturedSQL, "('") + 2
endIdx := strings.Index(capturedSQL, "')")
if startIdx > 1 && endIdx > startIdx {
prefix := capturedSQL[startIdx:endIdx]
_ = os.MkdirAll(filepath.Dir(prefix), 0o755)
_ = os.WriteFile(prefix+"_databackup_0_1", []byte("data"), 0o644)
}
return nil
},
}
runner := NewSAPHANARunner(executor)
result, err := runner.Run(context.Background(), TaskSpec{
Name: "hana-tenant",
Type: "saphana",
Database: DatabaseSpec{
Host: "10.0.0.1",
Port: 30015,
User: "SYSTEM",
Password: "secret",
Names: []string{"HDB"},
},
}, NopLogWriter{})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
defer os.RemoveAll(result.TempDir)
// For tenant databases, the SQL should use BACKUP DATA FOR <tenant>.
if !strings.Contains(capturedSQL, "BACKUP DATA FOR HDB USING FILE") {
t.Fatalf("expected BACKUP DATA FOR HDB in SQL, got: %s", capturedSQL)
}
}
func TestSAPHANARunnerRun_DefaultPort(t *testing.T) {
var capturedArgs []string
executor := &fakeCommandExecutor{
runFunc: func(name string, args []string, options CommandOptions) error {
capturedArgs = append([]string{}, args...)
sql := args[len(args)-1]
startIdx := strings.Index(sql, "('") + 2
endIdx := strings.Index(sql, "')")
if startIdx > 1 && endIdx > startIdx {
prefix := sql[startIdx:endIdx]
_ = os.MkdirAll(filepath.Dir(prefix), 0o755)
_ = os.WriteFile(prefix+"_databackup_0_1", []byte("data"), 0o644)
}
return nil
},
}
runner := NewSAPHANARunner(executor)
result, err := runner.Run(context.Background(), TaskSpec{
Name: "hana-default-port",
Type: "saphana",
Database: DatabaseSpec{
Host: "localhost",
Port: 0, // Should default to 30015
User: "SYSTEM",
Password: "secret",
},
}, NopLogWriter{})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
defer os.RemoveAll(result.TempDir)
// Verify default port 30015 was used.
for i, arg := range capturedArgs {
if arg == "-n" && i+1 < len(capturedArgs) {
if !strings.HasSuffix(capturedArgs[i+1], ":30015") {
t.Fatalf("expected default port 30015, got: %s", capturedArgs[i+1])
}
}
}
}
func TestSAPHANARunnerRun_LookPathError(t *testing.T) {
runner := NewSAPHANARunner(&fakeCommandExecutor{lookupErr: errors.New("not found")})
_, err := runner.Run(context.Background(), TaskSpec{
Name: "hana-missing",
Type: "saphana",
Database: DatabaseSpec{
Host: "10.0.0.1", Port: 30015, User: "SYSTEM", Password: "secret",
},
}, NopLogWriter{})
if err == nil {
t.Fatal("expected error when hdbsql is missing")
}
if !strings.Contains(err.Error(), "hdbsql") {
t.Fatalf("error should mention hdbsql, got: %v", err)
}
}
func TestSAPHANARunnerRestore_RecoverDataCommand(t *testing.T) {
// First, create a fake tar archive with a backup data file.
tarDir := t.TempDir()
dataDir := filepath.Join(tarDir, "hana_data")
_ = os.MkdirAll(dataDir, 0o755)
prefix := filepath.Join(dataDir, "hana_systemdb_20260324_120000")
_ = os.WriteFile(prefix+"_databackup_0_1", []byte("backup data"), 0o644)
// Create the tar.
tarPath := filepath.Join(tarDir, "backup.tar")
if err := packageBackupFiles(dataDir, tarPath, NopLogWriter{}); err != nil {
t.Fatalf("failed to create test tar: %v", err)
}
var capturedSQL string
executor := &fakeCommandExecutor{
runFunc: func(name string, args []string, options CommandOptions) error {
capturedSQL = args[len(args)-1]
return nil
},
}
runner := NewSAPHANARunner(executor)
err := runner.Restore(context.Background(), TaskSpec{
Name: "hana-restore",
Type: "saphana",
Database: DatabaseSpec{
Host: "10.0.0.1", Port: 30015, User: "SYSTEM", Password: "secret",
Names: []string{"SYSTEMDB"},
},
}, tarPath, NopLogWriter{})
if err != nil {
t.Fatalf("Restore returned error: %v", err)
}
if !strings.Contains(capturedSQL, "RECOVER DATA USING FILE") {
t.Fatalf("expected RECOVER DATA USING FILE in SQL, got: %s", capturedSQL)
}
if !strings.Contains(capturedSQL, "CLEAR LOG") {
t.Fatalf("expected CLEAR LOG in SQL, got: %s", capturedSQL)
}
}
func TestSAPHANARunnerRestore_TenantRecoverCommand(t *testing.T) {
tarDir := t.TempDir()
dataDir := filepath.Join(tarDir, "data")
_ = os.MkdirAll(dataDir, 0o755)
_ = os.WriteFile(filepath.Join(dataDir, "hana_hdb_20260324_120000_databackup_0_1"), []byte("data"), 0o644)
tarPath := filepath.Join(tarDir, "backup.tar")
if err := packageBackupFiles(dataDir, tarPath, NopLogWriter{}); err != nil {
t.Fatalf("failed to create test tar: %v", err)
}
var capturedSQL string
executor := &fakeCommandExecutor{
runFunc: func(name string, args []string, options CommandOptions) error {
capturedSQL = args[len(args)-1]
return nil
},
}
runner := NewSAPHANARunner(executor)
err := runner.Restore(context.Background(), TaskSpec{
Name: "hana-tenant-restore",
Type: "saphana",
Database: DatabaseSpec{
Host: "10.0.0.1", Port: 30015, User: "SYSTEM", Password: "secret",
Names: []string{"HDB"},
},
}, tarPath, NopLogWriter{})
if err != nil {
t.Fatalf("Restore returned error: %v", err)
}
if !strings.Contains(capturedSQL, "RECOVER DATA FOR HDB USING FILE") {
t.Fatalf("expected RECOVER DATA FOR HDB in SQL, got: %s", capturedSQL)
}
}
func TestHanaInstanceNumber(t *testing.T) {
tests := []struct {
port int
expected string
}{
{30015, "0"},
{30115, "1"},
{30215, "2"},
{31015, "10"},
{25000, "00"},
{40001, "00"},
}
for _, tc := range tests {
got := hanaInstanceNumber(tc.port)
if got != tc.expected {
t.Errorf("hanaInstanceNumber(%d) = %s, want %s", tc.port, got, tc.expected)
}
}
}