Compare commits
9 Commits
V1.0.0
...
feat/sapha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29dba71b53 | ||
|
|
6118d5e779 | ||
|
|
0330dd4700 | ||
|
|
9bfde8c632 | ||
|
|
f7adf30079 | ||
|
|
da2c87d7b0 | ||
|
|
9dbe27590a | ||
|
|
e02a1bb074 | ||
|
|
5668f89972 |
50
README.md
@@ -16,25 +16,51 @@
|
||||
<a href="#api-reference">API</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/Awuqing/GoogleDriverBackupEveryDay/stargazers"><img src="https://img.shields.io/github/stars/Awuqing/GoogleDriverBackupEveryDay?style=flat-square&color=f5c542" alt="Stars"></a>
|
||||
<a href="https://github.com/Awuqing/GoogleDriverBackupEveryDay/releases"><img src="https://img.shields.io/github/v/release/Awuqing/GoogleDriverBackupEveryDay?style=flat-square&color=brightgreen" alt="Release"></a>
|
||||
<a href="https://github.com/Awuqing/BackupX/stargazers"><img src="https://img.shields.io/github/stars/Awuqing/BackupX?style=flat-square&color=f5c542" alt="Stars"></a>
|
||||
<a href="https://github.com/Awuqing/BackupX/releases"><img src="https://img.shields.io/github/v/release/Awuqing/BackupX?style=flat-square&color=brightgreen" alt="Release"></a>
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat-square&logo=go" alt="Go">
|
||||
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
|
||||
<img src="https://img.shields.io/badge/TypeScript-5-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
|
||||
<img src="https://img.shields.io/badge/SQLite-embedded-003B57?style=flat-square&logo=sqlite" alt="SQLite">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Awuqing/GoogleDriverBackupEveryDay?style=flat-square" alt="License"></a>
|
||||
<a href="https://github.com/Awuqing/GoogleDriverBackupEveryDay/issues"><img src="https://img.shields.io/github/issues/Awuqing/GoogleDriverBackupEveryDay?style=flat-square" alt="Issues"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Awuqing/BackupX?style=flat-square" alt="License"></a>
|
||||
<a href="https://github.com/Awuqing/BackupX/issues"><img src="https://img.shields.io/github/issues/Awuqing/BackupX?style=flat-square" alt="Issues"></a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
BackupX 是一个面向 **Linux / macOS 服务器**的自托管备份管理平台。通过企业级 Web 控制台,轻松配置目录备份、数据库备份,并将备份文件安全存储到阿里云 OSS、腾讯云 COS、七牛云 Kodo、Google Drive、S3 兼容存储、WebDAV 或本地磁盘。
|
||||
BackupX 是一个面向 **Linux / macOS 服务器**的自托管备份管理平台。通过企业级 Web 控制台,轻松配置目录备份、数据库备份,并将备份文件安全存储到阿里云 OSS、腾讯云 COS、七牛云 Kodo、Google Drive、S3 兼容存储、WebDAV、FTP/FTPS 或本地磁盘。
|
||||
|
||||
支持 **多节点集群管理**,可统一管控分布在不同服务器上的备份任务。
|
||||
|
||||
> **适用人群**:拥有 Linux 服务器的个人开发者 / 小团队 / 企业运维
|
||||
|
||||
## Screenshots
|
||||
|
||||
### 登录页面
|
||||

|
||||
|
||||
### 仪表盘
|
||||

|
||||
|
||||
### 备份任务
|
||||

|
||||
|
||||
### 备份记录
|
||||

|
||||
|
||||
### 存储目标
|
||||

|
||||
|
||||
### 节点管理
|
||||

|
||||
|
||||
### 通知配置
|
||||

|
||||
|
||||
### 系统设置
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
### 📦 多种备份类型
|
||||
@@ -42,6 +68,7 @@ BackupX 是一个面向 **Linux / macOS 服务器**的自托管备份管理平
|
||||
- **MySQL** — 通过 `mysqldump` 原生工具
|
||||
- **SQLite** — 安全文件拷贝
|
||||
- **PostgreSQL** — 通过 `pg_dump` 原生工具
|
||||
- **SAP HANA** — 通过 `hdbsql` 原生工具(支持多租户数据库)
|
||||
|
||||
### ☁️ 多云存储后端
|
||||
| 厂商 | 类型 | 说明 |
|
||||
@@ -52,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 引擎零额外依赖。
|
||||
@@ -92,8 +120,8 @@ BackupX 是一个面向 **Linux / macOS 服务器**的自托管备份管理平
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/Awuqing/GoogleDriverBackupEveryDay.git
|
||||
cd GoogleDriverBackupEveryDay
|
||||
git clone https://github.com/Awuqing/BackupX.git
|
||||
cd BackupX
|
||||
|
||||
# 一键构建前后端
|
||||
make build
|
||||
@@ -162,6 +190,7 @@ log:
|
||||
│ │Scheduler │ │ │ S3 Compatible │ ││
|
||||
│ └──────────┘ │ │ Google Drive │ ││
|
||||
│ │ │ WebDAV │ ││
|
||||
│ │ │ FTP / FTPS │ ││
|
||||
│ ┌──────────┐ │ │ Local Disk │ ││
|
||||
│ │ Notify │ │ └─────────────────┘ ││
|
||||
│ │ Module │ └───────────────────────┘│
|
||||
@@ -185,7 +214,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 (自动轮转) |
|
||||
|
||||
@@ -217,13 +246,13 @@ Master 提供 `GET /api/nodes/:id/fs/list?path=/` 接口,可远程浏览节点
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
GoogleDriverBackupEveryDay/
|
||||
BackupX/
|
||||
├── server/ # Go 后端
|
||||
│ ├── cmd/backupx/ # 程序入口
|
||||
│ ├── internal/
|
||||
│ │ ├── app/ # 应用组装 (DI)
|
||||
│ │ ├── apperror/ # 统一错误类型
|
||||
│ │ ├── backup/ # 备份引擎 (file/mysql/sqlite/pgsql)
|
||||
│ │ ├── backup/ # 备份引擎 (file/mysql/sqlite/pgsql/saphana)
|
||||
│ │ │ └── retention/ # 保留策略
|
||||
│ │ ├── config/ # 配置加载 (viper)
|
||||
│ │ ├── database/ # 数据库初始化 + 迁移
|
||||
@@ -246,6 +275,7 @@ GoogleDriverBackupEveryDay/
|
||||
│ │ ├── webdav/ # WebDAV 核心
|
||||
│ │ ├── webdavprovider/ # WebDAV Provider 辅助
|
||||
│ │ ├── localdisk/ # 本地磁盘
|
||||
│ │ ├── ftp/ # FTP / FTPS
|
||||
│ │ └── codec/ # 配置编解码
|
||||
│ └── pkg/ # 工具包 (compress/crypto/response)
|
||||
├── web/ # React 前端
|
||||
|
||||
50
README_EN.md
@@ -16,25 +16,51 @@
|
||||
<a href="#api-reference">API</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/Awuqing/GoogleDriverBackupEveryDay/stargazers"><img src="https://img.shields.io/github/stars/Awuqing/GoogleDriverBackupEveryDay?style=flat-square&color=f5c542" alt="Stars"></a>
|
||||
<a href="https://github.com/Awuqing/GoogleDriverBackupEveryDay/releases"><img src="https://img.shields.io/github/v/release/Awuqing/GoogleDriverBackupEveryDay?style=flat-square&color=brightgreen" alt="Release"></a>
|
||||
<a href="https://github.com/Awuqing/BackupX/stargazers"><img src="https://img.shields.io/github/stars/Awuqing/BackupX?style=flat-square&color=f5c542" alt="Stars"></a>
|
||||
<a href="https://github.com/Awuqing/BackupX/releases"><img src="https://img.shields.io/github/v/release/Awuqing/BackupX?style=flat-square&color=brightgreen" alt="Release"></a>
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat-square&logo=go" alt="Go">
|
||||
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
|
||||
<img src="https://img.shields.io/badge/TypeScript-5-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
|
||||
<img src="https://img.shields.io/badge/SQLite-embedded-003B57?style=flat-square&logo=sqlite" alt="SQLite">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Awuqing/GoogleDriverBackupEveryDay?style=flat-square" alt="License"></a>
|
||||
<a href="https://github.com/Awuqing/GoogleDriverBackupEveryDay/issues"><img src="https://img.shields.io/github/issues/Awuqing/GoogleDriverBackupEveryDay?style=flat-square" alt="Issues"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/github/license/Awuqing/BackupX?style=flat-square" alt="License"></a>
|
||||
<a href="https://github.com/Awuqing/BackupX/issues"><img src="https://img.shields.io/github/issues/Awuqing/BackupX?style=flat-square" alt="Issues"></a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
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.
|
||||
|
||||
> **For**: Individual developers / small teams / DevOps with Linux servers
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Login
|
||||

|
||||
|
||||
### Dashboard
|
||||

|
||||
|
||||
### Backup Tasks
|
||||

|
||||
|
||||
### Backup Records
|
||||

|
||||
|
||||
### Storage Targets
|
||||

|
||||
|
||||
### Node Management
|
||||

|
||||
|
||||
### Notification Settings
|
||||

|
||||
|
||||
### System Settings
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
### 📦 Multiple Backup Types
|
||||
@@ -42,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 |
|
||||
@@ -52,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.
|
||||
@@ -92,8 +120,8 @@ Supports **multi-node cluster management** for unified control of backup tasks a
|
||||
|
||||
```bash
|
||||
# Clone the project
|
||||
git clone https://github.com/Awuqing/GoogleDriverBackupEveryDay.git
|
||||
cd GoogleDriverBackupEveryDay
|
||||
git clone https://github.com/Awuqing/BackupX.git
|
||||
cd BackupX
|
||||
|
||||
# Build frontend and backend
|
||||
make build
|
||||
@@ -163,6 +191,7 @@ log:
|
||||
│ │Scheduler │ │ │ S3 Compatible │ ││
|
||||
│ └──────────┘ │ │ Google Drive │ ││
|
||||
│ │ │ WebDAV │ ││
|
||||
│ │ │ FTP / FTPS │ ││
|
||||
│ ┌──────────┐ │ │ Local Disk │ ││
|
||||
│ │ Notify │ │ └─────────────────┘ ││
|
||||
│ │ Module │ └───────────────────────┘│
|
||||
@@ -186,7 +215,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) |
|
||||
|
||||
@@ -218,13 +247,13 @@ Master provides `GET /api/nodes/:id/fs/list?path=/` to remotely browse a node's
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
GoogleDriverBackupEveryDay/
|
||||
BackupX/
|
||||
├── server/ # Go backend
|
||||
│ ├── cmd/backupx/ # Entry point
|
||||
│ ├── 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
|
||||
@@ -247,6 +276,7 @@ GoogleDriverBackupEveryDay/
|
||||
│ │ ├── webdav/ # WebDAV core
|
||||
│ │ ├── webdavprovider/ # WebDAV Provider helper
|
||||
│ │ ├── localdisk/ # Local disk
|
||||
│ │ ├── ftp/ # FTP / FTPS
|
||||
│ │ └── codec/ # Config codec
|
||||
│ └── pkg/ # Utilities (compress/crypto/response)
|
||||
├── web/ # React frontend
|
||||
|
||||
BIN
screenshots/backup-records.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
screenshots/backup-tasks.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
screenshots/dashboard.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
screenshots/login.png
Normal file
|
After Width: | Height: | Size: 863 KiB |
BIN
screenshots/nodes.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
screenshots/notifications.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
screenshots/settings.png
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
screenshots/storage-targets.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
@@ -14,7 +14,7 @@ require (
|
||||
github.com/spf13/viper v1.20.0
|
||||
github.com/studio-b12/gowebdav v0.12.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.33.0
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/oauth2 v0.25.0
|
||||
google.golang.org/api v0.215.0
|
||||
gorm.io/gorm v1.25.12
|
||||
@@ -56,8 +56,11 @@ require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.0.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jlaffaye/ftp v0.2.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
@@ -80,9 +83,9 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.29.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect
|
||||
google.golang.org/grpc v1.67.3 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
|
||||
@@ -91,10 +91,16 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gT
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
|
||||
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
|
||||
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jlaffaye/ftp v0.2.0 h1:lXNvW7cBu7R/68bknOX3MrRIIqZ61zELs1P2RAiA3lg=
|
||||
github.com/jlaffaye/ftp v0.2.0/go.mod h1:is2Ds5qkhceAPy2xD6RLI6hmp/qysSoymZ+Z2uTnspI=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
@@ -174,20 +180,20 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
|
||||
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
google.golang.org/api v0.215.0 h1:jdYF4qnyczlEz2ReWIsosNLDuzXyvFHJtI5gcr0J7t0=
|
||||
google.golang.org/api v0.215.0/go.mod h1:fta3CVtuJYOEdugLNWm6WodzOS8KdFckABwN4I40hzY=
|
||||
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk=
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"backupx/server/internal/storage/googledrive"
|
||||
"backupx/server/internal/storage/localdisk"
|
||||
storageAliyun "backupx/server/internal/storage/aliyun"
|
||||
storageFTP "backupx/server/internal/storage/ftp"
|
||||
storageTencent "backupx/server/internal/storage/tencent"
|
||||
storageQiniu "backupx/server/internal/storage/qiniu"
|
||||
storageS3 "backupx/server/internal/storage/s3"
|
||||
@@ -76,12 +77,13 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
storageAliyun.NewFactory(),
|
||||
storageTencent.NewFactory(),
|
||||
storageQiniu.NewFactory(),
|
||||
storageFTP.NewFactory(),
|
||||
)
|
||||
storageTargetService := service.NewStorageTargetService(storageTargetRepo, oauthSessionRepo, storageRegistry, configCipher)
|
||||
storageTargetService.SetBackupTaskRepository(backupTaskRepo)
|
||||
storageTargetService.SetBackupRecordRepository(backupRecordRepo)
|
||||
backupTaskService := service.NewBackupTaskService(backupTaskRepo, storageTargetRepo, configCipher)
|
||||
backupRunnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil))
|
||||
backupRunnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil), backup.NewSAPHANARunner(nil))
|
||||
logHub := backup.NewLogHub()
|
||||
retentionService := backupretention.NewService(backupRecordRepo)
|
||||
notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier())
|
||||
|
||||
339
server/internal/backup/saphana_runner.go
Normal file
@@ -0,0 +1,339 @@
|
||||
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
|
||||
}
|
||||
294
server/internal/backup/saphana_runner_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
226
server/internal/storage/ftp/provider.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package ftp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/storage"
|
||||
|
||||
"github.com/jlaffaye/ftp"
|
||||
)
|
||||
|
||||
// Provider implements storage.StorageProvider for FTP.
|
||||
type Provider struct {
|
||||
config storage.FTPConfig
|
||||
}
|
||||
|
||||
// Factory creates FTP storage providers.
|
||||
type Factory struct{}
|
||||
|
||||
// NewFactory returns a new FTP Factory.
|
||||
func NewFactory() Factory {
|
||||
return Factory{}
|
||||
}
|
||||
|
||||
func (Factory) Type() storage.ProviderType { return storage.ProviderTypeFTP }
|
||||
func (Factory) SensitiveFields() []string { return []string{"username", "password"} }
|
||||
|
||||
func (f Factory) New(_ context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
|
||||
cfg, err := storage.DecodeConfig[storage.FTPConfig](rawConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(cfg.Host) == "" {
|
||||
return nil, fmt.Errorf("FTP host is required")
|
||||
}
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = 21
|
||||
}
|
||||
return &Provider{config: cfg}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Type() storage.ProviderType { return storage.ProviderTypeFTP }
|
||||
|
||||
// dial establishes a connection to the FTP server and logs in.
|
||||
func (p *Provider) dial() (*ftp.ServerConn, error) {
|
||||
addr := fmt.Sprintf("%s:%d", p.config.Host, p.config.Port)
|
||||
|
||||
var opts []ftp.DialOption
|
||||
opts = append(opts, ftp.DialWithTimeout(30*time.Second))
|
||||
if p.config.UseTLS {
|
||||
opts = append(opts, ftp.DialWithExplicitTLS(nil))
|
||||
}
|
||||
|
||||
conn, err := ftp.Dial(addr, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to FTP server %s: %w", addr, err)
|
||||
}
|
||||
|
||||
username := p.config.Username
|
||||
if username == "" {
|
||||
username = "anonymous"
|
||||
}
|
||||
if err := conn.Login(username, p.config.Password); err != nil {
|
||||
conn.Quit()
|
||||
return nil, fmt.Errorf("FTP login: %w", err)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (p *Provider) TestConnection(_ context.Context) error {
|
||||
conn, err := p.dial()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Quit()
|
||||
|
||||
basePath := p.normalizeBasePath()
|
||||
if err := p.ensureDir(conn, basePath); err != nil {
|
||||
return fmt.Errorf("ensure FTP base path: %w", err)
|
||||
}
|
||||
_, err = conn.List(basePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list FTP base path: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Upload(_ context.Context, objectKey string, reader io.Reader, _ int64, _ map[string]string) error {
|
||||
conn, err := p.dial()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Quit()
|
||||
|
||||
objectPath := p.resolvePath(objectKey)
|
||||
dir := path.Dir(objectPath)
|
||||
if err := p.ensureDir(conn, dir); err != nil {
|
||||
return fmt.Errorf("create FTP directories: %w", err)
|
||||
}
|
||||
|
||||
// Read all data into buffer since FTP STOR needs the full stream
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read upload data: %w", err)
|
||||
}
|
||||
|
||||
if err := conn.Stor(objectPath, bytes.NewReader(data)); err != nil {
|
||||
return fmt.Errorf("FTP upload: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) Download(_ context.Context, objectKey string) (io.ReadCloser, error) {
|
||||
conn, err := p.dial()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
objectPath := p.resolvePath(objectKey)
|
||||
resp, err := conn.Retr(objectPath)
|
||||
if err != nil {
|
||||
conn.Quit()
|
||||
return nil, fmt.Errorf("FTP download: %w", err)
|
||||
}
|
||||
|
||||
// Wrap the response to also close the FTP connection when done
|
||||
return &ftpReadCloser{ReadCloser: resp, conn: conn}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Delete(_ context.Context, objectKey string) error {
|
||||
conn, err := p.dial()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Quit()
|
||||
|
||||
objectPath := p.resolvePath(objectKey)
|
||||
if err := conn.Delete(objectPath); err != nil {
|
||||
return fmt.Errorf("FTP delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) List(_ context.Context, prefix string) ([]storage.ObjectInfo, error) {
|
||||
conn, err := p.dial()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Quit()
|
||||
|
||||
basePath := p.normalizeBasePath()
|
||||
entries, err := conn.List(basePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("FTP list: %w", err)
|
||||
}
|
||||
|
||||
items := make([]storage.ObjectInfo, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.Type == ftp.EntryTypeFolder {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimPrefix(path.Join(strings.TrimPrefix(basePath, "/"), entry.Name), "/")
|
||||
if prefix != "" && !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
items = append(items, storage.ObjectInfo{
|
||||
Key: key,
|
||||
Size: int64(entry.Size),
|
||||
UpdatedAt: entry.Time.UTC(),
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// normalizeBasePath returns a cleaned base path with leading slash.
|
||||
func (p *Provider) normalizeBasePath() string {
|
||||
clean := path.Clean("/" + strings.TrimSpace(p.config.BasePath))
|
||||
if clean == "." {
|
||||
return "/"
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
// resolvePath returns the full FTP path for the given object key.
|
||||
func (p *Provider) resolvePath(objectKey string) string {
|
||||
cleanKey := path.Clean("/" + strings.TrimSpace(objectKey))
|
||||
return path.Clean(path.Join(p.normalizeBasePath(), cleanKey))
|
||||
}
|
||||
|
||||
// ensureDir creates all directories in the path recursively.
|
||||
func (p *Provider) ensureDir(conn *ftp.ServerConn, dirPath string) error {
|
||||
parts := strings.Split(strings.Trim(dirPath, "/"), "/")
|
||||
current := ""
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
current = current + "/" + part
|
||||
if err := conn.MakeDir(current); err != nil {
|
||||
// Ignore errors if directory already exists
|
||||
// FTP doesn't have a standard "mkdir if not exists"
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ftpReadCloser wraps an io.ReadCloser from FTP and closes the connection when done.
|
||||
type ftpReadCloser struct {
|
||||
io.ReadCloser
|
||||
conn *ftp.ServerConn
|
||||
}
|
||||
|
||||
func (f *ftpReadCloser) Close() error {
|
||||
err := f.ReadCloser.Close()
|
||||
if f.conn != nil {
|
||||
f.conn.Quit()
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
ProviderTypeAliyunOSS ProviderType = "aliyun_oss"
|
||||
ProviderTypeTencentCOS ProviderType = "tencent_cos"
|
||||
ProviderTypeQiniuKodo ProviderType = "qiniu_kodo"
|
||||
ProviderTypeFTP ProviderType = "ftp"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -29,6 +30,7 @@ const (
|
||||
TypeAliyunOSS = string(ProviderTypeAliyunOSS)
|
||||
TypeTencentCOS = string(ProviderTypeTencentCOS)
|
||||
TypeQiniuKodo = string(ProviderTypeQiniuKodo)
|
||||
TypeFTP = string(ProviderTypeFTP)
|
||||
)
|
||||
|
||||
type ObjectInfo struct {
|
||||
@@ -118,3 +120,13 @@ func (cfg GoogleDriveConfig) Normalize() GoogleDriveConfig {
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
type FTPConfig struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
BasePath string `json:"basePath"`
|
||||
UseTLS bool `json:"useTLS"`
|
||||
}
|
||||
|
||||
|
||||
@@ -106,11 +106,11 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
type: value,
|
||||
sourcePath: value === 'file' ? current.sourcePath : '',
|
||||
excludePatterns: value === 'file' ? current.excludePatterns : [],
|
||||
dbHost: value === 'mysql' || value === 'postgresql' ? current.dbHost : '',
|
||||
dbPort: value === 'mysql' || value === 'postgresql' ? current.dbPort || getDefaultPort(value) : 0,
|
||||
dbUser: value === 'mysql' || value === 'postgresql' ? current.dbUser : '',
|
||||
dbPassword: value === 'mysql' || value === 'postgresql' ? current.dbPassword : '',
|
||||
dbName: value === 'mysql' || value === 'postgresql' ? current.dbName : '',
|
||||
dbHost: value === 'mysql' || value === 'postgresql' || value === 'saphana' ? current.dbHost : '',
|
||||
dbPort: value === 'mysql' || value === 'postgresql' || value === 'saphana' ? current.dbPort || getDefaultPort(value) : 0,
|
||||
dbUser: value === 'mysql' || value === 'postgresql' || value === 'saphana' ? current.dbUser : '',
|
||||
dbPassword: value === 'mysql' || value === 'postgresql' || value === 'saphana' ? current.dbPassword : '',
|
||||
dbName: value === 'mysql' || value === 'postgresql' || value === 'saphana' ? current.dbName : '',
|
||||
dbPath: value === 'sqlite' ? current.dbPath : '',
|
||||
}))
|
||||
if (value !== 'file') {
|
||||
|
||||
@@ -5,6 +5,7 @@ export const backupTaskTypeOptions = [
|
||||
{ label: 'MySQL', value: 'mysql' },
|
||||
{ label: 'SQLite', value: 'sqlite' },
|
||||
{ label: 'PostgreSQL', value: 'postgresql' },
|
||||
{ label: 'SAP HANA', value: 'saphana' },
|
||||
] as const
|
||||
|
||||
export const backupCompressionOptions = [
|
||||
@@ -22,6 +23,8 @@ export function getBackupTaskTypeLabel(type: BackupTaskType) {
|
||||
return 'SQLite'
|
||||
case 'postgresql':
|
||||
return 'PostgreSQL'
|
||||
case 'saphana':
|
||||
return 'SAP HANA'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
@@ -64,7 +67,7 @@ export function isSQLiteBackupTask(type: BackupTaskType) {
|
||||
}
|
||||
|
||||
export function isDatabaseBackupTask(type: BackupTaskType) {
|
||||
return type === 'mysql' || type === 'postgresql'
|
||||
return type === 'mysql' || type === 'postgresql' || type === 'saphana'
|
||||
}
|
||||
|
||||
export function getDefaultPort(type: BackupTaskType) {
|
||||
@@ -73,6 +76,8 @@ export function getDefaultPort(type: BackupTaskType) {
|
||||
return 3306
|
||||
case 'postgresql':
|
||||
return 5432
|
||||
case 'saphana':
|
||||
return 30015
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -216,6 +216,50 @@ const FIELD_CONFIG_MAP: Record<StorageTargetType, StorageTargetFieldConfig[]> =
|
||||
placeholder: '输入新的 SecretKey',
|
||||
},
|
||||
],
|
||||
ftp: [
|
||||
{
|
||||
key: 'host',
|
||||
label: '主机地址',
|
||||
type: 'input',
|
||||
required: true,
|
||||
placeholder: 'ftp.example.com',
|
||||
},
|
||||
{
|
||||
key: 'port',
|
||||
label: '端口',
|
||||
type: 'input',
|
||||
placeholder: '21',
|
||||
description: '默认 FTP 端口为 21。',
|
||||
},
|
||||
{
|
||||
key: 'username',
|
||||
label: '用户名',
|
||||
type: 'input',
|
||||
required: true,
|
||||
placeholder: 'backup_user',
|
||||
},
|
||||
{
|
||||
key: 'password',
|
||||
label: '密码',
|
||||
type: 'password',
|
||||
required: true,
|
||||
sensitive: true,
|
||||
placeholder: '输入新的 FTP 密码',
|
||||
},
|
||||
{
|
||||
key: 'basePath',
|
||||
label: '基础目录',
|
||||
type: 'input',
|
||||
placeholder: '/backups',
|
||||
description: 'FTP 服务器上的目标目录,留空使用根目录。',
|
||||
},
|
||||
{
|
||||
key: 'useTLS',
|
||||
label: '使用 TLS (FTPS)',
|
||||
type: 'switch',
|
||||
description: '启用 Explicit TLS 加密连接。',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export function getStorageTargetFieldConfigs(type: StorageTargetType) {
|
||||
@@ -238,6 +282,8 @@ export function getStorageTargetTypeLabel(type: StorageTargetType) {
|
||||
return '腾讯云 COS'
|
||||
case 'qiniu_kodo':
|
||||
return '七牛云 Kodo'
|
||||
case 'ftp':
|
||||
return 'FTP'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
@@ -251,4 +297,5 @@ export const storageTargetTypeOptions = [
|
||||
{ label: 'S3 Compatible', value: 's3' },
|
||||
{ label: 'Google Drive', value: 'google_drive' },
|
||||
{ label: 'WebDAV', value: 'webdav' },
|
||||
{ label: 'FTP', value: 'ftp' },
|
||||
] as const
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type BackupTaskType = 'file' | 'mysql' | 'sqlite' | 'postgresql'
|
||||
export type BackupTaskType = 'file' | 'mysql' | 'sqlite' | 'postgresql' | 'saphana'
|
||||
export type BackupTaskStatus = 'idle' | 'running' | 'success' | 'failed'
|
||||
export type BackupCompression = 'gzip' | 'none'
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type StorageTargetType = 'local_disk' | 'google_drive' | 's3' | 'webdav' | 'aliyun_oss' | 'tencent_cos' | 'qiniu_kodo'
|
||||
export type StorageTargetType = 'local_disk' | 'google_drive' | 's3' | 'webdav' | 'aliyun_oss' | 'tencent_cos' | 'qiniu_kodo' | 'ftp'
|
||||
export type StorageTestStatus = 'unknown' | 'success' | 'failed'
|
||||
export type StorageFieldType = 'input' | 'password' | 'switch'
|
||||
|
||||
|
||||